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,103 @@
1
+ /**
2
+ * reconstructSessionTimeline — the G6 audit-replay proof (ALI-1007).
3
+ *
4
+ * Acceptance ("replaying run events reconstructs the session"): given ONLY the
5
+ * server-side, append-only workbench run events (each `{ sequence, eventType }`,
6
+ * fetched from the events cursor — nothing client-held), rebuild the ordered
7
+ * session timeline and assert its COMPLETENESS as a behavioral proof:
8
+ * - every lifecycle PHASE is represented (bootstrap → ready → work → completion);
9
+ * - sequences are gapless, or the gaps are enumerated (the sink documents that
10
+ * an ambiguous append burns a sequence, so a gap is tolerated-but-recorded);
11
+ * - the terminal status is derivable (the run row's terminal status, itself
12
+ * server-side, corroborated by the pre-terminal `agent_transcript` marker).
13
+ *
14
+ * Pure: no I/O, no clock. The caller supplies the events (and the server run
15
+ * status, which is also server-side) so this composes into an e2e test or a
16
+ * server-side watchdog without a network.
17
+ */
18
+ // The lifecycle phases a hosted session passes through, in order. `completion`
19
+ // is the transcript tail (an optional pre-terminal event); the actual terminal
20
+ // transition is a run-row status set via PATCH, NOT an event — so completeness
21
+ // requires the terminal STATUS (see terminalDerivable), while the completion
22
+ // EVENT only corroborates it.
23
+ export const SESSION_PHASES = ['bootstrap', 'ready', 'work', 'completion'];
24
+ // The phases that MUST be represented by events for a session to reconstruct.
25
+ // `completion` is excluded: the terminal is a PATCH-set run status, not an event.
26
+ export const REQUIRED_EVENT_PHASES = ['bootstrap', 'ready', 'work'];
27
+ // Map each known server-side event type onto its lifecycle phase. Types absent
28
+ // here are still ordered in the timeline but tagged `phase: null` (they do not
29
+ // satisfy a required phase). Kept as a const map (no enums, per house style).
30
+ const PHASE_BY_EVENT_TYPE = {
31
+ // bootstrap: run start + sandbox/repo/runtime provisioning
32
+ run_started: 'bootstrap',
33
+ sandbox_provisioned: 'bootstrap',
34
+ repo_cloned: 'bootstrap',
35
+ cli_installed: 'bootstrap',
36
+ setup_hook_completed: 'bootstrap',
37
+ setup_hook_skipped: 'bootstrap',
38
+ // P3-a: for enforced-egress providers bootstrap defers the hook to the loop
39
+ // (which runs it after the egress canary); this marks the deferral in-timeline.
40
+ setup_hook_deferred: 'bootstrap',
41
+ // ready: the agent harness is up and holds a live credential
42
+ agent_ready: 'ready',
43
+ agent_token: 'ready',
44
+ // The startup egress canary (G5) fires in the ready window, right after
45
+ // bootstrap and before any agent work — proof the firewall is live.
46
+ egress_blocked: 'ready',
47
+ egress_allowed: 'ready',
48
+ // credential_rotated / credential_mint_failed are RESERVED vocabulary, NOT
49
+ // emitted today (host-side run-stream emission was rejected as a single-writer
50
+ // violation; the durable credential-use audit is the per-mint DB row). They
51
+ // stay mapped so a FUTURE single-writer emitter classifies correctly, but the
52
+ // `ready` phase is satisfied independently by agent_ready/agent_token — replay
53
+ // completeness never depends on a credential event.
54
+ credential_rotated: 'ready',
55
+ credential_mint_failed: 'ready',
56
+ // work: the agent acts (tool calls/results, steps, repo writes, artifacts)
57
+ agent_tool_call: 'work',
58
+ agent_tool_result: 'work',
59
+ agent_step_start: 'work',
60
+ agent_step_finish: 'work',
61
+ repo_sync: 'work',
62
+ artifact: 'work',
63
+ session_title: 'work',
64
+ // completion: the bounded transcript tail recorded just before the terminal PATCH
65
+ agent_transcript: 'completion',
66
+ };
67
+ const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'cancelled']);
68
+ export function reconstructSessionTimeline(events, opts = {}) {
69
+ const ordered = [...events]
70
+ .sort((a, b) => a.sequence - b.sequence)
71
+ .map(event => ({
72
+ sequence: event.sequence,
73
+ eventType: event.eventType,
74
+ phase: PHASE_BY_EVENT_TYPE[event.eventType] ?? null,
75
+ }));
76
+ const phasesPresent = Object.fromEntries(SESSION_PHASES.map(phase => [phase, ordered.some(entry => entry.phase === phase)]));
77
+ const missingPhases = REQUIRED_EVENT_PHASES.filter(phase => !phasesPresent[phase]);
78
+ const sequenceGaps = [];
79
+ for (let i = 1; i < ordered.length; i += 1) {
80
+ const previous = ordered[i - 1].sequence;
81
+ const current = ordered[i].sequence;
82
+ if (current > previous + 1) {
83
+ sequenceGaps.push([previous, current]);
84
+ }
85
+ }
86
+ const gapless = sequenceGaps.length === 0;
87
+ const runStatus = opts.runStatus ?? null;
88
+ const terminalStatus = runStatus && TERMINAL_STATUSES.has(runStatus) ? runStatus : null;
89
+ // The terminal is the run-row status (PATCH-set, server-side). The completion
90
+ // event (`agent_transcript`), when present, corroborates it but is not required.
91
+ const terminalDerivable = terminalStatus !== null;
92
+ const complete = missingPhases.length === 0 && terminalDerivable;
93
+ return {
94
+ ordered,
95
+ phasesPresent,
96
+ missingPhases,
97
+ sequenceGaps,
98
+ gapless,
99
+ terminalStatus,
100
+ terminalDerivable,
101
+ complete,
102
+ };
103
+ }
@@ -1,7 +1,7 @@
1
1
  export function renderAgentSetupPrompt(options) {
2
2
  const workspaceLine = options?.workspacePath
3
- ? `- Local Orizu artifacts belong under ${options.workspacePath} (gitignored). Keep secrets out of the repo; credentials live in ~/.config/orizu.`
4
- : '- If local Orizu artifacts are needed, suggest creating a gitignored .orizu/ workspace (orizu setup can do this).';
3
+ ? `- The Orizu workbench root is ${options.workspacePath}. Read AGENTS.md, Memory.md, project READMEs, and orizu.*.json manifests before changing anything.`
4
+ : '- If this repo is not initialized yet, suggest running `orizu setup --team <slug>` from the desired directory to create the local Orizu workbench contract.';
5
5
  return `Help me embed Orizu (eval-first LLM optimization) into this repository's workflow.
6
6
 
7
7
  Start by learning the Orizu workflow:
@@ -14,6 +14,8 @@ Then inspect this repository and propose how Orizu should fit it:
14
14
  - Propose datasets: existing data that can be uploaded now, or a concrete plan to collect examples if none exists.
15
15
  - Propose which prompts to version in Orizu, and which scorers or LLM judges would capture quality for them.
16
16
  - Propose runner artifacts where local execution is needed.
17
+ - Use root AGENTS.md, CLAUDE.md, and Memory.md for team context. Discover project and primitive context through README files and orizu.<kind>.json manifests.
18
+ - Keep Git-tracked source/context separate from Orizu DB state, object-storage bytes, and local/ephemeral files. Treat .orizu/ as ignored cache/generated state.
17
19
  ${workspaceLine}
18
20
 
19
21
  Important boundaries:
@@ -3,10 +3,12 @@ import { dirname, isAbsolute, join, relative, resolve } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { homedir } from 'os';
5
5
  import { createHash } from 'crypto';
6
+ import { workspaceExists } from './workspace.js';
6
7
  export const SKILL_NAME = 'orizu-cli';
7
8
  export const AGENTS_START_MARKER = '<!-- orizu-cli:start -->';
8
9
  export const AGENTS_END_MARKER = '<!-- orizu-cli:end -->';
9
10
  export const SKILL_INSTALL_TARGETS = [
11
+ 'codex-user',
10
12
  'agent-user',
11
13
  'agents-project',
12
14
  'codex-project',
@@ -114,6 +116,9 @@ function resolveCwd(options) {
114
116
  export function getSkillInstallPath(target, options) {
115
117
  const home = resolveHome(options);
116
118
  const cwd = resolveCwd(options);
119
+ if (target === 'codex-user') {
120
+ return resolve(home, '.codex', 'skills', SKILL_NAME);
121
+ }
117
122
  if (target === 'agent-user') {
118
123
  return resolve(home, '.agents', 'skills', SKILL_NAME);
119
124
  }
@@ -135,7 +140,7 @@ export function getTargetForAgent(agent, scope) {
135
140
  if (agent === 'claude') {
136
141
  return scope === 'global' ? 'claude-user' : 'claude-project';
137
142
  }
138
- return scope === 'global' ? 'agent-user' : 'agents-project';
143
+ return scope === 'global' ? 'codex-user' : 'agents-project';
139
144
  }
140
145
  export function isSkillInstallAgent(value) {
141
146
  return SKILL_INSTALL_AGENTS.includes(value);
@@ -212,6 +217,9 @@ function buildAgentsSection(sourceDir) {
212
217
  return `${AGENTS_START_MARKER}\n${body}\n${AGENTS_END_MARKER}\n`;
213
218
  }
214
219
  function installAgentsMd(sourceDir, targetPath, options) {
220
+ if (workspaceExists(dirname(targetPath))) {
221
+ throw new Error(`${targetPath} belongs to an Orizu workspace. Keep root AGENTS.md as concise workspace guidance; use global skill installs or project skill directories for detailed Orizu CLI reference material.`);
222
+ }
215
223
  const section = buildAgentsSection(sourceDir);
216
224
  const exists = existsSync(targetPath);
217
225
  const existing = exists ? readFileSync(targetPath, 'utf8') : '';
package/dist/smoke.js ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Terminal-only Phase 2 smoke workflow (ALI-908).
3
+ *
4
+ * Pure, importable step logic with an injected fetcher factory. It drives the
5
+ * REAL Phase 2 CLI command modules (workbench-cli sessions/runs/tail/terminal)
6
+ * plus the RunAPI / promotion-manifest routes over HTTP. The `scripts/workbench-
7
+ * smoke.mjs` driver supplies a live-server fetcher from env; the unit test
8
+ * supplies an in-memory fake server. No process/argv/print here.
9
+ *
10
+ * The workflow proves, against server-side state only:
11
+ * - create-or-attach workspace idempotency (reused on a second run),
12
+ * - a session + workbench run with persisted append-only events,
13
+ * - terminal-death reconnect (a fresh client re-reads every prior event),
14
+ * - promotion-manifest create + apply idempotency,
15
+ * - run-completion idempotency (one terminal event under a retried PATCH).
16
+ *
17
+ * Phase 2 boundaries: no OpenInspect, no hosted sandbox, no Slack/web entry,
18
+ * no Cloudflare Artifacts, no durable overnight jobs.
19
+ */
20
+ import { runSessionEnd, runSessionStart, runWorkbenchRunStart, runWorkbenchRunStatus, runWorkbenchRunTerminal, tailOnce, } from './workbench-cli.js';
21
+ export const SMOKE_RUN_TITLE = 'smoke: highlight-derived workflow';
22
+ // Deterministic, HIP/highlight-shaped events appended after run start (seq 1 is
23
+ // the server-emitted run_started). Stable event_ids make the append idempotent.
24
+ export const SMOKE_EVENTS = [
25
+ {
26
+ eventId: 'smoke-evt-1',
27
+ eventType: 'highlight_ingested',
28
+ sequence: 2,
29
+ payload: { highlightId: 'hl-smoke-001', source: 'hip', span: { start: 0, end: 42 } },
30
+ },
31
+ {
32
+ eventId: 'smoke-evt-2',
33
+ eventType: 'eval_scored',
34
+ sequence: 3,
35
+ payload: { metricKey: 'faithfulness', score: 0.87, comparable: true },
36
+ },
37
+ {
38
+ eventId: 'smoke-evt-3',
39
+ eventType: 'notes_recorded',
40
+ sequence: 4,
41
+ payload: { note: 'Highlight-derived smoke: no regression detected.' },
42
+ },
43
+ ];
44
+ const STEP_PLAN = [
45
+ { key: 'workspace', title: 'Workspace resolve-or-attach', plan: 'POST /api/cli/workspaces (idempotent on team+slug)' },
46
+ { key: 'session', title: 'Session start', plan: 'POST /api/cli/workspaces/{id}/sessions' },
47
+ { key: 'run', title: 'Workbench run start', plan: 'POST /api/cli/workbench-runs + verify seq-1 run_started via tail' },
48
+ { key: 'events', title: 'Append highlight-derived events', plan: 'POST /workbench-runs/{id}/events x3 (smoke-evt-1..3)' },
49
+ { key: 'reconnect', title: 'Terminal-death reconnect', plan: 'fresh client re-fetches run + tails from cursor 0' },
50
+ { key: 'manifest', title: 'Promotion manifest no_change', plan: 'create (idempotent) -> approve -> apply -> re-apply' },
51
+ { key: 'complete', title: 'Complete run + end session', plan: 'PATCH succeeded (idempotent) + PATCH session ended' },
52
+ { key: 'summary', title: 'Re-run accounting', plan: 'classify reused vs created rows' },
53
+ ];
54
+ export function planSmokeSteps(_config) {
55
+ return STEP_PLAN.map((step, i) => ({ index: i + 1, key: step.key, title: step.title, plan: step.plan }));
56
+ }
57
+ function titleFor(key) {
58
+ return STEP_PLAN.find(step => step.key === key)?.title ?? key;
59
+ }
60
+ function indexFor(key) {
61
+ return STEP_PLAN.findIndex(step => step.key === key) + 1;
62
+ }
63
+ async function readBody(response) {
64
+ try {
65
+ return (await response.json());
66
+ }
67
+ catch {
68
+ return {};
69
+ }
70
+ }
71
+ async function errorMessage(response) {
72
+ const body = await readBody(response.clone());
73
+ const error = typeof body.error === 'string' ? body.error : null;
74
+ return error || response.statusText || String(response.status);
75
+ }
76
+ async function postJson(fetcher, path, payload) {
77
+ const response = await fetcher(path, {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify(payload),
81
+ });
82
+ if (!response.ok) {
83
+ throw new Error(`${path} failed (${response.status}): ${await errorMessage(response)}`);
84
+ }
85
+ return { status: response.status, body: await readBody(response) };
86
+ }
87
+ function requireObject(value, label) {
88
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
89
+ throw new Error(`${label} missing from response`);
90
+ }
91
+ return value;
92
+ }
93
+ function requireId(value, label) {
94
+ if (typeof value !== 'string' || value.length === 0) {
95
+ throw new Error(`${label} missing from response`);
96
+ }
97
+ return value;
98
+ }
99
+ export async function runSmokeWorkflow(opts) {
100
+ const { config } = opts;
101
+ const fetcher = opts.makeFetcher();
102
+ const steps = [];
103
+ const summary = { created: [], reused: [], verified: [] };
104
+ const ids = {};
105
+ const record = (key, disposition, detail) => {
106
+ const step = { index: indexFor(key), key, title: titleFor(key), ok: true, disposition, detail };
107
+ steps.push(step);
108
+ if (disposition === 'created')
109
+ summary.created.push(key);
110
+ if (disposition === 'reused')
111
+ summary.reused.push(key);
112
+ if (disposition === 'verified')
113
+ summary.verified.push(key);
114
+ opts.onStep?.(step);
115
+ return step;
116
+ };
117
+ try {
118
+ // 1 — Workspace resolve-or-attach (201 created / 200 reused).
119
+ const workspace = await postJson(fetcher, '/api/cli/workspaces', {
120
+ teamSlug: config.teamSlug,
121
+ name: config.workspaceSlug,
122
+ slug: config.workspaceSlug,
123
+ });
124
+ const workspaceId = requireId(requireObject(workspace.body.workspace, 'workspace').id, 'workspace id');
125
+ ids.workspaceId = workspaceId;
126
+ const workspaceDisposition = workspace.status === 201 ? 'created' : 'reused';
127
+ record('workspace', workspaceDisposition, `${workspaceDisposition} workspace ${workspaceId} (slug=${config.workspaceSlug})`);
128
+ // 2 — Session start (always a new row).
129
+ const session = await runSessionStart({
130
+ fetcher,
131
+ workspaceId,
132
+ projectSlug: config.projectSlug,
133
+ clientInfo: { source: 'orizu-smoke' },
134
+ });
135
+ const sessionId = requireId(session.session.id, 'session id');
136
+ ids.sessionId = sessionId;
137
+ record('session', 'created', `created session ${sessionId}`);
138
+ // 3 — Workbench run start + verify the server-emitted seq-1 run_started.
139
+ const runStart = await runWorkbenchRunStart({ fetcher, sessionId, title: SMOKE_RUN_TITLE, projectSlug: config.projectSlug });
140
+ const runId = requireId(runStart.run.id, 'run id');
141
+ ids.runId = runId;
142
+ const opening = await tailOnce(fetcher, runId, 0);
143
+ const started = opening.events.find(event => event.sequence === 1);
144
+ if (!started || started.eventType !== 'run_started') {
145
+ throw new Error('seq-1 run_started event was not present on tail');
146
+ }
147
+ record('run', 'created', `created run ${runId}; verified seq-1 run_started`);
148
+ // 4 — Append the three highlight-derived events (deterministic ids).
149
+ for (const event of SMOKE_EVENTS) {
150
+ const payload = { ...event.payload };
151
+ if (event.eventType === 'eval_scored' && config.scoreRunId) {
152
+ payload.scoreRunId = config.scoreRunId;
153
+ }
154
+ const appended = await postJson(fetcher, `/api/cli/workbench-runs/${encodeURIComponent(runId)}/events`, {
155
+ eventId: event.eventId,
156
+ eventType: event.eventType,
157
+ sequence: event.sequence,
158
+ payload,
159
+ });
160
+ if (appended.status !== 201 && appended.status !== 200) {
161
+ throw new Error(`unexpected append status ${appended.status} for ${event.eventId}`);
162
+ }
163
+ }
164
+ record('events', 'created', `appended ${SMOKE_EVENTS.length} events (${SMOKE_EVENTS.map(e => e.eventId).join(', ')})`);
165
+ // 5 — Simulated terminal death: a fresh, stateless client re-reads the run.
166
+ const reconnectFetcher = opts.makeFetcher();
167
+ const reread = await runWorkbenchRunStatus({ fetcher: reconnectFetcher, runId });
168
+ if (reread.run.id !== runId) {
169
+ throw new Error('reconnected client resolved a different run id');
170
+ }
171
+ const replay = await tailOnce(reconnectFetcher, runId, 0);
172
+ const expectedTypes = ['run_started', ...SMOKE_EVENTS.map(e => e.eventType)];
173
+ const seenTypes = replay.events.filter(e => e.sequence <= expectedTypes.length).map(e => e.eventType);
174
+ for (const expected of expectedTypes) {
175
+ if (!seenTypes.includes(expected)) {
176
+ throw new Error(`reconnect did not see ${expected}; state was lost`);
177
+ }
178
+ }
179
+ record('reconnect', 'verified', `fresh client re-read ${expectedTypes.length} events after kill (${expectedTypes.join(', ')})`);
180
+ // 6 — Promotion manifest: create (idempotent), approve, apply, re-apply.
181
+ const projectRef = `${config.teamSlug}/${config.projectSlug}`;
182
+ const idempotencyKey = `smoke:no_change:${runId}`;
183
+ const manifestBody = {
184
+ actionType: 'no_change',
185
+ idempotencyKey,
186
+ workspaceId,
187
+ workspaceSessionId: sessionId,
188
+ workbenchRunId: runId,
189
+ impactNotes: 'Smoke workflow: no primitive change; substrate proof only.',
190
+ rollbackNotes: 'No-op; nothing to roll back.',
191
+ evidence: { workbench_run_ids: [runId], notes: ['highlight-derived smoke run'] },
192
+ currentState: {},
193
+ proposedState: {},
194
+ };
195
+ const manifestPath = `/api/cli/promotion-manifests?project=${encodeURIComponent(projectRef)}`;
196
+ const created = await postJson(fetcher, manifestPath, manifestBody);
197
+ const manifestId = requireId(requireObject(created.body.manifest, 'manifest').id, 'manifest id');
198
+ ids.manifestId = manifestId;
199
+ // Create idempotency: same key returns the same row.
200
+ const recreated = await postJson(fetcher, manifestPath, manifestBody);
201
+ const recreatedId = requireId(requireObject(recreated.body.manifest, 'manifest').id, 'manifest id');
202
+ if (recreatedId !== manifestId) {
203
+ throw new Error('manifest create was not idempotent on the idempotency key');
204
+ }
205
+ const actionPath = `/api/cli/promotion-manifests/${encodeURIComponent(manifestId)}`;
206
+ await postJson(fetcher, actionPath, { action: 'approve' });
207
+ const applied = await postJson(fetcher, actionPath, { action: 'apply' });
208
+ const appliedOutcome = requireObject(applied.body.manifest, 'manifest').outcome;
209
+ const reapplied = await postJson(fetcher, actionPath, { action: 'apply' });
210
+ const reappliedOutcome = requireObject(reapplied.body.manifest, 'manifest').outcome;
211
+ if (JSON.stringify(appliedOutcome) !== JSON.stringify(reappliedOutcome)) {
212
+ throw new Error('manifest re-apply produced a different outcome (not idempotent)');
213
+ }
214
+ record('manifest', 'created', `manifest ${manifestId} no_change applied; re-apply idempotent`);
215
+ // 7 — Complete the run (idempotent) + end the session.
216
+ await runWorkbenchRunTerminal({
217
+ fetcher,
218
+ runId,
219
+ status: 'succeeded',
220
+ summary: 'smoke workflow completed with no promotion change',
221
+ evidence: { promotion_manifest_ids: [manifestId] },
222
+ });
223
+ // Retry the terminal PATCH: still exactly one terminal event server-side.
224
+ await runWorkbenchRunTerminal({ fetcher, runId, status: 'succeeded', summary: 'idempotent retry' });
225
+ const finalTail = await tailOnce(fetcher, runId, 0, 500);
226
+ const terminalCount = finalTail.events.filter(e => e.eventType === 'run_completed').length;
227
+ if (terminalCount !== 1) {
228
+ throw new Error(`expected exactly one run_completed event, saw ${terminalCount}`);
229
+ }
230
+ await runSessionEnd({ fetcher, sessionId });
231
+ record('complete', 'verified', `run completed (single terminal event) and session ${sessionId} ended`);
232
+ // 8 — Re-run accounting: distinguish reused vs created rows.
233
+ record('summary', 'verified', `reused=[${summary.reused.join(', ') || 'none'}] created=[${summary.created.join(', ')}]`);
234
+ return { ok: true, steps, failedStep: null, summary, ids };
235
+ }
236
+ catch (error) {
237
+ const key = STEP_PLAN[steps.length]?.key ?? 'unknown';
238
+ const failedStep = {
239
+ index: indexFor(key),
240
+ key,
241
+ title: titleFor(key),
242
+ ok: false,
243
+ disposition: 'verified',
244
+ detail: error instanceof Error ? error.message : String(error),
245
+ };
246
+ steps.push(failedStep);
247
+ opts.onStep?.(failedStep);
248
+ return { ok: false, steps, failedStep, summary, ids };
249
+ }
250
+ }
@@ -0,0 +1,96 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ export const DEFAULT_ASSIGNMENT_MANIFEST_LIMITS = {
3
+ maxBytes: 5 * 1024 * 1024,
4
+ maxLines: 100_000,
5
+ };
6
+ function isRecord(value) {
7
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
8
+ }
9
+ function formatLimit(value) {
10
+ return value.toLocaleString('en-US');
11
+ }
12
+ function resolveLimits(limits = {}) {
13
+ return {
14
+ maxBytes: limits.maxBytes ?? DEFAULT_ASSIGNMENT_MANIFEST_LIMITS.maxBytes,
15
+ maxLines: limits.maxLines ?? DEFAULT_ASSIGNMENT_MANIFEST_LIMITS.maxLines,
16
+ };
17
+ }
18
+ function assertAssignmentManifestSize(bytes, limits = {}) {
19
+ const resolvedLimits = resolveLimits(limits);
20
+ if (bytes > resolvedLimits.maxBytes) {
21
+ throw new Error(`Assignment manifest file is ${formatLimit(bytes)} bytes; maximum supported size is ${formatLimit(resolvedLimits.maxBytes)} bytes`);
22
+ }
23
+ }
24
+ function assertAssignmentManifestLimits(content, limits = {}) {
25
+ const resolvedLimits = resolveLimits(limits);
26
+ assertAssignmentManifestSize(Buffer.byteLength(content, 'utf8'), resolvedLimits);
27
+ return resolvedLimits;
28
+ }
29
+ export function parseAssignmentManifestJsonl(content, limits) {
30
+ const resolvedLimits = assertAssignmentManifestLimits(content, limits);
31
+ // Keep these JSONL field rules in sync with the server's explicitAssignments parser.
32
+ const entries = [];
33
+ let records = 0;
34
+ const lines = content.split(/\r?\n/);
35
+ lines.forEach((line, lineIndex) => {
36
+ const trimmed = line.trim();
37
+ if (!trimmed) {
38
+ return;
39
+ }
40
+ records += 1;
41
+ if (records > resolvedLimits.maxLines) {
42
+ throw new Error(`Assignment manifest has ${formatLimit(records)} non-empty assignment records; maximum supported record count is ${formatLimit(resolvedLimits.maxLines)}`);
43
+ }
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(trimmed);
47
+ }
48
+ catch (_error) {
49
+ throw new Error(`Assignment manifest line ${lineIndex + 1} is not valid JSON`);
50
+ }
51
+ if (!isRecord(parsed)) {
52
+ throw new Error(`Assignment manifest line ${lineIndex + 1} must be a JSON object`);
53
+ }
54
+ const rowId = typeof parsed.rowId === 'string' ? parsed.rowId.trim() : '';
55
+ if (!rowId) {
56
+ throw new Error(`Assignment manifest line ${lineIndex + 1} must include a non-empty rowId`);
57
+ }
58
+ const hasAssignee = Object.hasOwn(parsed, 'assignee');
59
+ const hasAssignees = Object.hasOwn(parsed, 'assignees');
60
+ if (hasAssignee && hasAssignees) {
61
+ throw new Error(`Assignment manifest line ${lineIndex + 1} must provide either assignee or assignees, not both`);
62
+ }
63
+ if (hasAssignee) {
64
+ const assignee = typeof parsed.assignee === 'string' ? parsed.assignee.trim() : '';
65
+ if (!assignee) {
66
+ throw new Error(`Assignment manifest line ${lineIndex + 1} assignee must be a non-empty string`);
67
+ }
68
+ entries.push({ rowId, assignee });
69
+ return;
70
+ }
71
+ if (hasAssignees) {
72
+ if (!Array.isArray(parsed.assignees) || parsed.assignees.length === 0) {
73
+ throw new Error(`Assignment manifest line ${lineIndex + 1} assignees must be a non-empty string array`);
74
+ }
75
+ parsed.assignees.forEach((assigneeValue, assigneeIndex) => {
76
+ const assignee = typeof assigneeValue === 'string' ? assigneeValue.trim() : '';
77
+ if (!assignee) {
78
+ throw new Error(`Assignment manifest line ${lineIndex + 1} assignees[${assigneeIndex}] must be a non-empty string`);
79
+ }
80
+ entries.push({ rowId, assignee });
81
+ });
82
+ return;
83
+ }
84
+ throw new Error(`Assignment manifest line ${lineIndex + 1} must include assignee or assignees`);
85
+ });
86
+ if (entries.length === 0) {
87
+ throw new Error('Assignment manifest must contain at least one assignment');
88
+ }
89
+ return entries;
90
+ }
91
+ export function readAssignmentManifestJsonlFile(path, limits) {
92
+ const stats = statSync(path);
93
+ assertAssignmentManifestSize(stats.size, limits);
94
+ const content = readFileSync(path, 'utf8');
95
+ return parseAssignmentManifestJsonl(content, limits);
96
+ }