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.
- package/README.md +22 -13
- package/dist/artifact-archive.js +152 -0
- package/dist/connectors-cli.js +80 -0
- package/dist/credentials.js +4 -1
- package/dist/daytona-slice-rehearsal.js +255 -0
- package/dist/daytona-slice.js +441 -0
- package/dist/egress-allowlist-cli.js +132 -0
- package/dist/egress-policy.js +159 -0
- package/dist/git-credential.js +131 -0
- package/dist/github-setup.js +226 -0
- package/dist/help.js +276 -21
- package/dist/hosted-bootstrap-rehearsal.js +235 -0
- package/dist/hosted-bootstrap.js +365 -0
- package/dist/hosted-broker-server.js +220 -0
- package/dist/hosted-git-http-server.js +136 -0
- package/dist/hosted-harness-claude.js +493 -0
- package/dist/hosted-harness-opencode.js +729 -0
- package/dist/hosted-harness.js +60 -0
- package/dist/hosted-loop.js +477 -0
- package/dist/hosted-run-event-sink.js +499 -0
- package/dist/hosted-runtime-assets.js +216 -0
- package/dist/hosted-session-cli.js +642 -0
- package/dist/index.js +945 -258
- package/dist/kill-switch-cli.js +89 -0
- package/dist/manifests-cli.js +143 -0
- package/dist/preview-runtime.js +4 -1
- package/dist/sandbox-provider.js +238 -0
- package/dist/secret-redaction.js +112 -0
- package/dist/session-timeline.js +103 -0
- package/dist/setup-prompt.js +4 -2
- package/dist/skill-installer.js +9 -1
- package/dist/smoke.js +250 -0
- package/dist/task-assignment-manifest.js +96 -0
- package/dist/vercel-sandbox-provider.js +260 -0
- package/dist/workbench-cli.js +493 -0
- package/dist/workspace-sync.js +924 -0
- package/dist/workspace.js +868 -57
- package/package.json +4 -1
- package/src/artifact-archive.ts +182 -0
- package/src/connectors-cli.ts +115 -0
- package/src/credentials.ts +6 -2
- package/src/daytona-slice-rehearsal.ts +320 -0
- package/src/daytona-slice.ts +598 -0
- package/src/egress-allowlist-cli.ts +147 -0
- package/src/egress-policy.ts +188 -0
- package/src/git-credential.ts +155 -0
- package/src/github-setup.ts +366 -0
- package/src/help.ts +277 -21
- package/src/hosted-bootstrap-rehearsal.ts +312 -0
- package/src/hosted-bootstrap.ts +571 -0
- package/src/hosted-broker-server.ts +278 -0
- package/src/hosted-git-http-server.ts +164 -0
- package/src/hosted-harness-claude.ts +621 -0
- package/src/hosted-harness-opencode.ts +823 -0
- package/src/hosted-harness.ts +124 -0
- package/src/hosted-loop.ts +680 -0
- package/src/hosted-run-event-sink.ts +632 -0
- package/src/hosted-runtime-assets.ts +272 -0
- package/src/hosted-session-cli.ts +844 -0
- package/src/index.ts +1102 -317
- package/src/kill-switch-cli.ts +113 -0
- package/src/manifests-cli.ts +168 -0
- package/src/preview-runtime.ts +14 -9
- package/src/sandbox-provider.ts +440 -0
- package/src/secret-redaction.ts +124 -0
- package/src/session-timeline.ts +152 -0
- package/src/setup-prompt.ts +4 -2
- package/src/skill-installer.ts +13 -1
- package/src/smoke.ts +342 -0
- package/src/task-assignment-manifest.ts +164 -0
- package/src/vercel-sandbox-provider.ts +388 -0
- package/src/workbench-cli.ts +701 -0
- package/src/workspace-sync.ts +1148 -0
- package/src/workspace.ts +1051 -64
- package/vendor/skills/orizu-cli/SKILL.md +8 -4
- package/vendor/skills/orizu-cli/references/cli-reference.md +12 -4
|
@@ -0,0 +1,499 @@
|
|
|
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
|
+
import { isHarnessTerminalKind } from './hosted-harness.js';
|
|
44
|
+
import { redactSecrets } from './secret-redaction.js';
|
|
45
|
+
// -- Egress-audit vocabulary (G5 / ALI-1006) ---------------------------------
|
|
46
|
+
// The startup egress CANARY (in-sandbox loop, `hosted-loop.ts`) emits ONE of
|
|
47
|
+
// these right after bootstrap: `egress_blocked` when a known non-allowlisted
|
|
48
|
+
// host is correctly denied (the firewall is live → proof), or `egress_allowed`
|
|
49
|
+
// when it is UNEXPECTEDLY reachable (the policy did NOT take → the loop then
|
|
50
|
+
// fails the run closed). Emitted through the loop's single sink (single-writer
|
|
51
|
+
// invariant preserved — no host-side second writer). See
|
|
52
|
+
// docs/.../sandbox-egress-policy.md and t1-t7-adversarial-results.md (T1).
|
|
53
|
+
export const EGRESS_ALLOWED_EVENT_TYPE = 'egress_allowed';
|
|
54
|
+
export const EGRESS_BLOCKED_EVENT_TYPE = 'egress_blocked';
|
|
55
|
+
// -- Mapping table (audit §4b, in-scope rows) --------------------------------
|
|
56
|
+
// Kinds NOT present here are handled out-of-band:
|
|
57
|
+
// execution_complete / error → terminal PATCH (never a raw event);
|
|
58
|
+
// push_complete / push_error → deferred to P3.5;
|
|
59
|
+
// heartbeat / user_message → never emitted by the harness (dropped at source).
|
|
60
|
+
const EVENT_TYPE_BY_KIND = {
|
|
61
|
+
ready: 'agent_ready',
|
|
62
|
+
token: 'agent_token',
|
|
63
|
+
tool_call: 'agent_tool_call',
|
|
64
|
+
tool_result: 'agent_tool_result',
|
|
65
|
+
step_start: 'agent_step_start',
|
|
66
|
+
step_finish: 'agent_step_finish',
|
|
67
|
+
git_sync: 'repo_sync',
|
|
68
|
+
artifact: 'artifact',
|
|
69
|
+
session_title: 'session_title',
|
|
70
|
+
// Egress-canary proof events (G5 / ALI-1006) — emitted by the in-sandbox loop
|
|
71
|
+
// through this SAME sink (single writer). See EGRESS_*_EVENT_TYPE below.
|
|
72
|
+
egress_blocked: EGRESS_BLOCKED_EVENT_TYPE,
|
|
73
|
+
egress_allowed: EGRESS_ALLOWED_EVENT_TYPE,
|
|
74
|
+
};
|
|
75
|
+
/** The RunAPI event type a harness kind maps to, or null if it is not appendable
|
|
76
|
+
* (terminal kinds route through the PATCH; deferred kinds have no mapping yet). */
|
|
77
|
+
export function eventTypeForKind(kind) {
|
|
78
|
+
return EVENT_TYPE_BY_KIND[kind] ?? null;
|
|
79
|
+
}
|
|
80
|
+
export const AGENT_TOKEN_EVENT_TYPE = 'agent_token';
|
|
81
|
+
export const AGENT_TRANSCRIPT_EVENT_TYPE = 'agent_transcript';
|
|
82
|
+
/** Bounded tail size for the transcript stopgap (see artifact-ref decision). */
|
|
83
|
+
export const TRANSCRIPT_TAIL_MAX_CHARS = 16_384;
|
|
84
|
+
// -- Reserved credential-use audit vocabulary (G6 / ALI-1007, NOT emitted) ----
|
|
85
|
+
// The DURABLE credential-use audit is the per-mint DB row (agent_session_tokens
|
|
86
|
+
// for agent-token mints; repo_token_mints for repo tokens): each rotation writes
|
|
87
|
+
// a fresh row with created_at/expires_at/revoked_at, which IS the credential-use
|
|
88
|
+
// trail. These event-type constants RESERVE the run-timeline vocabulary but are
|
|
89
|
+
// NOT emitted today: a run's event/sequence space has a SINGLE writer (the
|
|
90
|
+
// in-sandbox loop — see app/api/cli/workbench-runs/[id]/events/route.ts), and a
|
|
91
|
+
// host-side append here would race that writer (409 → a healthy run killed). A
|
|
92
|
+
// FUTURE emitter must run in-sandbox (single-writer) or be out-of-band
|
|
93
|
+
// coordinated before these are wired to the stream.
|
|
94
|
+
export const CREDENTIAL_ROTATED_EVENT_TYPE = 'credential_rotated';
|
|
95
|
+
export const CREDENTIAL_MINT_FAILED_EVENT_TYPE = 'credential_mint_failed';
|
|
96
|
+
/** Raised when an append is attempted against a run the sink knows is terminal. */
|
|
97
|
+
export class RunTerminalError extends Error {
|
|
98
|
+
constructor(message) {
|
|
99
|
+
super(message);
|
|
100
|
+
this.name = 'RunTerminalError';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Raised when a critical event (or the terminal PATCH) is undeliverable after
|
|
104
|
+
* the bounded retry budget — the caller MUST treat the run as compromised. */
|
|
105
|
+
export class CriticalDeliveryError extends Error {
|
|
106
|
+
constructor(message) {
|
|
107
|
+
super(message);
|
|
108
|
+
this.name = 'CriticalDeliveryError';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Raised on a 403 from the RunAPI. Named for what a 403 actually is — NOT "the
|
|
113
|
+
* run is terminal" (that is 404/410). A 403 means the caller may no longer write
|
|
114
|
+
* this run: the session bearer was revoked, OR the agent lost access to the run
|
|
115
|
+
* (e.g. its session was reassigned). The write definitely did not land, so the
|
|
116
|
+
* sink does NOT seal and does NOT burn the sequence; the caller decides whether
|
|
117
|
+
* to re-authenticate or abandon the run.
|
|
118
|
+
*/
|
|
119
|
+
export class RunAuthError extends Error {
|
|
120
|
+
constructor(message) {
|
|
121
|
+
super(message);
|
|
122
|
+
this.name = 'RunAuthError';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'cancelled']);
|
|
126
|
+
function isTerminalStatus(status) {
|
|
127
|
+
return status !== null && TERMINAL_STATUSES.has(status);
|
|
128
|
+
}
|
|
129
|
+
const DEFAULT_MAX_CRITICAL_ATTEMPTS = 4;
|
|
130
|
+
function defaultSleep(ms) {
|
|
131
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
132
|
+
}
|
|
133
|
+
function tail(text, max) {
|
|
134
|
+
return text.length <= max ? text : text.slice(text.length - max);
|
|
135
|
+
}
|
|
136
|
+
export function createRunEventSink(options) {
|
|
137
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
138
|
+
const now = options.now ?? (() => Date.now());
|
|
139
|
+
const sleep = options.sleepImpl ?? defaultSleep;
|
|
140
|
+
const base = options.apiBaseUrl.replace(/\/$/, '');
|
|
141
|
+
const eventIdPrefix = options.eventIdPrefix ?? options.runId;
|
|
142
|
+
const bearerOption = options.bearer;
|
|
143
|
+
const bearerIsRotatable = typeof bearerOption === 'function';
|
|
144
|
+
const resolveBearer = bearerIsRotatable ? bearerOption : () => bearerOption;
|
|
145
|
+
const extraSecrets = options.redactSecretsList ?? [];
|
|
146
|
+
// Resolve the CURRENT bearer for every redaction pass so a rotated token is
|
|
147
|
+
// scrubbed too (the fixed-string case still resolves the same value).
|
|
148
|
+
const currentSecrets = () => [resolveBearer(), ...extraSecrets];
|
|
149
|
+
const maxCriticalAttempts = options.maxCriticalAttempts ?? DEFAULT_MAX_CRITICAL_ATTEMPTS;
|
|
150
|
+
const diag = options.onDiagnostic ?? (() => { });
|
|
151
|
+
const eventsUrl = `${base}/api/cli/workbench-runs/${encodeURIComponent(options.runId)}/events`;
|
|
152
|
+
const runUrl = `${base}/api/cli/workbench-runs/${encodeURIComponent(options.runId)}`;
|
|
153
|
+
const authHeaders = () => ({
|
|
154
|
+
'Content-Type': 'application/json',
|
|
155
|
+
Authorization: `Bearer ${resolveBearer()}`,
|
|
156
|
+
});
|
|
157
|
+
// One request with a single re-auth retry: a 401/403 may just mean the bearer
|
|
158
|
+
// was rotated mid-flight, so re-resolve it ONCE and retry before the caller
|
|
159
|
+
// classifies the failure. Only meaningful when the bearer is a PROVIDER (a
|
|
160
|
+
// rotated file yields a new value); a fixed string re-resolves identically, so
|
|
161
|
+
// we skip the wasted retry and let the caller classify the first response.
|
|
162
|
+
async function requestWithReauth(url, init) {
|
|
163
|
+
const first = await fetchImpl(url, { ...init, headers: authHeaders() });
|
|
164
|
+
if (!bearerIsRotatable || (first.status !== 401 && first.status !== 403))
|
|
165
|
+
return first;
|
|
166
|
+
return fetchImpl(url, { ...init, headers: authHeaders() });
|
|
167
|
+
}
|
|
168
|
+
const recorded = [];
|
|
169
|
+
// Insertion-ordered coalescing buffer: one latest snapshot per messageId.
|
|
170
|
+
// A token event carries the FULL accumulated text, so we replace (not append)
|
|
171
|
+
// — N deltas collapse to one `agent_token` at the next flush boundary.
|
|
172
|
+
const tokenBuffer = new Map();
|
|
173
|
+
let nextSequence = options.startSequence ?? 1;
|
|
174
|
+
let sealed = false;
|
|
175
|
+
function assertWritable() {
|
|
176
|
+
if (sealed) {
|
|
177
|
+
throw new RunTerminalError('run is terminal server-side — refusing to append (Orizu records win)');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// POST one event. The sequence is allocated as a CANDIDATE and committed
|
|
181
|
+
// (counter advanced, event recorded) after a 2xx. On failure the candidate is
|
|
182
|
+
// handled by the "gaps tolerated, swaps NOT" rule (P2-4):
|
|
183
|
+
//
|
|
184
|
+
// • AMBIGUOUS failure — a network throw, a 5xx, or a sequence-conflict 409
|
|
185
|
+
// (anything where the server MAY already hold this sequence, or where the
|
|
186
|
+
// sequence is now unusable) — BURNS the candidate: the counter advances so
|
|
187
|
+
// a later append can never reuse this sequence with DIFFERENT content. The
|
|
188
|
+
// cost is a possible server-visible gap, which is acceptable; a silent
|
|
189
|
+
// content swap at a committed sequence is not.
|
|
190
|
+
// • CLEAN failure — a 400 validation error, where the server definitely did
|
|
191
|
+
// not commit — KEEPS the candidate: the next append reuses this sequence.
|
|
192
|
+
// • 404 / 410 seal the sink (the run is gone/terminal server-side).
|
|
193
|
+
// • 403 throws RunAuthError WITHOUT sealing (bearer revoked or access lost).
|
|
194
|
+
//
|
|
195
|
+
// Because the eventId is derived from the candidate sequence, a retry of the
|
|
196
|
+
// SAME content within the critical budget is an idempotent replay (same
|
|
197
|
+
// eventId), never a same-sequence-different-eventId 409.
|
|
198
|
+
async function postEvent(eventType, payload, critical) {
|
|
199
|
+
const sequence = nextSequence;
|
|
200
|
+
const eventId = `${eventIdPrefix}:${sequence}`;
|
|
201
|
+
const redactedPayload = redactSecrets(payload, { secrets: currentSecrets() });
|
|
202
|
+
const body = JSON.stringify({
|
|
203
|
+
eventId,
|
|
204
|
+
sequence,
|
|
205
|
+
eventType,
|
|
206
|
+
occurredAt: new Date(now()).toISOString(),
|
|
207
|
+
payload: redactedPayload,
|
|
208
|
+
});
|
|
209
|
+
const attempts = critical ? maxCriticalAttempts : 1;
|
|
210
|
+
let lastDetail = '';
|
|
211
|
+
// Burn the candidate sequence on exit unless we prove the server did not
|
|
212
|
+
// (and cannot have) committed it — i.e. a clean 4xx.
|
|
213
|
+
let burnSequence = false;
|
|
214
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
215
|
+
let response;
|
|
216
|
+
try {
|
|
217
|
+
response = await requestWithReauth(eventsUrl, { method: 'POST', body });
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
// Network throw: the request may have reached and committed on the
|
|
221
|
+
// server — ambiguous, so burn.
|
|
222
|
+
lastDetail = error instanceof Error ? error.message : String(error);
|
|
223
|
+
burnSequence = true;
|
|
224
|
+
if (critical && attempt < attempts) {
|
|
225
|
+
await sleep(2 ** (attempt - 1) * 50);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
if (response.ok) {
|
|
231
|
+
nextSequence = sequence + 1;
|
|
232
|
+
recorded.push({ eventId, sequence, eventType, payload: redactedPayload });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// Run gone/terminal server-side: seal and stop (Orizu records win).
|
|
236
|
+
if (response.status === 404 || response.status === 410) {
|
|
237
|
+
sealed = true;
|
|
238
|
+
diag(`append rejected (${response.status}) for ${eventType} — sealing sink (run terminal)`);
|
|
239
|
+
throw new RunTerminalError(`append rejected (${response.status}) — run is terminal server-side`);
|
|
240
|
+
}
|
|
241
|
+
// Not authorized to write this run: revoked bearer OR lost access. The
|
|
242
|
+
// re-auth retry above already re-resolved the bearer once (a rotation), so
|
|
243
|
+
// a persistent 401/403 is a real auth failure. The write did not land;
|
|
244
|
+
// surface a distinct error and do NOT seal or burn.
|
|
245
|
+
if (response.status === 401 || response.status === 403) {
|
|
246
|
+
diag(`append rejected (${response.status}) for ${eventType} — bearer revoked or run access lost`);
|
|
247
|
+
throw new RunAuthError(`append rejected (${response.status}) for ${eventType} — bearer revoked or run access lost`);
|
|
248
|
+
}
|
|
249
|
+
lastDetail = `status ${response.status}`;
|
|
250
|
+
// 5xx (server may have committed) or 409 (sequence taken/unusable): burn.
|
|
251
|
+
if (response.status >= 500 || response.status === 409) {
|
|
252
|
+
burnSequence = true;
|
|
253
|
+
if (critical && response.status >= 500 && attempt < attempts) {
|
|
254
|
+
await sleep(2 ** (attempt - 1) * 50);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
// Clean 4xx (e.g. 400 validation): server did not commit — keep the
|
|
260
|
+
// sequence for the next append.
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
if (burnSequence)
|
|
264
|
+
nextSequence = sequence + 1;
|
|
265
|
+
if (critical) {
|
|
266
|
+
throw new CriticalDeliveryError(`critical event ${eventType} undeliverable after ${attempts} attempts (${lastDetail})`);
|
|
267
|
+
}
|
|
268
|
+
throw new Error(`run-event append failed (${lastDetail}) for ${eventType}`);
|
|
269
|
+
}
|
|
270
|
+
async function flushTokens() {
|
|
271
|
+
if (tokenBuffer.size === 0)
|
|
272
|
+
return;
|
|
273
|
+
const pending = [...tokenBuffer.entries()];
|
|
274
|
+
tokenBuffer.clear();
|
|
275
|
+
for (const [messageId, text] of pending) {
|
|
276
|
+
await postEvent(AGENT_TOKEN_EVENT_TYPE, { messageId, text }, false);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
async function append(event) {
|
|
280
|
+
assertWritable();
|
|
281
|
+
if (isHarnessTerminalKind(event.kind)) {
|
|
282
|
+
throw new Error(`${event.kind} is terminal — route it through finish(), not append()`);
|
|
283
|
+
}
|
|
284
|
+
if (event.kind === 'token') {
|
|
285
|
+
const messageId = event.messageId ?? '_';
|
|
286
|
+
const text = typeof event.payload.text === 'string' ? event.payload.text : '';
|
|
287
|
+
tokenBuffer.set(messageId, text);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const eventType = eventTypeForKind(event.kind);
|
|
291
|
+
if (!eventType) {
|
|
292
|
+
// Deferred kinds (push_*) have no mapping in this slice — drop explicitly.
|
|
293
|
+
diag(`no event-type mapping for kind ${event.kind} — dropped (deferred)`);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
// Any structural (non-token) event is a coalescing boundary.
|
|
297
|
+
await flushTokens();
|
|
298
|
+
await postEvent(eventType, event.payload, event.critical === true);
|
|
299
|
+
}
|
|
300
|
+
// GET the run's server-side status (run detail route). Best-effort: returns
|
|
301
|
+
// null on any transport/parse failure or non-2xx so the caller can decide.
|
|
302
|
+
async function fetchRunStatus() {
|
|
303
|
+
try {
|
|
304
|
+
const res = await requestWithReauth(runUrl, { method: 'GET' });
|
|
305
|
+
if (!res.ok)
|
|
306
|
+
return null;
|
|
307
|
+
const data = (await res.json());
|
|
308
|
+
const status = data.run?.status;
|
|
309
|
+
return typeof status === 'string' ? status : null;
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// PATCH the terminal transition. The route ingests the terminal event BEFORE
|
|
316
|
+
// the status CAS and retries once internally on a sequence conflict; we add a
|
|
317
|
+
// bounded critical-retry on transient transport/5xx failures and honor the
|
|
318
|
+
// route's already-terminal semantics (Orizu records win).
|
|
319
|
+
async function patchTerminal(status, opts) {
|
|
320
|
+
const eventId = `${eventIdPrefix}:terminal:${status}`;
|
|
321
|
+
const patchBody = { status, eventId };
|
|
322
|
+
if (opts?.summary)
|
|
323
|
+
patchBody.summary = redactSecrets(opts.summary, { secrets: currentSecrets() });
|
|
324
|
+
if (opts?.evidence)
|
|
325
|
+
patchBody.evidence = redactSecrets(opts.evidence, { secrets: currentSecrets() });
|
|
326
|
+
const body = JSON.stringify(patchBody);
|
|
327
|
+
let sequenceRetried = false;
|
|
328
|
+
let lastDetail = '';
|
|
329
|
+
for (let attempt = 1; attempt <= maxCriticalAttempts; attempt += 1) {
|
|
330
|
+
let response;
|
|
331
|
+
try {
|
|
332
|
+
response = await requestWithReauth(runUrl, { method: 'PATCH', body });
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
lastDetail = error instanceof Error ? error.message : String(error);
|
|
336
|
+
if (attempt < maxCriticalAttempts) {
|
|
337
|
+
await sleep(2 ** (attempt - 1) * 50);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
if (response.ok) {
|
|
343
|
+
sealed = true;
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (response.status === 409) {
|
|
347
|
+
// A 409 is either a sequence conflict (the route retries its own ingest;
|
|
348
|
+
// one more attempt here may clear it) or an already-terminal run (a
|
|
349
|
+
// concurrent finisher won). Retry once; if it STILL 409s, do not guess —
|
|
350
|
+
// ask the server what the run's status actually is. Only a terminal
|
|
351
|
+
// status lets us seal (Orizu records win); a run still running/pending
|
|
352
|
+
// means our transition did not happen and we must NOT report success.
|
|
353
|
+
if (!sequenceRetried) {
|
|
354
|
+
sequenceRetried = true;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const serverStatus = await fetchRunStatus();
|
|
358
|
+
if (isTerminalStatus(serverStatus)) {
|
|
359
|
+
sealed = true;
|
|
360
|
+
diag(`terminal PATCH 409 — server run already ${serverStatus} (Orizu records win)`);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
lastDetail = `409 conflict, server status ${serverStatus ?? 'unknown'}`;
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
lastDetail = `status ${response.status}`;
|
|
367
|
+
if (response.status >= 500 && attempt < maxCriticalAttempts) {
|
|
368
|
+
await sleep(2 ** (attempt - 1) * 50);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
throw new CriticalDeliveryError(`terminal PATCH (${status}) undeliverable (${lastDetail})`);
|
|
374
|
+
}
|
|
375
|
+
async function finish(status, opts) {
|
|
376
|
+
if (sealed) {
|
|
377
|
+
diag(`finish(${status}) called on an already-sealed sink — ignored (idempotent)`);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
// Pre-terminal appends (still writable): flush coalesced tokens, then the
|
|
381
|
+
// bounded transcript tail (the seal below refuses any later append).
|
|
382
|
+
await flushTokens();
|
|
383
|
+
if (opts?.transcript) {
|
|
384
|
+
await postEvent(AGENT_TRANSCRIPT_EVENT_TYPE, { text: tail(opts.transcript, TRANSCRIPT_TAIL_MAX_CHARS), truncated: opts.transcript.length > TRANSCRIPT_TAIL_MAX_CHARS }, true);
|
|
385
|
+
}
|
|
386
|
+
await patchTerminal(status, opts);
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
append,
|
|
390
|
+
flushTokens,
|
|
391
|
+
finish,
|
|
392
|
+
get sealed() {
|
|
393
|
+
return sealed;
|
|
394
|
+
},
|
|
395
|
+
get nextSequence() {
|
|
396
|
+
return nextSequence;
|
|
397
|
+
},
|
|
398
|
+
recorded,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Rebuild a sink after a restart WITHOUT losing run continuity (acceptance:
|
|
403
|
+
* "reconnect/resume without losing run continuity"). Walks the events GET
|
|
404
|
+
* cursor tail to the end to learn the server's latest sequence, then returns a
|
|
405
|
+
* fresh sink whose first allocation is `latest + 1`.
|
|
406
|
+
*
|
|
407
|
+
* ── QUIESCE PRECONDITION (single-writer handoff) ─────────────────────────────
|
|
408
|
+
* This is safe ONLY when the predecessor sink has FULLY STOPPED before resume
|
|
409
|
+
* runs — the RunAPI is single-writer-per-run (see the events route), and resume
|
|
410
|
+
* assumes it is the sole writer while it reads the tail and picks the next
|
|
411
|
+
* sequence. It does NOT coordinate with a live predecessor. If a still-in-flight
|
|
412
|
+
* commit from a not-yet-dead sink lands AFTER we read the cursor, that sink and
|
|
413
|
+
* this one can both target the same next sequence. We do not claim "no
|
|
414
|
+
* collision": deterministic eventIds (`<runId>:<sequence>`) make an identical
|
|
415
|
+
* replay an idempotent 200, but two DIFFERENT payloads racing one sequence is a
|
|
416
|
+
* real conflict — bounded, not eliminated, by the P2-4 burn rule (the loser
|
|
417
|
+
* burns its candidate and advances rather than swapping committed content).
|
|
418
|
+
* Callers MUST ensure the predecessor is quiesced; treat overlap as a bug.
|
|
419
|
+
*
|
|
420
|
+
* ── STUCK-RUNNING RECOVERY (out of scope here) ───────────────────────────────
|
|
421
|
+
* If a sink crashes BETWEEN its last append and finish(), the run is left with
|
|
422
|
+
* status='running' server-side and no terminal event — resume can continue
|
|
423
|
+
* appending, but nothing in THIS module will time out and finalize an abandoned
|
|
424
|
+
* run whose writer never came back. A server-side watchdog owns that recovery
|
|
425
|
+
* (G6 / ALI-1007); it is deliberately not implemented in the sandbox client.
|
|
426
|
+
*/
|
|
427
|
+
export async function resumeRunEventSink(options) {
|
|
428
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
429
|
+
const base = options.apiBaseUrl.replace(/\/$/, '');
|
|
430
|
+
const pageSize = options.pageSize ?? 500;
|
|
431
|
+
const bearerOption = options.bearer;
|
|
432
|
+
const resolveBearer = typeof bearerOption === 'function' ? bearerOption : () => bearerOption;
|
|
433
|
+
let cursor = 0;
|
|
434
|
+
for (;;) {
|
|
435
|
+
const url = `${base}/api/cli/workbench-runs/${encodeURIComponent(options.runId)}/events?after=${cursor}&limit=${pageSize}`;
|
|
436
|
+
const response = await fetchImpl(url, {
|
|
437
|
+
method: 'GET',
|
|
438
|
+
headers: { Authorization: `Bearer ${resolveBearer()}` },
|
|
439
|
+
});
|
|
440
|
+
if (!response.ok) {
|
|
441
|
+
throw new Error(`resume cursor read failed (${response.status})`);
|
|
442
|
+
}
|
|
443
|
+
const data = (await response.json());
|
|
444
|
+
const nextCursor = typeof data.cursor === 'number' ? data.cursor : cursor;
|
|
445
|
+
const count = Array.isArray(data.events) ? data.events.length : 0;
|
|
446
|
+
cursor = nextCursor;
|
|
447
|
+
if (count < pageSize)
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
return createRunEventSink({ ...options, startSequence: cursor + 1 });
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Consume a harness `runPrompt` stream into a sink: structural events are
|
|
454
|
+
* appended (with token coalescing), and the single terminal event
|
|
455
|
+
* (`execution_complete` / `error`) is routed to `finish()` — NEVER appended.
|
|
456
|
+
* This is the event bridge's happy-path driver; the terminal event decides the
|
|
457
|
+
* run's final status.
|
|
458
|
+
*/
|
|
459
|
+
export async function drainHarnessToSink(events, sink, opts = {}) {
|
|
460
|
+
for await (const event of events) {
|
|
461
|
+
if (event.kind === 'execution_complete') {
|
|
462
|
+
// A prompt the caller/agent stopped is a cancellation, not a failure: the
|
|
463
|
+
// driver flags it with `aborted: true` (P2-2) for both the signal-abort
|
|
464
|
+
// and stop()-initiated paths. A genuine failure (success:false, no abort
|
|
465
|
+
// flag) still lands 'failed'.
|
|
466
|
+
if (event.payload.aborted === true) {
|
|
467
|
+
await sink.finish('cancelled', {
|
|
468
|
+
summary: opts.summary,
|
|
469
|
+
evidence: opts.evidence,
|
|
470
|
+
transcript: opts.transcript,
|
|
471
|
+
});
|
|
472
|
+
return 'cancelled';
|
|
473
|
+
}
|
|
474
|
+
const success = event.payload.success !== false;
|
|
475
|
+
const status = success ? 'succeeded' : 'failed';
|
|
476
|
+
const summary = success
|
|
477
|
+
? opts.summary
|
|
478
|
+
: { ...(opts.summary ?? {}), error: event.payload.error ?? 'execution failed' };
|
|
479
|
+
await sink.finish(status, { summary, evidence: opts.evidence, transcript: opts.transcript });
|
|
480
|
+
return status;
|
|
481
|
+
}
|
|
482
|
+
if (event.kind === 'error') {
|
|
483
|
+
await sink.finish('failed', {
|
|
484
|
+
summary: { ...(opts.summary ?? {}), error: event.payload.error ?? 'agent error' },
|
|
485
|
+
evidence: opts.evidence,
|
|
486
|
+
transcript: opts.transcript,
|
|
487
|
+
});
|
|
488
|
+
return 'failed';
|
|
489
|
+
}
|
|
490
|
+
await sink.append(event);
|
|
491
|
+
}
|
|
492
|
+
// The harness contract guarantees a terminal event; reaching here means the
|
|
493
|
+
// stream ended abnormally. Fail closed so the run never dangles non-terminal.
|
|
494
|
+
await sink.finish('failed', {
|
|
495
|
+
summary: { ...(opts.summary ?? {}), error: 'harness stream ended without a terminal event' },
|
|
496
|
+
transcript: opts.transcript,
|
|
497
|
+
});
|
|
498
|
+
return 'failed';
|
|
499
|
+
}
|