Streams, terminal and browser URLs
## Context (BA Task 12447 → Architecture Task 12448 → Plan Task 12449)
Original request (verbatim): "Regarding task <https://app.jaah.ai/tasks/12393> This is a repeat situation with the same user apparently behind a broken corporate firewall. He says that previous fix helped and his task list is no longer causing trouble, but he was now struggling with Projects edit. Go over all UI surfaces and ensure that the same code is reused everywhere - one choke point - that works identically and uses the approach and protocols that are now proven working. If there will be other similar issues we would only have to fix it in one place"
Root cause (Architecture §0, AC-0): the hang was a WRITE — `PUT /api/projects/54` sat on a dead direct HTTP/2 connection (prod `activity_events` 1661992/3; every page read was relay-rescued, `client_events` 1335-1345). Writes are bare `fetch` with no relay/timeout (`apps/web/src/api.ts:68`); the relay is GET-only (`apps/api/app/routes/read_relay.py:13`).
Operator decisions (verbatim, PRD §3 + Architecture §1): D2 "**Relay once + idempotency key.** Every write carries a unique key. When the direct connection looks dead, the write also goes over the WebSocket relay, and the server applies a given key at most once." D3 "**All four:** all `api()` reads + writes; the raw-fetch stragglers (`useVersionCheck`, live `config.ts`, live rewriter, S3 upload PUT); streams + terminal (EventSource `/api/stream`, terminal WebSocket); browser-fetched URLs (`apiHref`: img, iframe, downloads)." D4 "**Yes, also unify edit pages:** load via `useEditRecord`, save via `useSubmit`". A1 one epic, parallel where no dependency. A2 **At most once** (claim before run). A3 **All console writes, capped** (body ≤ 1 MiB, per-session write budget, key required). A4 **Public relay, live paths only** (cookieless socket, `GET /api/public/live/*`, per-IP budget).
Full documents (read your section in full before building): the PRD and Architecture are the settled `prd` / `architecture` artifacts of Tasks 12447 / 12448 (read with `task_read` / `artifact_read`). Your section below is copied from them; the pinned seams are binding contracts other children build against — do not change them without updating the sibling that consumes them.
## Your scope: C5 — Streams, terminal, browser URLs
Add `net/streams.ts` (`openEventSource`, `openSocket`), the terminal silence watchdog (verify server keepalive answers first; add an ack frame server-side if not — ONLY in the control-plane terminal proxy (`apps/api`). If the terminal's server end for a transport is the node daemon (`apps/jaahd`, e.g. ConsolePane's `'direct'` transport) — which the PRD rules out of scope — base that transport's watchdog only on frames the server already sends (keepalive replies or output), and if none exist, name it as a deferred follow-up in your report and PR body rather than touching `apps/jaahd`), `net/href.tsx` (`<ApiImg>`, `<ApiFrame>`, `apiDownload`, `useApiImageUrl`) and convert all 11 `apiHref` call sites in the pinned inventory.
### Architecture §3.5 (verbatim)
### 3.5 Streams, terminal, browser URLs [child C5]
- `net/streams.ts` exports `openEventSource(path, init)` and `openSocket(url, protocols)`. `useEventStream.ts:90` and `ConsolePane.tsx:923,1026` construct through them. Their existing self-heal logic stays where it is, because it is already proven: the stream's `SILENT_AFTER_MS = 35 s` reopen and the terminal's 25 s keepalive and `reconnectDelayMs`. C5 adds the one missing rule, a **silence watchdog for the terminal socket**: it closes the socket and reconnects through the existing path. The bounds are pinned below.
- **Detection and reconnect bounds (AC-6)**:
- Stream: a stall is detected at `SILENT_AFTER_MS` = 35 s of silence (the server's heartbeat is shorter). The source reopens at once and the open must complete within 15 s.
- Terminal: the watchdog fires after 30 s with no inbound frame (not `2 × KEEPALIVE`). Reconnect through the existing `reconnectDelayMs(1)` must re-attach within 15 s.
- This needs the server to answer each keepalive. **C5 first verifies that it does.** If it does not, C5 adds a keepalive acknowledgement frame to the terminal socket's server end. That is a transport frame, not route logic, and it lives in the same child.
- The AC-6 oracle measures from the detection instant (stream ≤ 35 s, terminal ≤ 30 s after the stall begins) to the UI recovering (≤ 15 s).
- `apiDownload(path)` mechanism: it fetches the file as a blob through `send` with `relay:'none'` and `boundMs: 60_000`, plus one retry. It then saves via an object URL and an `<a download>` click. A failure shows the caller's error with a Retry. A blob download is used up to 50 MiB. A larger file falls back to a plain navigation (the browser's own download manager retries).
- **`apiHref` call sites (C5 owns all 11; inventory pinned):**
| Site | What it does | Change |
|---|---|---|
| `TaskAttachmentsField.tsx:57,79` | image `src` string | render `<ApiImg>` |
| `TaskArtifacts.tsx:875` | report frame `src` | render `<ApiFrame>` |
| `useCommentImages.ts:36` | hands URL strings to the rich-text renderer | `useApiImageUrl(path)` in `net/href.tsx`: it loads the image through `send` (`relay:'none'`, one retry) and returns an object URL |
| `planTermsCheckApi.ts:26` | CSV download link | `apiDownload` (retries like every other download) |
| `PerformanceTab.tsx:39`, `TaskActivity.tsx:209` | CSV / `.jsonl` download | `apiDownload` |
| `AttachmentViewer.tsx:208,225,234` | image / frame / download | `<ApiImg>`, `<ApiFrame>`, `apiDownload` |
| `useEventStream.ts:176` | stream path | goes through `openEventSource` in `net/streams.ts` |
After C5 the only `apiHref(` callers are inside `net/` and `api.ts`, which is what C7's rule enforces.
- `net/href.tsx` exports `<ApiImg>`, `<ApiFrame>`, `apiDownload(path)`, and `useApiImageUrl(path)`. Each takes an `apiHref` path. On `error`, or no `load` within 10 s, it retries once with a cache-busting `?_r=<n>` (a new request, which a dead HTTP/2 connection will by then have replaced). It then shows a "Retry" affordance and never stays a broken image (AC-7). `apiHref` itself stays in `api.ts`, and the gate requires `apiHref` results to be consumed only through `net/href.tsx` or the existing stream path.
### PRD assumptions that bind this child (verbatim)
- AS4. **Intentional internals stay where they are, but are named.** The relay's own probe and socket, client telemetry and the live beacon (`keepalive` fire-and-forget) may keep their own `fetch`, as entries on the gate allowlist (AC-4), each with a one-line reason. Why: they are the choke point's own machinery, or must not be raced.
### Requirement constraints (PRD §7, verbatim)
- Writes are never applied twice (D2, AC-2). Idempotency touches every mutating route, so it is a security-surface change (data integrity). Project instructions grant merge permission. The review lane still applies.
- Act-as headers (`lib/actAs.ts`) and session headers (`lib/activeOrg.ts`) must ride relayed writes exactly as direct ones. A relayed write must be authorized as the same session. A relayed 401/403 never wins over the direct answer, the same rule as reads.
- Single-home: the relay rules live in one module. `docs/standards/coding-standards.md` and the context policy apply.
### Acceptance criteria (PRD §5, verbatim)
**AC-6 — Streams and terminal recover.** The event stream (`/api/stream`, `useEventStream`) and the console terminal WebSocket reconnect after a silently dead connection, through the shared module, within **15 s**. No lost state the UI depends on: lists refetch or resume, the terminal re-attaches to the same session.
- verify: dev Playwright: open Tasks (live updates) and a console terminal. Stall the stream connection (stop delivering) → a task change made elsewhere shows within 15 s of the stall being detected. Kill the terminal socket → the terminal reconnects and accepts input.
**AC-7 — Browser-fetched URLs recover.** An `apiHref` image, iframe or download whose first fetch fails or stalls is retried, or offers a retry. Never a permanently broken image.
- verify: dev Playwright: open a task with an image attachment, fail that image's first request → the image renders after the automatic retry. Same for an artifact iframe and a download link.
Credentials & reach: (Architecture §6, verbatim rows)
| Oracle | Credential / identity / environment | Status |
|---|---|---|
| AC-1/3/5/6/7/9 dev Playwright | `me/jaah/debug/main/operator-login` (operator; its description says it works on dev and main) on `https://app.dev.jaah.ai` | **verified**: `credential_get` succeeded; `GET /version.json` → 200 from h2. `org/console/dev-login` also exists, but its base_url names a host that does not answer from h2, so the operator login is the one to use. |
| AC-6 terminal | a dev node with an attachable console session | **verified**: 9 dev nodes seen in the last 10 min. C5 opens a console on one through the UI. |
| AC-7 image / iframe / download | a dev task with an image attachment, and one with an HTML report | **verified**: dev `task_attachments` holds 3 `image/png` and 226 `text/html` rows |
| AC-10 deploy run | `jaah-forge pr checks` / `ci log` (pre-authenticated forge) | **verified**: no credential to fetch |
Out of scope: PRD §6 (verbatim) —
- Do NOT add a hostname, port, certificate, or change cookies or auth to carry writes. The WebSocket relay carrier stays (decision of Task 11936).
- Do NOT change the read relay's existing read semantics (race timing, probe, suspect and dropping memory) beyond what writes, streams and uploads need.
- Do NOT change any edit page's visible UI: layout, fields, tabs, copy, URLs, routes (AC-9 quality).
- Do NOT change React Query's global defaults (`apps/web/src/queryClient.ts`, `retry: false` for queries).
- Do NOT change server route business logic. Server changes are limited to (a) idempotency for console writes and (b) the relay accepting writes under that key.
- Do NOT touch `apps/site-widget`, `apps/docs`, `apps/jaahd`, `packages/` (AS9).
- Do NOT refactor non-edit-page mutations (compliance, billing, playbook list, template cards) beyond what routing them through the choke point needs.
- Do NOT add a feature flag (AS8).
- Do NOT treat this as Nick-only: no per-user or per-org switch.
- Do NOT touch stragglers/live host (C4), the gate (C7), or core send/relay rules (C3) beyond consuming them.
- Keep the existing stream 35 s reopen and terminal keepalive/reconnect logic; only add the watchdog.
Done means:
- AC-6 — oracle verbatim above (PRD §5 AC-6 `verify:` lines)
- AC-7 — oracle verbatim above (PRD §5 AC-7 `verify:` lines)
- AC-7 quality: no attachment image, report frame or download changes appearance on a healthy connection (screenshot before/after on dev); a failed first load renders after the retry, never a broken-image icon.
- No bypass left in the files you own: grep the Architecture §3.6 patterns (`fetch(`, `new EventSource`, `new WebSocket`, `XMLHttpRequest`, `navigator.sendBeacon`, `apiHref(`, `.json()` on a Response) — zero hits outside `apps/web/src/net/`, `api.ts` and the §3.6 allowlist (clientTelemetry, live beacon, lazyRetry). Cite the grep output in the report.
- Dev oracles use a console session you opened and records (tasks/attachments) that already exist; change nothing in them.
- AC-6 terminal oracle passes on the control-plane-proxied transport; any deferral for the node-daemon `'direct'` transport is named in the report and PR body.
- `./dc check quick` green; PR merged via the merge lane; Jaah Deploy to dev green. Dev oracles run on `https://app.dev.jaah.ai` (the PRD's `dev.jaah.ai` does not resolve from the nodes — Architecture §7), after your merge's Jaah Deploy to dev succeeds. Watch that deploy; if it fails, fix and merge again.
Activity
- Draft
- Ready