Skip to content

feat(control-plane): agent-mode steering verbs, events query resource, and --json lifecycle output#751

Open
AbirAbbas wants to merge 7 commits into
mainfrom
feat/agent-cli-gaps
Open

feat(control-plane): agent-mode steering verbs, events query resource, and --json lifecycle output#751
AbirAbbas wants to merge 7 commits into
mainfrom
feat/agent-cli-gaps

Conversation

@AbirAbbas

@AbirAbbas AbirAbbas commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Closes the gaps that stop an AI agent (e.g. Claude Code) from fully driving AgentField through the CLI: first-class steering verbs in agent mode, a pollable events resource, and JSON envelopes for the node-lifecycle commands.

What's in here

  • af agent exec pause|resume|cancel|restart|approval-status|approve --id <execution_id> — thin wrappers over the existing /api/v1/executions/:id/* endpoints, emitting exactly one {ok, data|error{code,message,hint}, meta} envelope on stdout, exit 0 on 2xx / 1 otherwise.
  • Authenticated approval resolution: new POST /api/v1/executions/:execution_id/approval-response ({decision, reason?, response?}) under normal API auth, sharing the exact resolution logic with the HMAC webhook via an extracted applyApprovalDecision (same state transitions, idempotency, events, callbacks). Listed in the discover catalog.
  • Events as a query resource: af agent query -r events --execution-id X | --run-id Y [--since --until --limit --offset] — exposes the already-persisted workflow_execution_events, time-sorted, no new persistence layer. Requires an execution or run filter (400 missing_filter otherwise).
  • --json for af list / install / run / stop / logs — same envelope shape; default human output is byte-identical to before. logs --json returns {node, log_path, lines} honoring --tail; --json --follow is rejected.
  • Load-bearing storage fix (cfac3b45): the raw INSERT for workflow_execution_events omitted recorded_at, so every stored event row had NULL and ListWorkflowExecutionEvents failed on scan — in both SQLite and Postgres modes (an existing test masked it with a manual UPDATE). Without this fix the events resource 500s on every query; it also fixes the latent bug for any other consumer.

Validation contract

  • Each exec verb: valid id → envelope with ok:true + status data; nonexistent id → {ok:false, error:{code:"not_found", …}} + exit 1; invalid state → invalid_state (normalized from the server's string errors); missing/invalid flags → missing_required_flag / invalid_flag_value without a server round-trip.
  • Approve: pending approval → processed + execution resumes; second approve → 200 already_processed; no approval request → 404 no_approval_request; expired is deliberately not accepted as an operator decision (webhook-only).
  • Events: sorted by emitted_at asc (tie: sequence); since/until are RFC3339 bounds; total reflects the pre-pagination count; future since → empty list, not an error.
  • Lifecycle --json: stdout is pure JSON (service progress prints redirected to stderr); non-zero exit on error.

Table-driven tests derive from this contract (httptest fake servers for the CLI, in-process tests for error normalization, handler tests for the events query).

Runtime verification

Exercised end-to-end against a local server (isolated SQLite/BoltDB): create execution → request-approval → exec approval-status (pending) → exec approve (processed, running) → idempotent re-approve → pause/resume/cancel → query -r events returned the full 5-event lifecycle time-sorted; run-id fan-out, limit/offset, error paths, and pure-JSON stdout all verified.

Notes for review

  • TestDevServiceRunDev fails on the dev machine only (a live local agent inside the port-scan range interferes); it fails identically at base 6b8b404e and this diff touches nothing in that package — CI should be green.
  • stop.go changes are output-layer only; feat(desktop): Electron desktop app skeleton + Windows enablement groundwork #752 refactors the signal/liveness code in the same file into build-tagged helpers — whichever merges second needs a trivial rebase.
  • install/run --json redirect service stdout to stderr via an os.Stdout swap; flagged in case a cleaner output-formatter seam is wanted later.

🤖 Generated with Claude Code

AbirAbbas and others added 7 commits July 10, 2026 15:29
…ecution ID

Approvals could previously only be resolved through the HMAC-signed
webhook (POST /api/v1/webhooks/approval-response), whose secret is held
by the external approval service. Extract the resolution core
(idempotency, state transitions, approval bookkeeping, events, agent
callback) into webhookApprovalController.applyApprovalDecision and add
POST /api/v1/executions/:execution_id/approval-response under the
authenticated agentAPI group, so operators and CLIs holding an API key
can approve/reject/request_changes directly. Webhook behavior is
unchanged; 'expired' stays webhook-only since expiry is not an operator
decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add 'af agent exec pause|resume|cancel|restart|approval-status|approve'
as thin wrappers over the existing /api/v1/executions/:execution_id/*
endpoints, emitting the standard AgentResponse envelope with non-zero
exit on error. approve wires --decision approved|rejected|
request_changes [--reason] to the new authenticated approval-response
endpoint.

proxyToServer now normalizes legacy string errors ({"error":"..."},
optionally with a sibling message) into the structured envelope error
object {code,message,hint}, deriving the code from the HTTP status when
the handler didn't provide one — so agents scripting against 'af agent'
always get {ok:false,error:{...}} on failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Expose persisted execution lifecycle events (workflow_execution_events)
through POST /api/v1/agentic/query as resource=events so agents can
poll event history from a shell loop instead of consuming SSE. Queries
require filters.execution_id (direct per-execution listing) or
filters.run_id (fan-out over the run's execution records); results are
sorted by emitted_at ascending with sequence tie-breaking, honor
since/until RFC3339 bounds, and paginate with limit/offset (total is
the pre-pagination count). No new persistence layer or storage
interface methods — this composes the existing QueryExecutionRecords
and ListWorkflowExecutionEvents queries.

CLI: 'af agent query -r events --execution-id X | --run-id Y' with a
new --execution-id filter flag; help documents that this is a pollable
snapshot of the SSE stream, not a live subscription.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a --json flag to af list, af install, af run, af stop, and af logs
that emits the agent-mode {ok,data,error:{code,message,hint}} envelope
on stdout with non-zero exit on error. Default human output is
unchanged.

- list: {nodes:[{name,version,status,port,description}], total}
- install/run: envelope on the real stdout; service-layer progress
  prints are redirected to stderr for the duration
- stop: {node, status: stopped|not_running}; progress output is
  suppressed via a Quiet flag on AgentNodeStopper (printf wrapper only,
  no changes to the signal/liveness logic)
- logs: {node, log_path, lines:[...]} honoring --tail via a Go-native
  tail (no external tail binary); --follow with --json is rejected as
  invalid_flags since a stream cannot be a JSON snapshot

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The raw INSERT in storeWorkflowExecutionEventTx omitted recorded_at
(the GORM model's autoCreateTime does not apply to raw SQL), leaving
NULL in SQLite/Postgres. ListWorkflowExecutionEvents scanned the column
into a non-nullable time.Time, so listing any stored event failed with
'unsupported Scan, storing driver.Value type <nil>'. Nothing exercised
this listing path end-to-end until the agentic events query resource;
the existing storage test masked the bug with a manual UPDATE of
recorded_at before listing.

Write recorded_at in the INSERT and scan it through sql.NullTime,
falling back to emitted_at for legacy NULL rows so old databases keep
listing cleanly. Found via runtime verification of
'af agent query -r events' against a local control plane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
structuredErrorFromString and defaultCodeForStatus were exercised only
through subprocess exit-path tests, which record no coverage. Add
direct table-driven tests so the patch-coverage gate sees these lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Register POST /api/v1/executions/:execution_id/approval-response in the
agentic API catalog so 'af agent discover' surfaces the new resolution
endpoint alongside the other approval routes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 87.00% 87.40% ↓ -0.40 pp 🟡
sdk-go 91.90% 92.00% ↓ -0.10 pp 🟢
sdk-python 93.76% 93.73% ↑ +0.03 pp 🟢
sdk-typescript 90.42% 90.42% → +0.00 pp 🟢
web-ui 84.76% 84.79% ↓ -0.03 pp 🟡
aggregate 85.59% 85.75% ↓ -0.16 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 610 96.00%
sdk-go 0 ➖ no changes
sdk-python 0 ➖ no changes
sdk-typescript 0 ➖ no changes
web-ui 0 ➖ no changes

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant