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,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
|
+
}
|
package/src/setup-prompt.ts
CHANGED
|
@@ -4,8 +4,8 @@ export interface AgentSetupPromptOptions {
|
|
|
4
4
|
|
|
5
5
|
export function renderAgentSetupPrompt(options?: AgentSetupPromptOptions): string {
|
|
6
6
|
const workspaceLine = options?.workspacePath
|
|
7
|
-
? `-
|
|
8
|
-
: '- If
|
|
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:
|
package/src/skill-installer.ts
CHANGED
|
@@ -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' ? '
|
|
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
|
+
}
|