orizu 0.4.1 → 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 +271 -16
  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 +855 -237
  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 +39 -2
  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 +272 -16
  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 +987 -296
  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 +70 -2
  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 +3 -1
  76. package/vendor/skills/orizu-cli/references/cli-reference.md +3 -3
@@ -0,0 +1,152 @@
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
+
19
+ export interface TimelineEvent {
20
+ sequence: number
21
+ eventType: string
22
+ payload?: Record<string, unknown>
23
+ }
24
+
25
+ // The lifecycle phases a hosted session passes through, in order. `completion`
26
+ // is the transcript tail (an optional pre-terminal event); the actual terminal
27
+ // transition is a run-row status set via PATCH, NOT an event — so completeness
28
+ // requires the terminal STATUS (see terminalDerivable), while the completion
29
+ // EVENT only corroborates it.
30
+ export const SESSION_PHASES = ['bootstrap', 'ready', 'work', 'completion'] as const
31
+ export type SessionPhase = (typeof SESSION_PHASES)[number]
32
+
33
+ // The phases that MUST be represented by events for a session to reconstruct.
34
+ // `completion` is excluded: the terminal is a PATCH-set run status, not an event.
35
+ export const REQUIRED_EVENT_PHASES: readonly SessionPhase[] = ['bootstrap', 'ready', 'work']
36
+
37
+ // Map each known server-side event type onto its lifecycle phase. Types absent
38
+ // here are still ordered in the timeline but tagged `phase: null` (they do not
39
+ // satisfy a required phase). Kept as a const map (no enums, per house style).
40
+ const PHASE_BY_EVENT_TYPE: Record<string, SessionPhase> = {
41
+ // bootstrap: run start + sandbox/repo/runtime provisioning
42
+ run_started: 'bootstrap',
43
+ sandbox_provisioned: 'bootstrap',
44
+ repo_cloned: 'bootstrap',
45
+ cli_installed: 'bootstrap',
46
+ setup_hook_completed: 'bootstrap',
47
+ setup_hook_skipped: 'bootstrap',
48
+ // P3-a: for enforced-egress providers bootstrap defers the hook to the loop
49
+ // (which runs it after the egress canary); this marks the deferral in-timeline.
50
+ setup_hook_deferred: 'bootstrap',
51
+ // ready: the agent harness is up and holds a live credential
52
+ agent_ready: 'ready',
53
+ agent_token: 'ready',
54
+ // The startup egress canary (G5) fires in the ready window, right after
55
+ // bootstrap and before any agent work — proof the firewall is live.
56
+ egress_blocked: 'ready',
57
+ egress_allowed: 'ready',
58
+ // credential_rotated / credential_mint_failed are RESERVED vocabulary, NOT
59
+ // emitted today (host-side run-stream emission was rejected as a single-writer
60
+ // violation; the durable credential-use audit is the per-mint DB row). They
61
+ // stay mapped so a FUTURE single-writer emitter classifies correctly, but the
62
+ // `ready` phase is satisfied independently by agent_ready/agent_token — replay
63
+ // completeness never depends on a credential event.
64
+ credential_rotated: 'ready',
65
+ credential_mint_failed: 'ready',
66
+ // work: the agent acts (tool calls/results, steps, repo writes, artifacts)
67
+ agent_tool_call: 'work',
68
+ agent_tool_result: 'work',
69
+ agent_step_start: 'work',
70
+ agent_step_finish: 'work',
71
+ repo_sync: 'work',
72
+ artifact: 'work',
73
+ session_title: 'work',
74
+ // completion: the bounded transcript tail recorded just before the terminal PATCH
75
+ agent_transcript: 'completion',
76
+ }
77
+
78
+ const TERMINAL_STATUSES: ReadonlySet<string> = new Set(['succeeded', 'failed', 'cancelled'])
79
+
80
+ export interface TimelineEntry {
81
+ sequence: number
82
+ eventType: string
83
+ phase: SessionPhase | null
84
+ }
85
+
86
+ export interface ReconstructedTimeline {
87
+ /** Every event, ordered by sequence, each tagged with its lifecycle phase. */
88
+ ordered: TimelineEntry[]
89
+ /** Which lifecycle phases are represented. */
90
+ phasesPresent: Record<SessionPhase, boolean>
91
+ /** Phases with no representative event (empty when complete). */
92
+ missingPhases: SessionPhase[]
93
+ /** Enumerated sequence gaps as [afterSequence, nextSequence] pairs. */
94
+ sequenceGaps: Array<[number, number]>
95
+ /** True when sequences are strictly contiguous from the first observed. */
96
+ gapless: boolean
97
+ /** The server run's terminal status, when one was supplied and is terminal. */
98
+ terminalStatus: string | null
99
+ /** A terminal status is derivable (the server run status is terminal). */
100
+ terminalDerivable: boolean
101
+ /** All REQUIRED event phases present AND a terminal is derivable. Gaps do NOT
102
+ * fail this — they are enumerated in `sequenceGaps` (tolerated-but-documented
103
+ * per the sink). */
104
+ complete: boolean
105
+ }
106
+
107
+ export function reconstructSessionTimeline(
108
+ events: readonly TimelineEvent[],
109
+ opts: { runStatus?: string | null } = {}
110
+ ): ReconstructedTimeline {
111
+ const ordered: TimelineEntry[] = [...events]
112
+ .sort((a, b) => a.sequence - b.sequence)
113
+ .map(event => ({
114
+ sequence: event.sequence,
115
+ eventType: event.eventType,
116
+ phase: PHASE_BY_EVENT_TYPE[event.eventType] ?? null,
117
+ }))
118
+
119
+ const phasesPresent = Object.fromEntries(
120
+ SESSION_PHASES.map(phase => [phase, ordered.some(entry => entry.phase === phase)])
121
+ ) as Record<SessionPhase, boolean>
122
+ const missingPhases = REQUIRED_EVENT_PHASES.filter(phase => !phasesPresent[phase])
123
+
124
+ const sequenceGaps: Array<[number, number]> = []
125
+ for (let i = 1; i < ordered.length; i += 1) {
126
+ const previous = ordered[i - 1].sequence
127
+ const current = ordered[i].sequence
128
+ if (current > previous + 1) {
129
+ sequenceGaps.push([previous, current])
130
+ }
131
+ }
132
+ const gapless = sequenceGaps.length === 0
133
+
134
+ const runStatus = opts.runStatus ?? null
135
+ const terminalStatus = runStatus && TERMINAL_STATUSES.has(runStatus) ? runStatus : null
136
+ // The terminal is the run-row status (PATCH-set, server-side). The completion
137
+ // event (`agent_transcript`), when present, corroborates it but is not required.
138
+ const terminalDerivable = terminalStatus !== null
139
+
140
+ const complete = missingPhases.length === 0 && terminalDerivable
141
+
142
+ return {
143
+ ordered,
144
+ phasesPresent,
145
+ missingPhases,
146
+ sequenceGaps,
147
+ gapless,
148
+ terminalStatus,
149
+ terminalDerivable,
150
+ complete,
151
+ }
152
+ }
@@ -4,8 +4,8 @@ export interface AgentSetupPromptOptions {
4
4
 
5
5
  export function renderAgentSetupPrompt(options?: AgentSetupPromptOptions): string {
6
6
  const workspaceLine = options?.workspacePath
7
- ? `- Local Orizu artifacts belong under ${options.workspacePath} (gitignored). Keep secrets out of the repo; credentials live in ~/.config/orizu.`
8
- : '- If local Orizu artifacts are needed, suggest creating a gitignored .orizu/ workspace (orizu setup can do this).'
7
+ ? `- The Orizu workbench root is ${options.workspacePath}. Read AGENTS.md, Memory.md, project READMEs, and orizu.*.json manifests before changing anything.`
8
+ : '- If this repo is not initialized yet, suggest running `orizu setup --team <slug>` from the desired directory to create the local Orizu workbench contract.'
9
9
 
10
10
  return `Help me embed Orizu (eval-first LLM optimization) into this repository's workflow.
11
11
 
@@ -19,6 +19,8 @@ Then inspect this repository and propose how Orizu should fit it:
19
19
  - Propose datasets: existing data that can be uploaded now, or a concrete plan to collect examples if none exists.
20
20
  - Propose which prompts to version in Orizu, and which scorers or LLM judges would capture quality for them.
21
21
  - Propose runner artifacts where local execution is needed.
22
+ - 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.
23
+ - Keep Git-tracked source/context separate from Orizu DB state, object-storage bytes, and local/ephemeral files. Treat .orizu/ as ignored cache/generated state.
22
24
  ${workspaceLine}
23
25
 
24
26
  Important boundaries:
@@ -15,12 +15,14 @@ import { dirname, isAbsolute, join, relative, resolve } from 'path'
15
15
  import { fileURLToPath } from 'url'
16
16
  import { homedir } from 'os'
17
17
  import { createHash } from 'crypto'
18
+ import { workspaceExists } from './workspace.js'
18
19
 
19
20
  export const SKILL_NAME = 'orizu-cli'
20
21
  export const AGENTS_START_MARKER = '<!-- orizu-cli:start -->'
21
22
  export const AGENTS_END_MARKER = '<!-- orizu-cli:end -->'
22
23
 
23
24
  export const SKILL_INSTALL_TARGETS = [
25
+ 'codex-user',
24
26
  'agent-user',
25
27
  'agents-project',
26
28
  'codex-project',
@@ -192,6 +194,10 @@ export function getSkillInstallPath(
192
194
  const home = resolveHome(options)
193
195
  const cwd = resolveCwd(options)
194
196
 
197
+ if (target === 'codex-user') {
198
+ return resolve(home, '.codex', 'skills', SKILL_NAME)
199
+ }
200
+
195
201
  if (target === 'agent-user') {
196
202
  return resolve(home, '.agents', 'skills', SKILL_NAME)
197
203
  }
@@ -222,7 +228,7 @@ export function getTargetForAgent(
222
228
  if (agent === 'claude') {
223
229
  return scope === 'global' ? 'claude-user' : 'claude-project'
224
230
  }
225
- return scope === 'global' ? 'agent-user' : 'agents-project'
231
+ return scope === 'global' ? 'codex-user' : 'agents-project'
226
232
  }
227
233
 
228
234
  export function isSkillInstallAgent(value: string): value is SkillInstallAgent {
@@ -325,6 +331,12 @@ function installAgentsMd(
325
331
  targetPath: string,
326
332
  options?: SkillInstallOptions
327
333
  ): SkillInstallResult {
334
+ if (workspaceExists(dirname(targetPath))) {
335
+ throw new Error(
336
+ `${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.`
337
+ )
338
+ }
339
+
328
340
  const section = buildAgentsSection(sourceDir)
329
341
  const exists = existsSync(targetPath)
330
342
  const existing = exists ? readFileSync(targetPath, 'utf8') : ''
package/src/smoke.ts ADDED
@@ -0,0 +1,342 @@
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
+
21
+ import {
22
+ runSessionEnd,
23
+ runSessionStart,
24
+ runWorkbenchRunStart,
25
+ runWorkbenchRunStatus,
26
+ runWorkbenchRunTerminal,
27
+ tailOnce,
28
+ } from './workbench-cli.js'
29
+
30
+ export interface SmokeFetcher {
31
+ (path: string, init?: RequestInit): Promise<Response>
32
+ }
33
+
34
+ export interface SmokeConfig {
35
+ teamSlug: string
36
+ projectSlug: string
37
+ workspaceSlug: string
38
+ /** Optional real score-run id to reference from the eval_scored event. */
39
+ scoreRunId?: string | null
40
+ }
41
+
42
+ export type SmokeDisposition = 'created' | 'reused' | 'verified'
43
+
44
+ export interface SmokeStepReport {
45
+ index: number
46
+ key: string
47
+ title: string
48
+ ok: boolean
49
+ disposition: SmokeDisposition
50
+ detail: string
51
+ }
52
+
53
+ export interface SmokeStepPlan {
54
+ index: number
55
+ key: string
56
+ title: string
57
+ plan: string
58
+ }
59
+
60
+ export interface SmokeSummary {
61
+ created: string[]
62
+ reused: string[]
63
+ verified: string[]
64
+ }
65
+
66
+ export interface SmokeReport {
67
+ ok: boolean
68
+ steps: SmokeStepReport[]
69
+ failedStep: SmokeStepReport | null
70
+ summary: SmokeSummary
71
+ ids: {
72
+ workspaceId?: string
73
+ sessionId?: string
74
+ runId?: string
75
+ manifestId?: string
76
+ }
77
+ }
78
+
79
+ export interface RunSmokeOptions {
80
+ config: SmokeConfig
81
+ /** Returns a fresh, stateless HTTP client. Called for the primary flow and
82
+ * again to simulate a terminal kill/reconnect. */
83
+ makeFetcher: () => SmokeFetcher
84
+ now?: () => string
85
+ onStep?: (step: SmokeStepReport) => void
86
+ }
87
+
88
+ export const SMOKE_RUN_TITLE = 'smoke: highlight-derived workflow'
89
+
90
+ // Deterministic, HIP/highlight-shaped events appended after run start (seq 1 is
91
+ // the server-emitted run_started). Stable event_ids make the append idempotent.
92
+ export const SMOKE_EVENTS = [
93
+ {
94
+ eventId: 'smoke-evt-1',
95
+ eventType: 'highlight_ingested',
96
+ sequence: 2,
97
+ payload: { highlightId: 'hl-smoke-001', source: 'hip', span: { start: 0, end: 42 } },
98
+ },
99
+ {
100
+ eventId: 'smoke-evt-2',
101
+ eventType: 'eval_scored',
102
+ sequence: 3,
103
+ payload: { metricKey: 'faithfulness', score: 0.87, comparable: true },
104
+ },
105
+ {
106
+ eventId: 'smoke-evt-3',
107
+ eventType: 'notes_recorded',
108
+ sequence: 4,
109
+ payload: { note: 'Highlight-derived smoke: no regression detected.' },
110
+ },
111
+ ] as const
112
+
113
+ const STEP_PLAN: ReadonlyArray<{ key: string; title: string; plan: string }> = [
114
+ { key: 'workspace', title: 'Workspace resolve-or-attach', plan: 'POST /api/cli/workspaces (idempotent on team+slug)' },
115
+ { key: 'session', title: 'Session start', plan: 'POST /api/cli/workspaces/{id}/sessions' },
116
+ { key: 'run', title: 'Workbench run start', plan: 'POST /api/cli/workbench-runs + verify seq-1 run_started via tail' },
117
+ { key: 'events', title: 'Append highlight-derived events', plan: 'POST /workbench-runs/{id}/events x3 (smoke-evt-1..3)' },
118
+ { key: 'reconnect', title: 'Terminal-death reconnect', plan: 'fresh client re-fetches run + tails from cursor 0' },
119
+ { key: 'manifest', title: 'Promotion manifest no_change', plan: 'create (idempotent) -> approve -> apply -> re-apply' },
120
+ { key: 'complete', title: 'Complete run + end session', plan: 'PATCH succeeded (idempotent) + PATCH session ended' },
121
+ { key: 'summary', title: 'Re-run accounting', plan: 'classify reused vs created rows' },
122
+ ]
123
+
124
+ export function planSmokeSteps(_config: SmokeConfig): SmokeStepPlan[] {
125
+ return STEP_PLAN.map((step, i) => ({ index: i + 1, key: step.key, title: step.title, plan: step.plan }))
126
+ }
127
+
128
+ function titleFor(key: string): string {
129
+ return STEP_PLAN.find(step => step.key === key)?.title ?? key
130
+ }
131
+
132
+ function indexFor(key: string): number {
133
+ return STEP_PLAN.findIndex(step => step.key === key) + 1
134
+ }
135
+
136
+ async function readBody(response: Response): Promise<Record<string, unknown>> {
137
+ try {
138
+ return (await response.json()) as Record<string, unknown>
139
+ } catch {
140
+ return {}
141
+ }
142
+ }
143
+
144
+ async function errorMessage(response: Response): Promise<string> {
145
+ const body = await readBody(response.clone())
146
+ const error = typeof body.error === 'string' ? body.error : null
147
+ return error || response.statusText || String(response.status)
148
+ }
149
+
150
+ async function postJson(
151
+ fetcher: SmokeFetcher,
152
+ path: string,
153
+ payload: Record<string, unknown>
154
+ ): Promise<{ status: number; body: Record<string, unknown> }> {
155
+ const response = await fetcher(path, {
156
+ method: 'POST',
157
+ headers: { 'Content-Type': 'application/json' },
158
+ body: JSON.stringify(payload),
159
+ })
160
+ if (!response.ok) {
161
+ throw new Error(`${path} failed (${response.status}): ${await errorMessage(response)}`)
162
+ }
163
+ return { status: response.status, body: await readBody(response) }
164
+ }
165
+
166
+ function requireObject(value: unknown, label: string): Record<string, unknown> {
167
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
168
+ throw new Error(`${label} missing from response`)
169
+ }
170
+ return value as Record<string, unknown>
171
+ }
172
+
173
+ function requireId(value: unknown, label: string): string {
174
+ if (typeof value !== 'string' || value.length === 0) {
175
+ throw new Error(`${label} missing from response`)
176
+ }
177
+ return value
178
+ }
179
+
180
+ export async function runSmokeWorkflow(opts: RunSmokeOptions): Promise<SmokeReport> {
181
+ const { config } = opts
182
+ const fetcher = opts.makeFetcher()
183
+ const steps: SmokeStepReport[] = []
184
+ const summary: SmokeSummary = { created: [], reused: [], verified: [] }
185
+ const ids: SmokeReport['ids'] = {}
186
+
187
+ const record = (key: string, disposition: SmokeDisposition, detail: string): SmokeStepReport => {
188
+ const step: SmokeStepReport = { index: indexFor(key), key, title: titleFor(key), ok: true, disposition, detail }
189
+ steps.push(step)
190
+ if (disposition === 'created') summary.created.push(key)
191
+ if (disposition === 'reused') summary.reused.push(key)
192
+ if (disposition === 'verified') summary.verified.push(key)
193
+ opts.onStep?.(step)
194
+ return step
195
+ }
196
+
197
+ try {
198
+ // 1 — Workspace resolve-or-attach (201 created / 200 reused).
199
+ const workspace = await postJson(fetcher, '/api/cli/workspaces', {
200
+ teamSlug: config.teamSlug,
201
+ name: config.workspaceSlug,
202
+ slug: config.workspaceSlug,
203
+ })
204
+ const workspaceId = requireId(requireObject(workspace.body.workspace, 'workspace').id, 'workspace id')
205
+ ids.workspaceId = workspaceId
206
+ const workspaceDisposition: SmokeDisposition = workspace.status === 201 ? 'created' : 'reused'
207
+ record('workspace', workspaceDisposition, `${workspaceDisposition} workspace ${workspaceId} (slug=${config.workspaceSlug})`)
208
+
209
+ // 2 — Session start (always a new row).
210
+ const session = await runSessionStart({
211
+ fetcher,
212
+ workspaceId,
213
+ projectSlug: config.projectSlug,
214
+ clientInfo: { source: 'orizu-smoke' },
215
+ })
216
+ const sessionId = requireId(session.session.id, 'session id')
217
+ ids.sessionId = sessionId
218
+ record('session', 'created', `created session ${sessionId}`)
219
+
220
+ // 3 — Workbench run start + verify the server-emitted seq-1 run_started.
221
+ const runStart = await runWorkbenchRunStart({ fetcher, sessionId, title: SMOKE_RUN_TITLE, projectSlug: config.projectSlug })
222
+ const runId = requireId(runStart.run.id, 'run id')
223
+ ids.runId = runId
224
+ const opening = await tailOnce(fetcher, runId, 0)
225
+ const started = opening.events.find(event => event.sequence === 1)
226
+ if (!started || started.eventType !== 'run_started') {
227
+ throw new Error('seq-1 run_started event was not present on tail')
228
+ }
229
+ record('run', 'created', `created run ${runId}; verified seq-1 run_started`)
230
+
231
+ // 4 — Append the three highlight-derived events (deterministic ids).
232
+ for (const event of SMOKE_EVENTS) {
233
+ const payload: Record<string, unknown> = { ...event.payload }
234
+ if (event.eventType === 'eval_scored' && config.scoreRunId) {
235
+ payload.scoreRunId = config.scoreRunId
236
+ }
237
+ const appended = await postJson(fetcher, `/api/cli/workbench-runs/${encodeURIComponent(runId)}/events`, {
238
+ eventId: event.eventId,
239
+ eventType: event.eventType,
240
+ sequence: event.sequence,
241
+ payload,
242
+ })
243
+ if (appended.status !== 201 && appended.status !== 200) {
244
+ throw new Error(`unexpected append status ${appended.status} for ${event.eventId}`)
245
+ }
246
+ }
247
+ record('events', 'created', `appended ${SMOKE_EVENTS.length} events (${SMOKE_EVENTS.map(e => e.eventId).join(', ')})`)
248
+
249
+ // 5 — Simulated terminal death: a fresh, stateless client re-reads the run.
250
+ const reconnectFetcher = opts.makeFetcher()
251
+ const reread = await runWorkbenchRunStatus({ fetcher: reconnectFetcher, runId })
252
+ if (reread.run.id !== runId) {
253
+ throw new Error('reconnected client resolved a different run id')
254
+ }
255
+ const replay = await tailOnce(reconnectFetcher, runId, 0)
256
+ const expectedTypes = ['run_started', ...SMOKE_EVENTS.map(e => e.eventType)]
257
+ const seenTypes = replay.events.filter(e => e.sequence <= expectedTypes.length).map(e => e.eventType)
258
+ for (const expected of expectedTypes) {
259
+ if (!seenTypes.includes(expected)) {
260
+ throw new Error(`reconnect did not see ${expected}; state was lost`)
261
+ }
262
+ }
263
+ record('reconnect', 'verified', `fresh client re-read ${expectedTypes.length} events after kill (${expectedTypes.join(', ')})`)
264
+
265
+ // 6 — Promotion manifest: create (idempotent), approve, apply, re-apply.
266
+ const projectRef = `${config.teamSlug}/${config.projectSlug}`
267
+ const idempotencyKey = `smoke:no_change:${runId}`
268
+ const manifestBody = {
269
+ actionType: 'no_change',
270
+ idempotencyKey,
271
+ workspaceId,
272
+ workspaceSessionId: sessionId,
273
+ workbenchRunId: runId,
274
+ impactNotes: 'Smoke workflow: no primitive change; substrate proof only.',
275
+ rollbackNotes: 'No-op; nothing to roll back.',
276
+ evidence: { workbench_run_ids: [runId], notes: ['highlight-derived smoke run'] },
277
+ currentState: {},
278
+ proposedState: {},
279
+ }
280
+ const manifestPath = `/api/cli/promotion-manifests?project=${encodeURIComponent(projectRef)}`
281
+ const created = await postJson(fetcher, manifestPath, manifestBody)
282
+ const manifestId = requireId(requireObject(created.body.manifest, 'manifest').id, 'manifest id')
283
+ ids.manifestId = manifestId
284
+ // Create idempotency: same key returns the same row.
285
+ const recreated = await postJson(fetcher, manifestPath, manifestBody)
286
+ const recreatedId = requireId(requireObject(recreated.body.manifest, 'manifest').id, 'manifest id')
287
+ if (recreatedId !== manifestId) {
288
+ throw new Error('manifest create was not idempotent on the idempotency key')
289
+ }
290
+
291
+ const actionPath = `/api/cli/promotion-manifests/${encodeURIComponent(manifestId)}`
292
+ await postJson(fetcher, actionPath, { action: 'approve' })
293
+ const applied = await postJson(fetcher, actionPath, { action: 'apply' })
294
+ const appliedOutcome = requireObject(applied.body.manifest, 'manifest').outcome
295
+ const reapplied = await postJson(fetcher, actionPath, { action: 'apply' })
296
+ const reappliedOutcome = requireObject(reapplied.body.manifest, 'manifest').outcome
297
+ if (JSON.stringify(appliedOutcome) !== JSON.stringify(reappliedOutcome)) {
298
+ throw new Error('manifest re-apply produced a different outcome (not idempotent)')
299
+ }
300
+ record('manifest', 'created', `manifest ${manifestId} no_change applied; re-apply idempotent`)
301
+
302
+ // 7 — Complete the run (idempotent) + end the session.
303
+ await runWorkbenchRunTerminal({
304
+ fetcher,
305
+ runId,
306
+ status: 'succeeded',
307
+ summary: 'smoke workflow completed with no promotion change',
308
+ evidence: { promotion_manifest_ids: [manifestId] },
309
+ })
310
+ // Retry the terminal PATCH: still exactly one terminal event server-side.
311
+ await runWorkbenchRunTerminal({ fetcher, runId, status: 'succeeded', summary: 'idempotent retry' })
312
+ const finalTail = await tailOnce(fetcher, runId, 0, 500)
313
+ const terminalCount = finalTail.events.filter(e => e.eventType === 'run_completed').length
314
+ if (terminalCount !== 1) {
315
+ throw new Error(`expected exactly one run_completed event, saw ${terminalCount}`)
316
+ }
317
+ await runSessionEnd({ fetcher, sessionId })
318
+ record('complete', 'verified', `run completed (single terminal event) and session ${sessionId} ended`)
319
+
320
+ // 8 — Re-run accounting: distinguish reused vs created rows.
321
+ record(
322
+ 'summary',
323
+ 'verified',
324
+ `reused=[${summary.reused.join(', ') || 'none'}] created=[${summary.created.join(', ')}]`
325
+ )
326
+
327
+ return { ok: true, steps, failedStep: null, summary, ids }
328
+ } catch (error) {
329
+ const key = STEP_PLAN[steps.length]?.key ?? 'unknown'
330
+ const failedStep: SmokeStepReport = {
331
+ index: indexFor(key),
332
+ key,
333
+ title: titleFor(key),
334
+ ok: false,
335
+ disposition: 'verified',
336
+ detail: error instanceof Error ? error.message : String(error),
337
+ }
338
+ steps.push(failedStep)
339
+ opts.onStep?.(failedStep)
340
+ return { ok: false, steps, failedStep, summary, ids }
341
+ }
342
+ }
@@ -1,17 +1,67 @@
1
+ import { readFileSync, statSync } from 'node:fs'
2
+
1
3
  export interface ParsedAssignmentManifestEntry {
2
4
  rowId: string
3
5
  assignee: string
4
6
  }
5
7
 
8
+ export interface AssignmentManifestLimits {
9
+ maxBytes?: number
10
+ maxLines?: number
11
+ }
12
+
13
+ export const DEFAULT_ASSIGNMENT_MANIFEST_LIMITS = {
14
+ maxBytes: 5 * 1024 * 1024,
15
+ maxLines: 100_000,
16
+ } as const
17
+
6
18
  function isRecord(value: unknown): value is Record<string, unknown> {
7
19
  return typeof value === 'object' && value !== null && !Array.isArray(value)
8
20
  }
9
21
 
22
+ function formatLimit(value: number): string {
23
+ return value.toLocaleString('en-US')
24
+ }
25
+
26
+ function resolveLimits(
27
+ limits: AssignmentManifestLimits = {}
28
+ ): Required<AssignmentManifestLimits> {
29
+ return {
30
+ maxBytes: limits.maxBytes ?? DEFAULT_ASSIGNMENT_MANIFEST_LIMITS.maxBytes,
31
+ maxLines: limits.maxLines ?? DEFAULT_ASSIGNMENT_MANIFEST_LIMITS.maxLines,
32
+ }
33
+ }
34
+
35
+ function assertAssignmentManifestSize(
36
+ bytes: number,
37
+ limits: AssignmentManifestLimits = {}
38
+ ) {
39
+ const resolvedLimits = resolveLimits(limits)
40
+ if (bytes > resolvedLimits.maxBytes) {
41
+ throw new Error(
42
+ `Assignment manifest file is ${formatLimit(bytes)} bytes; maximum supported size is ${formatLimit(resolvedLimits.maxBytes)} bytes`
43
+ )
44
+ }
45
+ }
46
+
47
+ function assertAssignmentManifestLimits(
48
+ content: string,
49
+ limits: AssignmentManifestLimits = {}
50
+ ) {
51
+ const resolvedLimits = resolveLimits(limits)
52
+ assertAssignmentManifestSize(Buffer.byteLength(content, 'utf8'), resolvedLimits)
53
+ return resolvedLimits
54
+ }
55
+
10
56
  export function parseAssignmentManifestJsonl(
11
- content: string
57
+ content: string,
58
+ limits?: AssignmentManifestLimits
12
59
  ): ParsedAssignmentManifestEntry[] {
60
+ const resolvedLimits = assertAssignmentManifestLimits(content, limits)
61
+
13
62
  // Keep these JSONL field rules in sync with the server's explicitAssignments parser.
14
63
  const entries: ParsedAssignmentManifestEntry[] = []
64
+ let records = 0
15
65
  const lines = content.split(/\r?\n/)
16
66
 
17
67
  lines.forEach((line, lineIndex) => {
@@ -20,10 +70,17 @@ export function parseAssignmentManifestJsonl(
20
70
  return
21
71
  }
22
72
 
73
+ records += 1
74
+ if (records > resolvedLimits.maxLines) {
75
+ throw new Error(
76
+ `Assignment manifest has ${formatLimit(records)} non-empty assignment records; maximum supported record count is ${formatLimit(resolvedLimits.maxLines)}`
77
+ )
78
+ }
79
+
23
80
  let parsed: unknown
24
81
  try {
25
82
  parsed = JSON.parse(trimmed)
26
- } catch (error) {
83
+ } catch (_error) {
27
84
  throw new Error(
28
85
  `Assignment manifest line ${lineIndex + 1} is not valid JSON`
29
86
  )
@@ -94,3 +151,14 @@ export function parseAssignmentManifestJsonl(
94
151
 
95
152
  return entries
96
153
  }
154
+
155
+ export function readAssignmentManifestJsonlFile(
156
+ path: string,
157
+ limits?: AssignmentManifestLimits
158
+ ): ParsedAssignmentManifestEntry[] {
159
+ const stats = statSync(path)
160
+ assertAssignmentManifestSize(stats.size, limits)
161
+
162
+ const content = readFileSync(path, 'utf8')
163
+ return parseAssignmentManifestJsonl(content, limits)
164
+ }