AI Build Analysis — Streaming Redesign

Date: 2026-06-05  ·  Status: Draft for review  ·  Repos: Cap-go/capgo (edge fn + CLI), Cap-go/capgo_builder (worker)

1. Problem

The AI build-debug feature times out for roughly half of all failed CI builds.

Evidence (workflow logs from Cap-go/capgo, June 3–5 2026): of 21 failed builder runs that reached the AI step, 10 ended with AI analysis failed: The operation was aborted due to timeout. at almost exactly 60s. Successful runs measured 45–54s — the analysis duration distribution straddles the timeout.

Root cause: two stacked, equal 60s wall-clock timeouts — cli/src/ai/analyze.ts:61 (CLI → API) and supabase/functions/_backend/public/build/ai_analyze.ts:195 (API → builder). The CLI's clock starts earlier and also covers auth, DB checks and uploading a multi-MB log body, so the CLI always aborts first whenever the LLM needs ~60s.

2. Decision summary

TopicDecision
TransportSSE streaming end-to-end (Workers AI → builder → capgo API → CLI). Fixed wall-clock budgets replaced by data-flow watchdogs.
New endpointPOST /build/ai_analyze_stream on the capgo API gateway.
Old endpointPOST /build/ai_analyze immediately deprecated — always returns 426 Upgrade Required + "upgrade @capgo/cli" message. Handled in capgo, never reaches capgo_builder.
PersistenceNone. Analysis text is never stored (liability decision). It exists only in transit.
Flag semanticsClaim-then-refund: atomically claim ai_analyzed = true before calling the builder; refund only on provably-pre-AI failures; ambiguity fails closed. Client disconnects never refund.
Handshake / ackRejected — the open HTTP connection is the implicit "ready to receive".
Durable ObjectsNot used; the builder handler stays a stateless Worker handler.

3. End-to-end flow

sequenceDiagram
    autonumber
    participant CLI as Capgo CLI
(CI or TTY) participant API as capgo API
/build/ai_analyze_stream participant DB as Postgres
build_requests participant B as capgo_builder
Worker participant AI as Workers AI
kimi-k2.6 CLI->>API: POST {jobId, appId, logs}
accept: text/event-stream API->>API: checkPermission(app.build_native) API->>DB: SELECT ownership + status = 'failed' API->>DB: CLAIM: UPDATE ai_analyzed = true
WHERE ... AND ai_analyzed = false alt 0 rows returned API-->>CLI: 409 already_analyzed end API->>B: POST /jobs/{id}/ai-analyze (SSE) alt connection fails OR aiStarted = false API->>DB: REFUND: ai_analyzed = false API-->>CLI: 502 builder_error (retryable) else builder streams B->>AI: env.AI.run(stream: true) AI--)B: token deltas B--)API: event: chunk {text} API--)CLI: event: chunk {text}
(watchdogs: 90s first-byte / 30s idle) Note over CLI: TTY: render progressively
CI: buffer, print once B--)API: event: done {durationMs} API--)CLI: event: done API->>API: waitUntil: telemetry
(no analysis text, ever) end

4. Protocol

4.1 New endpoint: POST /build/ai_analyze_stream

POST /build/ai_analyze_stream
capgkey: <apikey>
content-type: application/json
accept: text/event-stream

{ "jobId": "...", "appId": "...", "logs": "<captured build log>" }

Pre-stream failures are plain JSON with an HTTP status (the stream has not started, so status codes still work):

StatusCodeMeaningSlot consumed?
400invalid_stateBuild not in failed state / row mismatchNo (never claimed)
401/403unauthorizedBad key / missing app.build_nativeNo
409already_analyzedClaim found ai_analyzed = trueAlready was
413logs_too_bigBody over 10 MB limitNo
500config_errorBUILDER_URL / BUILDER_API_KEY missingNo
502builder_errorBuilder unreachable or pre-AI failureNo — refunded

Success: 200 + content-type: text/event-stream:

event: chunk
data: {"text":"<token delta>"}

event: done
data: {"durationMs":48211}

event: error
data: {"code":"ai_error" | "idle_timeout"}

4.2 Old endpoint: POST /build/ai_analyze (deprecated)

426 Upgrade Required
{ "error": "AI build analysis requires a newer CLI. Please upgrade: npx @capgo/cli@latest",
  "code": "upgrade_required" }
Body shape matters: the human-readable text MUST be in the error field — the deployed CLI resolves the printed message as body.error || body.message (unchanged since the feature's first release, commit 803e4752c), so a machine code in error would shadow the instruction. With this shape old CLIs print AI analysis failed (426): AI build analysis requires a newer CLI. Please upgrade: npx @capgo/cli@latest. with no client change. code carries the machine-readable identifier for new clients and tests.

The handler keeps the apikey middleware but performs no DB reads/writes and never contacts the builder.

4.3 Builder endpoint: POST {BUILDER_URL}/jobs/{jobId}/ai-analyze

Internal, x-api-key-authenticated, called only by the capgo edge function. Success: 200 + the same SSE event protocol (piped through). Pre-stream errors carry an explicit cost marker:

{ "error": "invalid_json" | "logs_too_big" | "ai_error", "aiStarted": true | false }

aiStarted: false — rejected before env.AI.run was invoked (validation, trim overflow). aiStarted: trueenv.AI.run was invoked and threw; billing unknown → counts as started. The edge fn refunds only on aiStarted: false or a connection-level failure.

5. Flag lifecycle — claim-then-refund

Why claim before calling the builder: Workers AI charges input tokens at prompt ingestion — cost commits at submission, not delivery. Flipping on first delivered chunk would let an abuser POST-and-disconnect repeatedly: each attempt starts a paid AI run but never flips the flag. Claiming first makes spam-and-disconnect consume the job's single slot on attempt #1; every later request is a cheap 409 that never reaches the builder.
flowchart TD
    A[Request arrives] --> P{Permission +
ownership + status checks} P -- fail --> R1[4xx — no claim] P -- pass --> C{Atomic claim
UPDATE ... WHERE ai_analyzed = false} C -- 0 rows --> R2[409 already_analyzed] C -- claimed --> F{fetch builder} F -- connection failure
never reached builder --> RF1[REFUND
502 retryable]:::refund F -- "non-200, aiStarted = false" --> RF2[REFUND
502 retryable]:::refund F -- "non-200, aiStarted = true
or malformed body" --> K1[NO REFUND
fail closed]:::keep F -- 200 SSE --> S{Stream} S -- "watchdog fires
(90s first-byte / 30s idle)" --> K2[NO REFUND
event: error idle_timeout]:::keep S -- mid-stream error event --> K3[NO REFUND
partial shown]:::keep S -- client disconnects --> K4[NO REFUND
ever]:::keep S -- done --> OK[Success
telemetry via waitUntil]:::good classDef refund fill:#1d3324,stroke:#6fcf97,color:#bfe8cf classDef keep fill:#33201d,stroke:#eb5757,color:#f3c2c2 classDef good fill:#1c2a3a,stroke:#4fc3f7,color:#cfe9ff

Invariant: every refunded cycle is provably zero-AI-cost, so total AI runs per job stay ≤ 1 under any interleaving of retries, spam, races or disconnects. The conditional UPDATE serializes on the Postgres row lock: N parallel requests → exactly 1 claim, N−1 409s (this also removes the SELECT-then-flip race in the current code). If the refund write itself fails: log loudly, fail closed — a user rarely loses a retryable slot; Capgo never pays for an extra run.

6. Timeouts & watchdogs

LayerFirst-byte watchdogIdle (between chunks)Total safety cap
Edge fn → builder90 s30 snone (Workers has no wall-clock limit while streaming)
CLI → edge fn120 s45 s10 min

First-byte is generous because prompt ingestion of a large trimmed log produces no tokens for a while. Watchdogs = timer reset inside a TransformStream; firing aborts the upstream fetch and emits event: error {"code":"idle_timeout"} downstream. Watchdog expiry never refunds. Values staggered so the inner layer always fires before the outer.

7. Component changes

7.1 capgo_builder — src/ai-analyze.ts

7.2 capgo edge function

7.3 capgo CLI

8. Telemetry

Privacy rule unchanged: analysis text never appears in any event, tag or log.

9. Rollout

  1. builder — Accept-gated streaming + aiStarted markers (buffered JSON path retained). Zero behavior change for the current edge fn.
  2. capgo edge fn — ship ai_analyze_stream + switch ai_analyze to 426. From this moment all old CLIs get the upgrade message.
  3. CLI release — streaming client. CI workflows on bunx @capgo/cli@latest pick it up on the next run.
  4. builder cleanup — delete buffered path once upgrade_required telemetry confirms cutover (target: within a week).

Each step independently deployable and reversible; the only user-visible discontinuity is the intended one (step 2).

10. Testing

11. Out of scope