Skip to content

feat: nan run / nan runs (agent automations phase 1) - #41

Merged
sre-helmcode merged 8 commits into
mainfrom
feat/runs
Oct 10, 2026
Merged

sre-helmcode merged 8 commits into
mainfrom
feat/runs

Conversation

@sre-helmcode

Copy link
Copy Markdown
Contributor

⚠️ Do not merge until the runs backend is live

Merging this PR publishes a release: tag.yml tags and releases any merge to main that changes internal/tui.Version (bumped here to 0.1.24). The /v1/runs API this talks to does not exist yet (cloud-api T1–T4 of Agent Automations Phase 1), so this must wait until https://cloud-api.nan.builders/v1/runs is deployed and the feature gate is on. No tags or releases were created.

What

NaN Agent Automations Phase 1, task T5 (NaN community tenant). Spec: helmcode/nan-devops AGENT-AUTOMATIONS-PHASE1.md §2.1.3 (HTTP API + SSE frames), §2.4 (CLI), §5 "nan-cli (T5)". Issue: helmcode/nan#162.

nan run [--ws NAME] [--agent pi|hermes] [--model M] [--cwd PATH] [--worktree|--no-worktree]
        [--timeout 30m] [--detach] [--json] [--idempotency-key K] ( "prompt" | -f prompt.md | - )
nan runs ls [--ws NAME] [--state S] [--limit 20] [--json]
nan runs show <id> [--json]
nan runs logs <id> [-f] [--after N] [--json]
nan runs cancel <id> [--json]
  • internal/api/runs.go — RunsBaseURL = https://cloud-api.nan.builders (not api.nan.builders: the edge Worker rewrites bodies, spec §0.3). Types mirror §2.1.3. Session cookie nan_session first; member API key as Authorization: Bearer when there is no session; 401 → ErrSessionExpired. Both error envelopes parsed (OpenAI on /v1, bare on /api).
  • SSE reader: LF framing (tolerates CRLF, multi-line data:), Last-Event-ID reconnect up to 5 failures in a row with exponential backoff (1s→16s; the budget resets after any byte, so the 30-min server close never exhausts it), replayed events de-duplicated by seq, 45 s idle timeout (the server heartbeats every 15 s), no client timeout on the stream (the 30 s timeout stays on every other request). 401/404 are not retried. Lines over 1 MiB are fatal.
  • internal/runs/render.go — event → terminal lines. All guest text is sanitised: CSI/OSC/DCS/SOS/PM/APC and two-byte escapes, C0 (except \n/\t), DEL, C1, CR, bidi overrides; invalid UTF-8 → U+FFFD. --json output also escapes C1/bidi controls (encoding/json already escapes C0).
  • cmd/run.go, cmd/runs.go, cmd/exit.go — exit-code table of §2.4 (0/1/2/3/4 run outcome; 64 usage; 65 auth; 69 unavailable / stream lost, naming the run; 75 detached). Ctrl-C detaches and prints nan runs logs <id> -f / nan runs cancel <id>; a second Ctrl-C within 2 s cancels. Ctrl-C is only captured once the run is being followed. --ws defaults to the member's only workspace (GET /api/workspaces), else exit 64 listing the names. Run ids are validated as UUIDs before they go into a path. Client-side checks mirror the server limits (prompt 1..32768 bytes, UTF-8, no NUL; timeout 1m..2h; model and idempotency-key regexes) so mistakes exit 64 without a round trip. Unset optional flags are omitted from the body so the platform defaults apply.
  • cmd/root.go — Execute honours ExitError; on run/runs a cobra refusal (unknown flag, missing id, mutually exclusive flags) exits 64. Other commands keep exiting 1.
  • README: usage, flags, Ctrl-C behaviour, exit codes. CONTRIBUTING: project structure.
  • Version 0.1.23 → 0.1.24 (patch).

User-facing strings are English, like the rest of the CLI.

Assumptions to confirm against the backend (T1/T4)

  • message events are incremental chunks (coalesced deltas), with final: true on the last; the renderer concatenates them. If final carries the full text instead, the renderer needs a one-line change.
  • GET /v1/runs?workspace= accepts the workspace name.
  • GET /api/workspaces accepts the same credentials (session; with API-key-only auth, a refusal tells the member to pass --ws).

Tests

Spec-driven, httptest mocks of §2.1.3 for every item in §5 "nan-cli (T5)":

  • run streams and exits 0/1/2/3/4 (incl. 4 for config_error/agent_not_installed/no_inference_key)
  • SSE reconnect with Last-Event-ID, duplicate drop, 5-retry give-up (exit 69 naming the run), idle timeout reconnect, no stream timeout, no retry on 401/404, context cancel
  • Ctrl-C detach (exit 75, hints, no cancel) / double Ctrl-C cancel (exit 3, one cancel POST)
  • --ws default, ambiguity (exit 64, names sanitised), none, explicit --ws
  • 401 → exit 65 with nan auth login on run/ls/show/cancel; no session → 65; API-key fallback
  • control chars stripped from stream, logs, tables, error messages; JSON escapes
  • runs ls/show/logs/cancel JSON output; logs -f exit codes and --after
  • admission refusals → exit codes; usage errors exit 64 before anything is sent; cobra refusals → 64
gofmt -l .          # clean
go vet ./...        # clean
go test -race -count=5 ./...   # ok
cross-compile linux/darwin/windows × amd64/arm64   # ok
govulncheck@v1.8.0  # No vulnerabilities found.

Deploy notes

None beyond the merge gate above. No env vars, no secrets.

🤖 Generated with Claude Code

barckcode and others added 6 commits October 10, 2026 15:49
Types mirror the run object, event and list shapes of the Phase 1 runs
API. The stream reader frames on LF (tolerating CRLF), reconnects with
Last-Event-ID up to five times in a row with backoff, drops replayed
events, treats 45 s of silence as a dead connection, and never applies
the 30 s request timeout to the stream. Session cookie first, member
API key as Bearer when there is no session; 401 is ErrSessionExpired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every string a run produces comes from the member's workspace, so ANSI
escape sequences (CSI, OSC, DCS...), C0/C1 controls, carriage returns
and bidi overrides are stripped before anything reaches the terminal.
JSON output escapes C1 and bidi controls too. ExitCode maps a finished
run onto the documented exit codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nan run creates a run on /v1/runs and streams it, exiting with the
run's outcome (0/1/2/3/4), 64 for usage errors, 65 for auth, 69 when
the platform or the stream is lost (naming the run), 75 when detached.
Ctrl-C detaches and prints how to follow or cancel; a second Ctrl-C
within 2 s cancels. --ws defaults to the member's only workspace and
lists the names when there are several. On run/runs, flag errors from
cobra exit 64; other commands keep exiting 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- stream: the idle limit also covers the wait for response headers; a
  200 that is not text/event-stream is a failed connection; the retry
  budget resets only on a connection that delivered a frame or stayed
  up heartbeating, so nothing reconnects forever; frames capped at
  4 MiB; redirects are never followed (credential stays put)
- run: arguments are checked before the session; a run that ends as
  Ctrl-C lands reports its outcome; repeated state frames print once;
  a third Ctrl-C after the cancel window is no longer swallowed; the
  run id the server returns is validated; stream-lost messages are
  sanitised; stdin on a terminal says it is waiting for the prompt
- runs: missing id says so; empty filtered list and --limit 100 hints;
  logs without -f says when the run is still going
- json: DEL escaped too
- tests: header timeout, non-SSE 200, heartbeat-only, budget reset,
  429 retry, malformed end, oversized line/frame, redirects, logs -f
  401/404, runtime errors exit 1, no credential in any output
- README: set -e and --detach, idempotency for retrying scripts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- json: invalid UTF-8 carried through json.RawMessage (a lone 0x9b is
  an 8-bit CSI) goes out as �; DEL escaped
- the RunE path is the tested path: doRun moved into the tests, which
  drive buildRunRequest + startRun exactly as the command does
- stream: replayed events and state frames no longer refill the retry
  budget; an end frame for another run is refused; reconnects are
  announced on stderr
- run: --worktree/--no-worktree conflict reports our own message;
  --timeout help shows 30m; help warns that argv prompts land in shell
  history and ps
- runs logs -f help says Ctrl-C stops following, the run goes on
- tests: logs pagination (advance and stuck cursor), lone C1 byte in
  --json for logs and run, logs -f exit 4, atomic counters in handlers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sre-helmcode

Copy link
Copy Markdown
Contributor Author

Blocking reviews (Claude Code, Opus), three separate lenses, all on head fc39159964c8bf74a4f5560552a365fc5affe693:

  • QA: APPROVED (round 3). Round 1 blockers: idle timeout didn't cover the header wait; a 200 that is not SSE reconnected forever; logs -f 401/404 untested. Round 2 blockers: doRun only reachable from the tests; logs pagination untested. All fixed.
  • UX: APPROVED (every round).
  • Security: APPROVED (round 3). Round 2 blocker: a lone C1 byte carried through json.RawMessage into --json output. Fixed.

Reminder: do not merge until /v1/runs is live — merging publishes v0.1.24 via tag.yml.

🤖 Generated with Claude Code

barckcode and others added 2 commits October 10, 2026 19:13
The idle timer only reset on a complete line, so an oversized line that
took longer than IdleTimeout to arrive (1 MiB under -race on a slow
Linux runner vs the 200 ms test limit) ended the first connection as an
idle reconnect; only the retry hit bufio.ErrTooLong. The oversize error
was therefore retried once on CI.

The idle clock now tracks the last bytes read from the body, and a read
result that is already pending wins over the timer, so an oversized
line or frame is always fatal on the first connection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
go.mod is the single source for CI and release (go-version-file), so the
bump covers vet/test, govulncheck and the release build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sre-helmcode
sre-helmcode merged commit 45afdb7 into main Oct 10, 2026
5 checks passed
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.

2 participants