orizu 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +22 -13
  2. package/dist/artifact-archive.js +152 -0
  3. package/dist/connectors-cli.js +80 -0
  4. package/dist/credentials.js +4 -1
  5. package/dist/daytona-slice-rehearsal.js +255 -0
  6. package/dist/daytona-slice.js +441 -0
  7. package/dist/egress-allowlist-cli.js +132 -0
  8. package/dist/egress-policy.js +159 -0
  9. package/dist/git-credential.js +131 -0
  10. package/dist/github-setup.js +226 -0
  11. package/dist/help.js +276 -21
  12. package/dist/hosted-bootstrap-rehearsal.js +235 -0
  13. package/dist/hosted-bootstrap.js +365 -0
  14. package/dist/hosted-broker-server.js +220 -0
  15. package/dist/hosted-git-http-server.js +136 -0
  16. package/dist/hosted-harness-claude.js +493 -0
  17. package/dist/hosted-harness-opencode.js +729 -0
  18. package/dist/hosted-harness.js +60 -0
  19. package/dist/hosted-loop.js +477 -0
  20. package/dist/hosted-run-event-sink.js +499 -0
  21. package/dist/hosted-runtime-assets.js +216 -0
  22. package/dist/hosted-session-cli.js +642 -0
  23. package/dist/index.js +945 -258
  24. package/dist/kill-switch-cli.js +89 -0
  25. package/dist/manifests-cli.js +143 -0
  26. package/dist/preview-runtime.js +4 -1
  27. package/dist/sandbox-provider.js +238 -0
  28. package/dist/secret-redaction.js +112 -0
  29. package/dist/session-timeline.js +103 -0
  30. package/dist/setup-prompt.js +4 -2
  31. package/dist/skill-installer.js +9 -1
  32. package/dist/smoke.js +250 -0
  33. package/dist/task-assignment-manifest.js +96 -0
  34. package/dist/vercel-sandbox-provider.js +260 -0
  35. package/dist/workbench-cli.js +493 -0
  36. package/dist/workspace-sync.js +924 -0
  37. package/dist/workspace.js +868 -57
  38. package/package.json +4 -1
  39. package/src/artifact-archive.ts +182 -0
  40. package/src/connectors-cli.ts +115 -0
  41. package/src/credentials.ts +6 -2
  42. package/src/daytona-slice-rehearsal.ts +320 -0
  43. package/src/daytona-slice.ts +598 -0
  44. package/src/egress-allowlist-cli.ts +147 -0
  45. package/src/egress-policy.ts +188 -0
  46. package/src/git-credential.ts +155 -0
  47. package/src/github-setup.ts +366 -0
  48. package/src/help.ts +277 -21
  49. package/src/hosted-bootstrap-rehearsal.ts +312 -0
  50. package/src/hosted-bootstrap.ts +571 -0
  51. package/src/hosted-broker-server.ts +278 -0
  52. package/src/hosted-git-http-server.ts +164 -0
  53. package/src/hosted-harness-claude.ts +621 -0
  54. package/src/hosted-harness-opencode.ts +823 -0
  55. package/src/hosted-harness.ts +124 -0
  56. package/src/hosted-loop.ts +680 -0
  57. package/src/hosted-run-event-sink.ts +632 -0
  58. package/src/hosted-runtime-assets.ts +272 -0
  59. package/src/hosted-session-cli.ts +844 -0
  60. package/src/index.ts +1102 -317
  61. package/src/kill-switch-cli.ts +113 -0
  62. package/src/manifests-cli.ts +168 -0
  63. package/src/preview-runtime.ts +14 -9
  64. package/src/sandbox-provider.ts +440 -0
  65. package/src/secret-redaction.ts +124 -0
  66. package/src/session-timeline.ts +152 -0
  67. package/src/setup-prompt.ts +4 -2
  68. package/src/skill-installer.ts +13 -1
  69. package/src/smoke.ts +342 -0
  70. package/src/task-assignment-manifest.ts +164 -0
  71. package/src/vercel-sandbox-provider.ts +388 -0
  72. package/src/workbench-cli.ts +701 -0
  73. package/src/workspace-sync.ts +1148 -0
  74. package/src/workspace.ts +1051 -64
  75. package/vendor/skills/orizu-cli/SKILL.md +8 -4
  76. package/vendor/skills/orizu-cli/references/cli-reference.md +12 -4
@@ -0,0 +1,632 @@
1
+ /**
2
+ * RunEventSink — the RunAPI event bridge (ALI-926 / P3.4, interface 2 of
3
+ * ADR-005's swappability contract; the full realization of audit §4b).
4
+ *
5
+ * This is the transport half that `bridge.py`'s WebSocket client is NOT: it maps
6
+ * normalized `HarnessEvent`s onto Orizu's append-only, client-sequenced
7
+ * `workbench_run_events` and terminal PATCH. It extends the seed
8
+ * `createBootstrapRunEventSink` (`hosted-bootstrap.ts`) into the complete bridge:
9
+ * kind-to-event-type mapping, token coalescing, monotonic client-sequenced
10
+ * allocation (gaps tolerated on an ambiguous append failure, but NEVER a content
11
+ * swap at a committed sequence — see the P2-4 burn rule in `postEvent`),
12
+ * critical-event delivery guarantees, terminal-via-PATCH, reconnect/resume, and
13
+ * the "Orizu records win" disagreement rule. A retried POST is an idempotent 200
14
+ * ONLY for a content-identical replay (same eventId, same sequence, same
15
+ * payload); a burned sequence after an ambiguous error leaves a server-visible
16
+ * gap, which is acceptable.
17
+ *
18
+ * ── Disagreement rule (Orizu records win) ────────────────────────────────────
19
+ * Orizu's RunAPI is the source of truth; the OpenCode-side session state is a
20
+ * cache/UX layer. If the harness reports state that CONTRADICTS a terminal Orizu
21
+ * run status — e.g. it keeps streaming parts after the run was finished — the
22
+ * sink REFUSES to append: once `finish()` succeeds the sink is SEALED, and any
23
+ * later `append()` throws `RunTerminalError` locally rather than POSTing. The
24
+ * sink never un-terminates a run and never invents a new one. A run-gone/terminal
25
+ * signal from the server (append 404/410, or a persistent terminal-status 409 on
26
+ * the finish PATCH) is likewise accepted as truth, sealing the sink. Note the
27
+ * narrow classifications: a 403 is NOT "terminal" — it is a `RunAuthError`
28
+ * (revoked bearer or lost access) that does not seal, and a 409 whose run is
29
+ * still running does NOT report success. The harness is a subordinate producer;
30
+ * the server record wins every disagreement.
31
+ *
32
+ * ── Artifact-ref decision (transcript/summary) ───────────────────────────────
33
+ * The audit points at `lib/primitive-artifact-writer.server.ts`, but that is
34
+ * server-only Supabase/object-store code the sandbox cannot import, and the
35
+ * CLI's existing artifact write paths (`orizu runners/prompts/... push`) are all
36
+ * PROJECT-scoped versioned-primitive uploads — there is NO run-scoped artifact
37
+ * write path today. Per ADR-003 (do not invent a new storage convention), a
38
+ * transcript is recorded as a BOUNDED-TAIL run event (`agent_transcript`,
39
+ * `TRANSCRIPT_TAIL_MAX_CHARS`) appended BEFORE the terminal PATCH (a post-terminal
40
+ * append would be refused by the seal). TODO(P3.6/ALI-928): when a run-scoped
41
+ * artifact-ref CLI path lands, swap the tail event for a real object ref.
42
+ */
43
+
44
+ import type { HarnessEvent, HarnessEventKind } from './hosted-harness.js'
45
+ import { isHarnessTerminalKind } from './hosted-harness.js'
46
+ import { redactSecrets } from './secret-redaction.js'
47
+
48
+ export type HostedFetch = (url: string, init?: RequestInit) => Promise<Response>
49
+
50
+ // -- Egress-audit vocabulary (G5 / ALI-1006) ---------------------------------
51
+ // The startup egress CANARY (in-sandbox loop, `hosted-loop.ts`) emits ONE of
52
+ // these right after bootstrap: `egress_blocked` when a known non-allowlisted
53
+ // host is correctly denied (the firewall is live → proof), or `egress_allowed`
54
+ // when it is UNEXPECTEDLY reachable (the policy did NOT take → the loop then
55
+ // fails the run closed). Emitted through the loop's single sink (single-writer
56
+ // invariant preserved — no host-side second writer). See
57
+ // docs/.../sandbox-egress-policy.md and t1-t7-adversarial-results.md (T1).
58
+ export const EGRESS_ALLOWED_EVENT_TYPE = 'egress_allowed'
59
+ export const EGRESS_BLOCKED_EVENT_TYPE = 'egress_blocked'
60
+
61
+ // -- Mapping table (audit §4b, in-scope rows) --------------------------------
62
+ // Kinds NOT present here are handled out-of-band:
63
+ // execution_complete / error → terminal PATCH (never a raw event);
64
+ // push_complete / push_error → deferred to P3.5;
65
+ // heartbeat / user_message → never emitted by the harness (dropped at source).
66
+ const EVENT_TYPE_BY_KIND: Partial<Record<HarnessEventKind, string>> = {
67
+ ready: 'agent_ready',
68
+ token: 'agent_token',
69
+ tool_call: 'agent_tool_call',
70
+ tool_result: 'agent_tool_result',
71
+ step_start: 'agent_step_start',
72
+ step_finish: 'agent_step_finish',
73
+ git_sync: 'repo_sync',
74
+ artifact: 'artifact',
75
+ session_title: 'session_title',
76
+ // Egress-canary proof events (G5 / ALI-1006) — emitted by the in-sandbox loop
77
+ // through this SAME sink (single writer). See EGRESS_*_EVENT_TYPE below.
78
+ egress_blocked: EGRESS_BLOCKED_EVENT_TYPE,
79
+ egress_allowed: EGRESS_ALLOWED_EVENT_TYPE,
80
+ }
81
+
82
+ /** The RunAPI event type a harness kind maps to, or null if it is not appendable
83
+ * (terminal kinds route through the PATCH; deferred kinds have no mapping yet). */
84
+ export function eventTypeForKind(kind: HarnessEventKind): string | null {
85
+ return EVENT_TYPE_BY_KIND[kind] ?? null
86
+ }
87
+
88
+ export const AGENT_TOKEN_EVENT_TYPE = 'agent_token'
89
+ export const AGENT_TRANSCRIPT_EVENT_TYPE = 'agent_transcript'
90
+ /** Bounded tail size for the transcript stopgap (see artifact-ref decision). */
91
+ export const TRANSCRIPT_TAIL_MAX_CHARS = 16_384
92
+
93
+ // -- Reserved credential-use audit vocabulary (G6 / ALI-1007, NOT emitted) ----
94
+ // The DURABLE credential-use audit is the per-mint DB row (agent_session_tokens
95
+ // for agent-token mints; repo_token_mints for repo tokens): each rotation writes
96
+ // a fresh row with created_at/expires_at/revoked_at, which IS the credential-use
97
+ // trail. These event-type constants RESERVE the run-timeline vocabulary but are
98
+ // NOT emitted today: a run's event/sequence space has a SINGLE writer (the
99
+ // in-sandbox loop — see app/api/cli/workbench-runs/[id]/events/route.ts), and a
100
+ // host-side append here would race that writer (409 → a healthy run killed). A
101
+ // FUTURE emitter must run in-sandbox (single-writer) or be out-of-band
102
+ // coordinated before these are wired to the stream.
103
+ export const CREDENTIAL_ROTATED_EVENT_TYPE = 'credential_rotated'
104
+ export const CREDENTIAL_MINT_FAILED_EVENT_TYPE = 'credential_mint_failed'
105
+
106
+ export type TerminalStatus = 'succeeded' | 'failed' | 'cancelled'
107
+
108
+ export interface FinishOptions {
109
+ summary?: Record<string, unknown>
110
+ evidence?: Record<string, unknown>
111
+ /** Full agent transcript; stored as a bounded-tail `agent_transcript` event
112
+ * BEFORE the terminal PATCH (see the artifact-ref decision docblock). */
113
+ transcript?: string
114
+ }
115
+
116
+ export interface RunEventSink {
117
+ /** Map + coalesce + POST a harness event. Owns per-run monotonic sequence.
118
+ * Terminal kinds are rejected (they must go through `finish`). */
119
+ append(event: HarnessEvent): Promise<void>
120
+ /** Force-flush any coalesced token snapshots (also runs on every structural
121
+ * event and on `finish`). Exposed so callers can flush on a timer if desired. */
122
+ flushTokens(): Promise<void>
123
+ /** Terminal transition via PATCH; records the transcript tail first, then CAS. */
124
+ finish(status: TerminalStatus, opts?: FinishOptions): Promise<void>
125
+ /** True once a terminal transition has landed and the sink is sealed. */
126
+ readonly sealed: boolean
127
+ /** The next sequence the sink will attempt to allocate (test/resume aid). */
128
+ readonly nextSequence: number
129
+ /** Events the sink has confirmed appended, in order (redacted payloads). */
130
+ readonly recorded: readonly AppendedRunEvent[]
131
+ }
132
+
133
+ export interface AppendedRunEvent {
134
+ eventId: string
135
+ sequence: number
136
+ eventType: string
137
+ payload: Record<string, unknown>
138
+ }
139
+
140
+ /** Raised when an append is attempted against a run the sink knows is terminal. */
141
+ export class RunTerminalError extends Error {
142
+ constructor(message: string) {
143
+ super(message)
144
+ this.name = 'RunTerminalError'
145
+ }
146
+ }
147
+
148
+ /** Raised when a critical event (or the terminal PATCH) is undeliverable after
149
+ * the bounded retry budget — the caller MUST treat the run as compromised. */
150
+ export class CriticalDeliveryError extends Error {
151
+ constructor(message: string) {
152
+ super(message)
153
+ this.name = 'CriticalDeliveryError'
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Raised on a 403 from the RunAPI. Named for what a 403 actually is — NOT "the
159
+ * run is terminal" (that is 404/410). A 403 means the caller may no longer write
160
+ * this run: the session bearer was revoked, OR the agent lost access to the run
161
+ * (e.g. its session was reassigned). The write definitely did not land, so the
162
+ * sink does NOT seal and does NOT burn the sequence; the caller decides whether
163
+ * to re-authenticate or abandon the run.
164
+ */
165
+ export class RunAuthError extends Error {
166
+ constructor(message: string) {
167
+ super(message)
168
+ this.name = 'RunAuthError'
169
+ }
170
+ }
171
+
172
+ const TERMINAL_STATUSES: ReadonlySet<string> = new Set(['succeeded', 'failed', 'cancelled'])
173
+
174
+ function isTerminalStatus(status: string | null): boolean {
175
+ return status !== null && TERMINAL_STATUSES.has(status)
176
+ }
177
+
178
+ export interface CreateRunEventSinkOptions {
179
+ apiBaseUrl: string
180
+ runId: string
181
+ /**
182
+ * The Orizu bearer, EITHER a fixed string OR a provider resolved per request.
183
+ * A provider (e.g. one that reads the rotated 0600 bearer file) lets the sink
184
+ * pick up a host-side token rotation without being rebuilt: every request
185
+ * resolves the current value, and on a 401/403 the sink re-resolves ONCE and
186
+ * retries before classifying the failure (fresh token after rotation).
187
+ */
188
+ bearer: string | (() => string)
189
+ fetchImpl?: HostedFetch
190
+ /**
191
+ * Verbatim secrets to strip from every payload before it leaves the sandbox.
192
+ *
193
+ * THIS IS THE REDACTION GUARANTEE. Exact-match on these values is the control
194
+ * we rely on — ANY secret that is present in the sandbox (the run bearer is
195
+ * added automatically; also pass every minted repo token, model-provider API
196
+ * key, and any credential a customer setup step injects) MUST appear in this
197
+ * list. Whatever is here is scrubbed wherever it appears, even mid-string and
198
+ * regardless of its shape. The token-shape patterns in `secret-redaction.ts`
199
+ * are only defense-in-depth for credentials no one declared; never treat a
200
+ * shape match as the primary safeguard.
201
+ */
202
+ redactSecretsList?: readonly string[]
203
+ now?: () => number
204
+ /** First sequence to allocate (resume sets this to server cursor + 1). */
205
+ startSequence?: number
206
+ /** eventId namespace; deterministic ids `<prefix>:<sequence>` make a replayed
207
+ * event an idempotent 200 (never a 409 same-sequence-different-eventId). */
208
+ eventIdPrefix?: string
209
+ /** Bounded retry budget for critical deliveries (append critical + finish). */
210
+ maxCriticalAttempts?: number
211
+ /** Injectable backoff sleep (tests pass a no-op to avoid real delays). */
212
+ sleepImpl?: (ms: number) => Promise<void>
213
+ /** Local diagnostic sink for disagreement-rule logs (never throws). */
214
+ onDiagnostic?: (message: string) => void
215
+ }
216
+
217
+ const DEFAULT_MAX_CRITICAL_ATTEMPTS = 4
218
+
219
+ function defaultSleep(ms: number): Promise<void> {
220
+ return new Promise(resolve => setTimeout(resolve, ms))
221
+ }
222
+
223
+ function tail(text: string, max: number): string {
224
+ return text.length <= max ? text : text.slice(text.length - max)
225
+ }
226
+
227
+ export function createRunEventSink(options: CreateRunEventSinkOptions): RunEventSink {
228
+ const fetchImpl = options.fetchImpl ?? (globalThis.fetch as HostedFetch)
229
+ const now = options.now ?? (() => Date.now())
230
+ const sleep = options.sleepImpl ?? defaultSleep
231
+ const base = options.apiBaseUrl.replace(/\/$/, '')
232
+ const eventIdPrefix = options.eventIdPrefix ?? options.runId
233
+ const bearerOption = options.bearer
234
+ const bearerIsRotatable = typeof bearerOption === 'function'
235
+ const resolveBearer = bearerIsRotatable ? bearerOption : (): string => bearerOption
236
+ const extraSecrets = options.redactSecretsList ?? []
237
+ // Resolve the CURRENT bearer for every redaction pass so a rotated token is
238
+ // scrubbed too (the fixed-string case still resolves the same value).
239
+ const currentSecrets = (): string[] => [resolveBearer(), ...extraSecrets]
240
+ const maxCriticalAttempts = options.maxCriticalAttempts ?? DEFAULT_MAX_CRITICAL_ATTEMPTS
241
+ const diag = options.onDiagnostic ?? ((): void => {})
242
+
243
+ const eventsUrl = `${base}/api/cli/workbench-runs/${encodeURIComponent(options.runId)}/events`
244
+ const runUrl = `${base}/api/cli/workbench-runs/${encodeURIComponent(options.runId)}`
245
+ const authHeaders = (): Record<string, string> => ({
246
+ 'Content-Type': 'application/json',
247
+ Authorization: `Bearer ${resolveBearer()}`,
248
+ })
249
+
250
+ // One request with a single re-auth retry: a 401/403 may just mean the bearer
251
+ // was rotated mid-flight, so re-resolve it ONCE and retry before the caller
252
+ // classifies the failure. Only meaningful when the bearer is a PROVIDER (a
253
+ // rotated file yields a new value); a fixed string re-resolves identically, so
254
+ // we skip the wasted retry and let the caller classify the first response.
255
+ async function requestWithReauth(url: string, init: RequestInit): Promise<Response> {
256
+ const first = await fetchImpl(url, { ...init, headers: authHeaders() })
257
+ if (!bearerIsRotatable || (first.status !== 401 && first.status !== 403)) return first
258
+ return fetchImpl(url, { ...init, headers: authHeaders() })
259
+ }
260
+
261
+ const recorded: AppendedRunEvent[] = []
262
+ // Insertion-ordered coalescing buffer: one latest snapshot per messageId.
263
+ // A token event carries the FULL accumulated text, so we replace (not append)
264
+ // — N deltas collapse to one `agent_token` at the next flush boundary.
265
+ const tokenBuffer = new Map<string, string>()
266
+
267
+ let nextSequence = options.startSequence ?? 1
268
+ let sealed = false
269
+
270
+ function assertWritable(): void {
271
+ if (sealed) {
272
+ throw new RunTerminalError('run is terminal server-side — refusing to append (Orizu records win)')
273
+ }
274
+ }
275
+
276
+ // POST one event. The sequence is allocated as a CANDIDATE and committed
277
+ // (counter advanced, event recorded) after a 2xx. On failure the candidate is
278
+ // handled by the "gaps tolerated, swaps NOT" rule (P2-4):
279
+ //
280
+ // • AMBIGUOUS failure — a network throw, a 5xx, or a sequence-conflict 409
281
+ // (anything where the server MAY already hold this sequence, or where the
282
+ // sequence is now unusable) — BURNS the candidate: the counter advances so
283
+ // a later append can never reuse this sequence with DIFFERENT content. The
284
+ // cost is a possible server-visible gap, which is acceptable; a silent
285
+ // content swap at a committed sequence is not.
286
+ // • CLEAN failure — a 400 validation error, where the server definitely did
287
+ // not commit — KEEPS the candidate: the next append reuses this sequence.
288
+ // • 404 / 410 seal the sink (the run is gone/terminal server-side).
289
+ // • 403 throws RunAuthError WITHOUT sealing (bearer revoked or access lost).
290
+ //
291
+ // Because the eventId is derived from the candidate sequence, a retry of the
292
+ // SAME content within the critical budget is an idempotent replay (same
293
+ // eventId), never a same-sequence-different-eventId 409.
294
+ async function postEvent(
295
+ eventType: string,
296
+ payload: Record<string, unknown>,
297
+ critical: boolean
298
+ ): Promise<void> {
299
+ const sequence = nextSequence
300
+ const eventId = `${eventIdPrefix}:${sequence}`
301
+ const redactedPayload = redactSecrets(payload, { secrets: currentSecrets() })
302
+ const body = JSON.stringify({
303
+ eventId,
304
+ sequence,
305
+ eventType,
306
+ occurredAt: new Date(now()).toISOString(),
307
+ payload: redactedPayload,
308
+ })
309
+
310
+ const attempts = critical ? maxCriticalAttempts : 1
311
+ let lastDetail = ''
312
+ // Burn the candidate sequence on exit unless we prove the server did not
313
+ // (and cannot have) committed it — i.e. a clean 4xx.
314
+ let burnSequence = false
315
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
316
+ let response: Response
317
+ try {
318
+ response = await requestWithReauth(eventsUrl, { method: 'POST', body })
319
+ } catch (error) {
320
+ // Network throw: the request may have reached and committed on the
321
+ // server — ambiguous, so burn.
322
+ lastDetail = error instanceof Error ? error.message : String(error)
323
+ burnSequence = true
324
+ if (critical && attempt < attempts) {
325
+ await sleep(2 ** (attempt - 1) * 50)
326
+ continue
327
+ }
328
+ break
329
+ }
330
+ if (response.ok) {
331
+ nextSequence = sequence + 1
332
+ recorded.push({ eventId, sequence, eventType, payload: redactedPayload })
333
+ return
334
+ }
335
+ // Run gone/terminal server-side: seal and stop (Orizu records win).
336
+ if (response.status === 404 || response.status === 410) {
337
+ sealed = true
338
+ diag(`append rejected (${response.status}) for ${eventType} — sealing sink (run terminal)`)
339
+ throw new RunTerminalError(`append rejected (${response.status}) — run is terminal server-side`)
340
+ }
341
+ // Not authorized to write this run: revoked bearer OR lost access. The
342
+ // re-auth retry above already re-resolved the bearer once (a rotation), so
343
+ // a persistent 401/403 is a real auth failure. The write did not land;
344
+ // surface a distinct error and do NOT seal or burn.
345
+ if (response.status === 401 || response.status === 403) {
346
+ diag(`append rejected (${response.status}) for ${eventType} — bearer revoked or run access lost`)
347
+ throw new RunAuthError(
348
+ `append rejected (${response.status}) for ${eventType} — bearer revoked or run access lost`
349
+ )
350
+ }
351
+ lastDetail = `status ${response.status}`
352
+ // 5xx (server may have committed) or 409 (sequence taken/unusable): burn.
353
+ if (response.status >= 500 || response.status === 409) {
354
+ burnSequence = true
355
+ if (critical && response.status >= 500 && attempt < attempts) {
356
+ await sleep(2 ** (attempt - 1) * 50)
357
+ continue
358
+ }
359
+ break
360
+ }
361
+ // Clean 4xx (e.g. 400 validation): server did not commit — keep the
362
+ // sequence for the next append.
363
+ break
364
+ }
365
+ if (burnSequence) nextSequence = sequence + 1
366
+ if (critical) {
367
+ throw new CriticalDeliveryError(
368
+ `critical event ${eventType} undeliverable after ${attempts} attempts (${lastDetail})`
369
+ )
370
+ }
371
+ throw new Error(`run-event append failed (${lastDetail}) for ${eventType}`)
372
+ }
373
+
374
+ async function flushTokens(): Promise<void> {
375
+ if (tokenBuffer.size === 0) return
376
+ const pending = [...tokenBuffer.entries()]
377
+ tokenBuffer.clear()
378
+ for (const [messageId, text] of pending) {
379
+ await postEvent(AGENT_TOKEN_EVENT_TYPE, { messageId, text }, false)
380
+ }
381
+ }
382
+
383
+ async function append(event: HarnessEvent): Promise<void> {
384
+ assertWritable()
385
+ if (isHarnessTerminalKind(event.kind)) {
386
+ throw new Error(`${event.kind} is terminal — route it through finish(), not append()`)
387
+ }
388
+ if (event.kind === 'token') {
389
+ const messageId = event.messageId ?? '_'
390
+ const text = typeof event.payload.text === 'string' ? event.payload.text : ''
391
+ tokenBuffer.set(messageId, text)
392
+ return
393
+ }
394
+ const eventType = eventTypeForKind(event.kind)
395
+ if (!eventType) {
396
+ // Deferred kinds (push_*) have no mapping in this slice — drop explicitly.
397
+ diag(`no event-type mapping for kind ${event.kind} — dropped (deferred)`)
398
+ return
399
+ }
400
+ // Any structural (non-token) event is a coalescing boundary.
401
+ await flushTokens()
402
+ await postEvent(eventType, event.payload, event.critical === true)
403
+ }
404
+
405
+ // GET the run's server-side status (run detail route). Best-effort: returns
406
+ // null on any transport/parse failure or non-2xx so the caller can decide.
407
+ async function fetchRunStatus(): Promise<string | null> {
408
+ try {
409
+ const res = await requestWithReauth(runUrl, { method: 'GET' })
410
+ if (!res.ok) return null
411
+ const data = (await res.json()) as { run?: { status?: unknown } }
412
+ const status = data.run?.status
413
+ return typeof status === 'string' ? status : null
414
+ } catch {
415
+ return null
416
+ }
417
+ }
418
+
419
+ // PATCH the terminal transition. The route ingests the terminal event BEFORE
420
+ // the status CAS and retries once internally on a sequence conflict; we add a
421
+ // bounded critical-retry on transient transport/5xx failures and honor the
422
+ // route's already-terminal semantics (Orizu records win).
423
+ async function patchTerminal(status: TerminalStatus, opts?: FinishOptions): Promise<void> {
424
+ const eventId = `${eventIdPrefix}:terminal:${status}`
425
+ const patchBody: Record<string, unknown> = { status, eventId }
426
+ if (opts?.summary) patchBody.summary = redactSecrets(opts.summary, { secrets: currentSecrets() })
427
+ if (opts?.evidence) patchBody.evidence = redactSecrets(opts.evidence, { secrets: currentSecrets() })
428
+ const body = JSON.stringify(patchBody)
429
+
430
+ let sequenceRetried = false
431
+ let lastDetail = ''
432
+ for (let attempt = 1; attempt <= maxCriticalAttempts; attempt += 1) {
433
+ let response: Response
434
+ try {
435
+ response = await requestWithReauth(runUrl, { method: 'PATCH', body })
436
+ } catch (error) {
437
+ lastDetail = error instanceof Error ? error.message : String(error)
438
+ if (attempt < maxCriticalAttempts) {
439
+ await sleep(2 ** (attempt - 1) * 50)
440
+ continue
441
+ }
442
+ break
443
+ }
444
+ if (response.ok) {
445
+ sealed = true
446
+ return
447
+ }
448
+ if (response.status === 409) {
449
+ // A 409 is either a sequence conflict (the route retries its own ingest;
450
+ // one more attempt here may clear it) or an already-terminal run (a
451
+ // concurrent finisher won). Retry once; if it STILL 409s, do not guess —
452
+ // ask the server what the run's status actually is. Only a terminal
453
+ // status lets us seal (Orizu records win); a run still running/pending
454
+ // means our transition did not happen and we must NOT report success.
455
+ if (!sequenceRetried) {
456
+ sequenceRetried = true
457
+ continue
458
+ }
459
+ const serverStatus = await fetchRunStatus()
460
+ if (isTerminalStatus(serverStatus)) {
461
+ sealed = true
462
+ diag(`terminal PATCH 409 — server run already ${serverStatus} (Orizu records win)`)
463
+ return
464
+ }
465
+ lastDetail = `409 conflict, server status ${serverStatus ?? 'unknown'}`
466
+ break
467
+ }
468
+ lastDetail = `status ${response.status}`
469
+ if (response.status >= 500 && attempt < maxCriticalAttempts) {
470
+ await sleep(2 ** (attempt - 1) * 50)
471
+ continue
472
+ }
473
+ break
474
+ }
475
+ throw new CriticalDeliveryError(`terminal PATCH (${status}) undeliverable (${lastDetail})`)
476
+ }
477
+
478
+ async function finish(status: TerminalStatus, opts?: FinishOptions): Promise<void> {
479
+ if (sealed) {
480
+ diag(`finish(${status}) called on an already-sealed sink — ignored (idempotent)`)
481
+ return
482
+ }
483
+ // Pre-terminal appends (still writable): flush coalesced tokens, then the
484
+ // bounded transcript tail (the seal below refuses any later append).
485
+ await flushTokens()
486
+ if (opts?.transcript) {
487
+ await postEvent(
488
+ AGENT_TRANSCRIPT_EVENT_TYPE,
489
+ { text: tail(opts.transcript, TRANSCRIPT_TAIL_MAX_CHARS), truncated: opts.transcript.length > TRANSCRIPT_TAIL_MAX_CHARS },
490
+ true
491
+ )
492
+ }
493
+ await patchTerminal(status, opts)
494
+ }
495
+
496
+ return {
497
+ append,
498
+ flushTokens,
499
+ finish,
500
+ get sealed() {
501
+ return sealed
502
+ },
503
+ get nextSequence() {
504
+ return nextSequence
505
+ },
506
+ recorded,
507
+ }
508
+ }
509
+
510
+ // -- Reconnect / resume ------------------------------------------------------
511
+
512
+ export interface ResumeRunEventSinkOptions extends Omit<CreateRunEventSinkOptions, 'startSequence'> {
513
+ /** Page size for the cursor tail while discovering the latest sequence. */
514
+ pageSize?: number
515
+ }
516
+
517
+ /**
518
+ * Rebuild a sink after a restart WITHOUT losing run continuity (acceptance:
519
+ * "reconnect/resume without losing run continuity"). Walks the events GET
520
+ * cursor tail to the end to learn the server's latest sequence, then returns a
521
+ * fresh sink whose first allocation is `latest + 1`.
522
+ *
523
+ * ── QUIESCE PRECONDITION (single-writer handoff) ─────────────────────────────
524
+ * This is safe ONLY when the predecessor sink has FULLY STOPPED before resume
525
+ * runs — the RunAPI is single-writer-per-run (see the events route), and resume
526
+ * assumes it is the sole writer while it reads the tail and picks the next
527
+ * sequence. It does NOT coordinate with a live predecessor. If a still-in-flight
528
+ * commit from a not-yet-dead sink lands AFTER we read the cursor, that sink and
529
+ * this one can both target the same next sequence. We do not claim "no
530
+ * collision": deterministic eventIds (`<runId>:<sequence>`) make an identical
531
+ * replay an idempotent 200, but two DIFFERENT payloads racing one sequence is a
532
+ * real conflict — bounded, not eliminated, by the P2-4 burn rule (the loser
533
+ * burns its candidate and advances rather than swapping committed content).
534
+ * Callers MUST ensure the predecessor is quiesced; treat overlap as a bug.
535
+ *
536
+ * ── STUCK-RUNNING RECOVERY (out of scope here) ───────────────────────────────
537
+ * If a sink crashes BETWEEN its last append and finish(), the run is left with
538
+ * status='running' server-side and no terminal event — resume can continue
539
+ * appending, but nothing in THIS module will time out and finalize an abandoned
540
+ * run whose writer never came back. A server-side watchdog owns that recovery
541
+ * (G6 / ALI-1007); it is deliberately not implemented in the sandbox client.
542
+ */
543
+ export async function resumeRunEventSink(
544
+ options: ResumeRunEventSinkOptions
545
+ ): Promise<RunEventSink> {
546
+ const fetchImpl = options.fetchImpl ?? (globalThis.fetch as HostedFetch)
547
+ const base = options.apiBaseUrl.replace(/\/$/, '')
548
+ const pageSize = options.pageSize ?? 500
549
+ const bearerOption = options.bearer
550
+ const resolveBearer = typeof bearerOption === 'function' ? bearerOption : (): string => bearerOption
551
+
552
+ let cursor = 0
553
+ for (;;) {
554
+ const url = `${base}/api/cli/workbench-runs/${encodeURIComponent(options.runId)}/events?after=${cursor}&limit=${pageSize}`
555
+ const response = await fetchImpl(url, {
556
+ method: 'GET',
557
+ headers: { Authorization: `Bearer ${resolveBearer()}` },
558
+ })
559
+ if (!response.ok) {
560
+ throw new Error(`resume cursor read failed (${response.status})`)
561
+ }
562
+ const data = (await response.json()) as { cursor?: number; events?: unknown[] }
563
+ const nextCursor = typeof data.cursor === 'number' ? data.cursor : cursor
564
+ const count = Array.isArray(data.events) ? data.events.length : 0
565
+ cursor = nextCursor
566
+ if (count < pageSize) break
567
+ }
568
+
569
+ return createRunEventSink({ ...options, startSequence: cursor + 1 })
570
+ }
571
+
572
+ // -- Bridge glue: drive a harness stream into a sink -------------------------
573
+
574
+ export interface DrainOptions {
575
+ /** Transcript captured by the caller, stored on the terminal transition. */
576
+ transcript?: string
577
+ summary?: Record<string, unknown>
578
+ evidence?: Record<string, unknown>
579
+ }
580
+
581
+ /**
582
+ * Consume a harness `runPrompt` stream into a sink: structural events are
583
+ * appended (with token coalescing), and the single terminal event
584
+ * (`execution_complete` / `error`) is routed to `finish()` — NEVER appended.
585
+ * This is the event bridge's happy-path driver; the terminal event decides the
586
+ * run's final status.
587
+ */
588
+ export async function drainHarnessToSink(
589
+ events: AsyncIterable<HarnessEvent>,
590
+ sink: RunEventSink,
591
+ opts: DrainOptions = {}
592
+ ): Promise<TerminalStatus> {
593
+ for await (const event of events) {
594
+ if (event.kind === 'execution_complete') {
595
+ // A prompt the caller/agent stopped is a cancellation, not a failure: the
596
+ // driver flags it with `aborted: true` (P2-2) for both the signal-abort
597
+ // and stop()-initiated paths. A genuine failure (success:false, no abort
598
+ // flag) still lands 'failed'.
599
+ if (event.payload.aborted === true) {
600
+ await sink.finish('cancelled', {
601
+ summary: opts.summary,
602
+ evidence: opts.evidence,
603
+ transcript: opts.transcript,
604
+ })
605
+ return 'cancelled'
606
+ }
607
+ const success = event.payload.success !== false
608
+ const status: TerminalStatus = success ? 'succeeded' : 'failed'
609
+ const summary = success
610
+ ? opts.summary
611
+ : { ...(opts.summary ?? {}), error: event.payload.error ?? 'execution failed' }
612
+ await sink.finish(status, { summary, evidence: opts.evidence, transcript: opts.transcript })
613
+ return status
614
+ }
615
+ if (event.kind === 'error') {
616
+ await sink.finish('failed', {
617
+ summary: { ...(opts.summary ?? {}), error: event.payload.error ?? 'agent error' },
618
+ evidence: opts.evidence,
619
+ transcript: opts.transcript,
620
+ })
621
+ return 'failed'
622
+ }
623
+ await sink.append(event)
624
+ }
625
+ // The harness contract guarantees a terminal event; reaching here means the
626
+ // stream ended abnormally. Fail closed so the run never dangles non-terminal.
627
+ await sink.finish('failed', {
628
+ summary: { ...(opts.summary ?? {}), error: 'harness stream ended without a terminal event' },
629
+ transcript: opts.transcript,
630
+ })
631
+ return 'failed'
632
+ }