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,701 @@
1
+ /**
2
+ * Workspace session/run visibility commands (ALI-902 CLI half).
3
+ *
4
+ * Pure logic + injected HTTP: command functions do not parse process.argv and
5
+ * do not print. The thin `workbenchCommand` entry point owns CLI argument
6
+ * parsing and human/JSON output.
7
+ */
8
+
9
+ import { authedFetch } from './http.js'
10
+ import { getWorkspaceRoot } from './workspace.js'
11
+ import { attachedWorkspaceId, stringOrNull } from './workspace-sync.js'
12
+
13
+ export interface WorkbenchFetcher {
14
+ (path: string, init?: RequestInit): Promise<Response>
15
+ }
16
+
17
+ export interface WorkbenchSleep {
18
+ (ms: number): Promise<void>
19
+ }
20
+
21
+ export interface WorkbenchRunSummary {
22
+ id: string
23
+ status: string
24
+ }
25
+
26
+ export interface WorkbenchSession {
27
+ id: string
28
+ workspaceId?: string
29
+ projectId?: string | null
30
+ actorUserId?: string
31
+ actorType?: string
32
+ status: string
33
+ clientInfo?: Record<string, unknown>
34
+ startedAt?: string | null
35
+ endedAt?: string | null
36
+ createdAt?: string
37
+ updatedAt?: string
38
+ runs?: WorkbenchRunSummary[]
39
+ [key: string]: unknown
40
+ }
41
+
42
+ export interface WorkbenchRun {
43
+ id: string
44
+ workspaceSessionId?: string
45
+ workspaceId?: string
46
+ projectId?: string | null
47
+ actorUserId?: string
48
+ actorType?: string
49
+ title?: string
50
+ status: string
51
+ evidence?: Record<string, unknown>
52
+ summary?: Record<string, unknown>
53
+ latestSequence?: number
54
+ startedAt?: string | null
55
+ finishedAt?: string | null
56
+ createdAt?: string
57
+ updatedAt?: string
58
+ [key: string]: unknown
59
+ }
60
+
61
+ export interface WorkbenchRunEvent {
62
+ sequence: number
63
+ eventId: string
64
+ eventType: string
65
+ occurredAt?: string | null
66
+ payload?: Record<string, unknown>
67
+ [key: string]: unknown
68
+ }
69
+
70
+ export interface CommonWorkbenchOptions {
71
+ fetcher?: WorkbenchFetcher
72
+ cwd?: string
73
+ }
74
+
75
+ export interface SessionStartOptions extends CommonWorkbenchOptions {
76
+ workspaceId?: string | null
77
+ projectSlug?: string | null
78
+ clientInfo?: Record<string, unknown>
79
+ }
80
+
81
+ export interface SessionStatusOptions extends CommonWorkbenchOptions {
82
+ workspaceId?: string | null
83
+ sessionId?: string | null
84
+ status?: string | null
85
+ }
86
+
87
+ export interface SessionEndOptions {
88
+ fetcher?: WorkbenchFetcher
89
+ sessionId: string
90
+ }
91
+
92
+ export interface SessionFinishOptions {
93
+ fetcher?: WorkbenchFetcher
94
+ sessionId: string
95
+ projectSlug?: string | null
96
+ }
97
+
98
+ export interface SessionFinishResult {
99
+ outcome: string
100
+ branch?: string
101
+ branchDeleted?: boolean
102
+ manifest?: Record<string, unknown>
103
+ }
104
+
105
+ export interface SessionStartResult {
106
+ session: WorkbenchSession
107
+ }
108
+
109
+ export interface SessionStatusResult {
110
+ session?: WorkbenchSession
111
+ sessions?: WorkbenchSession[]
112
+ }
113
+
114
+ export interface SessionEndResult {
115
+ session: WorkbenchSession
116
+ branchStatus?: string
117
+ }
118
+
119
+ export interface WorkbenchRunStartOptions {
120
+ fetcher?: WorkbenchFetcher
121
+ sessionId: string
122
+ title: string
123
+ projectSlug?: string | null
124
+ }
125
+
126
+ export interface WorkbenchRunStatusOptions {
127
+ fetcher?: WorkbenchFetcher
128
+ runId: string
129
+ }
130
+
131
+ export interface WorkbenchRunTerminalOptions {
132
+ fetcher?: WorkbenchFetcher
133
+ runId: string
134
+ status: string
135
+ summary?: string | null
136
+ evidence?: Record<string, unknown> | null
137
+ }
138
+
139
+ export interface WorkbenchRunResult {
140
+ run: WorkbenchRun
141
+ }
142
+
143
+ export interface TailOnceResult {
144
+ events: WorkbenchRunEvent[]
145
+ cursor: number
146
+ runStatus: string
147
+ }
148
+
149
+ export interface TailWorkbenchRunOptions {
150
+ fetcher?: WorkbenchFetcher
151
+ runId: string
152
+ after?: number
153
+ limit?: number
154
+ intervalSeconds?: number
155
+ once?: boolean
156
+ sleep?: WorkbenchSleep
157
+ onEvent?: (event: WorkbenchRunEvent) => void
158
+ }
159
+
160
+ export interface WorkbenchCommandIo {
161
+ json: boolean
162
+ print: (line: string) => void
163
+ fetcher?: WorkbenchFetcher
164
+ sleep?: WorkbenchSleep
165
+ }
166
+
167
+ const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'cancelled'])
168
+ const SESSION_STATUSES = new Set(['active', 'ended'])
169
+ const DEFAULT_TAIL_LIMIT = 100
170
+ const MAX_TAIL_LIMIT = 500
171
+ const DEFAULT_TAIL_INTERVAL_SECONDS = 2
172
+ // Floor for the continuous poll sleep so `--interval 0` (or negatives) cannot
173
+ // hot-spin against the API while a non-terminal run is quiet. Draining full
174
+ // pages intentionally skips the sleep — that is catch-up, not polling.
175
+ const MIN_TAIL_INTERVAL_MS = 500
176
+
177
+ function defaultSleep(ms: number): Promise<void> {
178
+ return new Promise(resolve => setTimeout(resolve, ms))
179
+ }
180
+
181
+ function numberOrFallback(value: unknown, fallback: number): number {
182
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
183
+ }
184
+
185
+ function normalizeTailLimit(limit: number | undefined): number {
186
+ return typeof limit === 'number' && Number.isFinite(limit) && limit > 0
187
+ ? Math.min(Math.floor(limit), MAX_TAIL_LIMIT)
188
+ : DEFAULT_TAIL_LIMIT
189
+ }
190
+
191
+ function normalizeProjectSlug(value: string | null | undefined): string | null {
192
+ const raw = stringOrNull(value)
193
+ if (!raw) {
194
+ return null
195
+ }
196
+ const segments = raw.split('/').map(segment => segment.trim()).filter(Boolean)
197
+ return segments.length > 0 ? segments[segments.length - 1] : null
198
+ }
199
+
200
+ function resolveAttachedWorkspaceId(opts: SessionStartOptions | SessionStatusOptions): string {
201
+ const explicit = stringOrNull(opts.workspaceId ?? undefined)
202
+ if (explicit) {
203
+ return explicit
204
+ }
205
+ const root = getWorkspaceRoot(opts.cwd)
206
+ const attached = attachedWorkspaceId(root)
207
+ if (!attached) {
208
+ throw new Error('This workspace is not attached yet; run `orizu workspace sync` first to attach it.')
209
+ }
210
+ return attached
211
+ }
212
+
213
+ async function readJson(response: Response): Promise<Record<string, unknown>> {
214
+ try {
215
+ return await response.json() as Record<string, unknown>
216
+ } catch {
217
+ return {}
218
+ }
219
+ }
220
+
221
+ async function responseMessage(response: Response): Promise<string> {
222
+ try {
223
+ const data = await response.clone().json() as Record<string, unknown>
224
+ return stringOrNull(data.error) || stringOrNull(data.message) || response.statusText || String(response.status)
225
+ } catch {
226
+ try {
227
+ const text = await response.text()
228
+ return text.trim() || response.statusText || String(response.status)
229
+ } catch {
230
+ return response.statusText || String(response.status)
231
+ }
232
+ }
233
+ }
234
+
235
+ async function assertOk(response: Response, action: string): Promise<void> {
236
+ if (response.ok) {
237
+ return
238
+ }
239
+ throw new Error(`${action} failed (${response.status}): ${await responseMessage(response)}`)
240
+ }
241
+
242
+ function fetcherFrom(opts: { fetcher?: WorkbenchFetcher }): WorkbenchFetcher {
243
+ return opts.fetcher ?? authedFetch
244
+ }
245
+
246
+ function requiredBodyObject(data: Record<string, unknown>, key: string, action: string): Record<string, unknown> {
247
+ const value = data[key]
248
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
249
+ throw new Error(`${action} response did not include ${key}`)
250
+ }
251
+ return value as Record<string, unknown>
252
+ }
253
+
254
+ function sessionFrom(data: Record<string, unknown>, action: string): WorkbenchSession {
255
+ return requiredBodyObject(data, 'session', action) as WorkbenchSession
256
+ }
257
+
258
+ function runFrom(data: Record<string, unknown>, action: string): WorkbenchRun {
259
+ return requiredBodyObject(data, 'run', action) as WorkbenchRun
260
+ }
261
+
262
+ function sessionsFrom(data: Record<string, unknown>): WorkbenchSession[] {
263
+ return Array.isArray(data.sessions) ? data.sessions as WorkbenchSession[] : []
264
+ }
265
+
266
+ function eventsFrom(data: Record<string, unknown>): WorkbenchRunEvent[] {
267
+ return Array.isArray(data.events) ? data.events as WorkbenchRunEvent[] : []
268
+ }
269
+
270
+ export async function runSessionStart(opts: SessionStartOptions = {}): Promise<SessionStartResult> {
271
+ const fetcher = fetcherFrom(opts)
272
+ const workspaceId = resolveAttachedWorkspaceId(opts)
273
+ const projectSlug = normalizeProjectSlug(opts.projectSlug)
274
+ const body: Record<string, unknown> = {}
275
+ if (projectSlug) {
276
+ body.projectSlug = projectSlug
277
+ }
278
+ if (opts.clientInfo) {
279
+ body.clientInfo = opts.clientInfo
280
+ }
281
+
282
+ const response = await fetcher(`/api/cli/workspaces/${encodeURIComponent(workspaceId)}/sessions`, {
283
+ method: 'POST',
284
+ headers: { 'Content-Type': 'application/json' },
285
+ body: JSON.stringify(body),
286
+ })
287
+ await assertOk(response, 'Session start')
288
+ const data = await readJson(response)
289
+ return { session: sessionFrom(data, 'Session start') }
290
+ }
291
+
292
+ export async function runSessionStatus(opts: SessionStatusOptions = {}): Promise<SessionStatusResult> {
293
+ const fetcher = fetcherFrom(opts)
294
+ const sessionId = stringOrNull(opts.sessionId ?? undefined)
295
+ if (sessionId) {
296
+ const response = await fetcher(`/api/cli/sessions/${encodeURIComponent(sessionId)}`)
297
+ await assertOk(response, 'Session status')
298
+ const data = await readJson(response)
299
+ return { session: sessionFrom(data, 'Session status') }
300
+ }
301
+
302
+ const workspaceId = resolveAttachedWorkspaceId(opts)
303
+ const status = stringOrNull(opts.status ?? undefined)
304
+ const query = status ? `?status=${encodeURIComponent(status)}` : ''
305
+ const response = await fetcher(`/api/cli/workspaces/${encodeURIComponent(workspaceId)}/sessions${query}`)
306
+ await assertOk(response, 'Session status')
307
+ const data = await readJson(response)
308
+ return { sessions: sessionsFrom(data) }
309
+ }
310
+
311
+ export async function runSessionEnd(opts: SessionEndOptions): Promise<SessionEndResult> {
312
+ const fetcher = fetcherFrom(opts)
313
+ if (!stringOrNull(opts.sessionId)) {
314
+ throw new Error('Usage: orizu session end --session <id> [--json]')
315
+ }
316
+ const response = await fetcher(`/api/cli/sessions/${encodeURIComponent(opts.sessionId)}`, {
317
+ method: 'PATCH',
318
+ headers: { 'Content-Type': 'application/json' },
319
+ body: JSON.stringify({ status: 'ended' }),
320
+ })
321
+ await assertOk(response, 'Session end')
322
+ const data = await readJson(response)
323
+ return {
324
+ session: sessionFrom(data, 'Session end'),
325
+ branchStatus: stringOrNull(data.branchStatus) ?? undefined,
326
+ }
327
+ }
328
+
329
+ export async function runSessionFinish(opts: SessionFinishOptions): Promise<SessionFinishResult> {
330
+ const fetcher = fetcherFrom(opts)
331
+ if (!stringOrNull(opts.sessionId)) {
332
+ throw new Error('Usage: orizu session finish --session <id> [--project <team/project>] [--json]')
333
+ }
334
+ const body: Record<string, unknown> = {}
335
+ const projectSlug = normalizeProjectSlug(opts.projectSlug)
336
+ if (projectSlug) {
337
+ body.projectSlug = projectSlug
338
+ }
339
+ const response = await fetcher(`/api/cli/sessions/${encodeURIComponent(opts.sessionId)}/finish-branch`, {
340
+ method: 'POST',
341
+ headers: { 'Content-Type': 'application/json' },
342
+ body: JSON.stringify(body),
343
+ })
344
+ await assertOk(response, 'Session finish')
345
+ const data = await readJson(response)
346
+ const manifest =
347
+ data.manifest && typeof data.manifest === 'object' && !Array.isArray(data.manifest)
348
+ ? (data.manifest as Record<string, unknown>)
349
+ : undefined
350
+ return {
351
+ outcome: stringOrNull(data.outcome) || 'unknown',
352
+ branch: stringOrNull(data.branch) ?? undefined,
353
+ branchDeleted: typeof data.branchDeleted === 'boolean' ? data.branchDeleted : undefined,
354
+ manifest,
355
+ }
356
+ }
357
+
358
+ export async function runWorkbenchRunStart(opts: WorkbenchRunStartOptions): Promise<WorkbenchRunResult> {
359
+ const fetcher = fetcherFrom(opts)
360
+ if (!stringOrNull(opts.sessionId) || !stringOrNull(opts.title)) {
361
+ throw new Error('Usage: orizu run start --session <id> --title <title> [--project <team/project>] [--json]')
362
+ }
363
+ const projectSlug = normalizeProjectSlug(opts.projectSlug)
364
+ const body: Record<string, unknown> = {
365
+ workspaceSessionId: opts.sessionId,
366
+ title: opts.title,
367
+ }
368
+ if (projectSlug) {
369
+ body.projectSlug = projectSlug
370
+ }
371
+
372
+ const response = await fetcher('/api/cli/workbench-runs', {
373
+ method: 'POST',
374
+ headers: { 'Content-Type': 'application/json' },
375
+ body: JSON.stringify(body),
376
+ })
377
+ if (response.status === 409) {
378
+ // PR #1284 contract: starting a run on an ended session is a 409.
379
+ const message = await responseMessage(response)
380
+ const hint = /ended/i.test(message) ? ' Run `orizu session start` to open a new session.' : ''
381
+ throw new Error(`Run start failed (409): ${message}${hint}`)
382
+ }
383
+ await assertOk(response, 'Run start')
384
+ const data = await readJson(response)
385
+ return { run: runFrom(data, 'Run start') }
386
+ }
387
+
388
+ export async function runWorkbenchRunStatus(opts: WorkbenchRunStatusOptions): Promise<WorkbenchRunResult> {
389
+ const fetcher = fetcherFrom(opts)
390
+ if (!stringOrNull(opts.runId)) {
391
+ throw new Error('Usage: orizu run status --run <id> [--json]')
392
+ }
393
+ const response = await fetcher(`/api/cli/workbench-runs/${encodeURIComponent(opts.runId)}`)
394
+ await assertOk(response, 'Run status')
395
+ const data = await readJson(response)
396
+ return { run: runFrom(data, 'Run status') }
397
+ }
398
+
399
+ export async function runWorkbenchRunTerminal(opts: WorkbenchRunTerminalOptions): Promise<WorkbenchRunResult> {
400
+ const fetcher = fetcherFrom(opts)
401
+ if (!stringOrNull(opts.runId) || !TERMINAL_RUN_STATUSES.has(opts.status)) {
402
+ throw new Error('Usage: orizu run <complete|fail|cancel> --run <id> [--summary <text>] [--json]')
403
+ }
404
+ const body: Record<string, unknown> = { status: opts.status }
405
+ if (opts.summary) {
406
+ body.summary = { note: opts.summary }
407
+ }
408
+ if (opts.evidence) {
409
+ body.evidence = opts.evidence
410
+ }
411
+
412
+ const response = await fetcher(`/api/cli/workbench-runs/${encodeURIComponent(opts.runId)}`, {
413
+ method: 'PATCH',
414
+ headers: { 'Content-Type': 'application/json' },
415
+ body: JSON.stringify(body),
416
+ })
417
+ if (response.status === 409) {
418
+ // PR #1284 contract: "Run already <status>" names the STORED status when a
419
+ // different terminal status was requested — report it plus a status hint.
420
+ const message = await responseMessage(response)
421
+ const alreadyMatch = /^Run already (\S+)/.exec(message)
422
+ if (alreadyMatch) {
423
+ throw new Error(
424
+ `Run update rejected (409): run is already '${alreadyMatch[1]}' (requested '${opts.status}'). Check \`orizu run status --run ${opts.runId}\`.`
425
+ )
426
+ }
427
+ throw new Error(`Run update failed (409): ${message}`)
428
+ }
429
+ await assertOk(response, 'Run update')
430
+ const data = await readJson(response)
431
+ return { run: runFrom(data, 'Run update') }
432
+ }
433
+
434
+ export async function tailOnce(
435
+ fetcher: WorkbenchFetcher,
436
+ runId: string,
437
+ cursor: number,
438
+ limit = DEFAULT_TAIL_LIMIT
439
+ ): Promise<TailOnceResult> {
440
+ // The API requires an integer cursor: only send back whole non-negative
441
+ // sequences (PR #1284 contract — non-numeric/negative `after` is a 400).
442
+ const safeCursor = Number.isFinite(cursor) && cursor > 0 ? Math.floor(cursor) : 0
443
+ const safeLimit = normalizeTailLimit(limit)
444
+ const response = await fetcher(
445
+ `/api/cli/workbench-runs/${encodeURIComponent(runId)}/events?after=${safeCursor}&limit=${safeLimit}`
446
+ )
447
+ await assertOk(response, 'Run tail')
448
+ const data = await readJson(response)
449
+ return {
450
+ events: eventsFrom(data),
451
+ cursor: numberOrFallback(data.cursor, safeCursor),
452
+ runStatus: stringOrNull(data.runStatus) || 'unknown',
453
+ }
454
+ }
455
+
456
+ export async function tailWorkbenchRun(opts: TailWorkbenchRunOptions): Promise<TailOnceResult> {
457
+ const fetcher = fetcherFrom(opts)
458
+ if (!stringOrNull(opts.runId)) {
459
+ throw new Error(
460
+ 'Usage: orizu run tail --run <id> [--after <seq>] [--interval <seconds>] [--once] [--json] (--once drains all currently persisted events, then exits without following)'
461
+ )
462
+ }
463
+ const sleep = opts.sleep ?? defaultSleep
464
+ let cursor = typeof opts.after === 'number' && Number.isFinite(opts.after) && opts.after > 0 ? Math.floor(opts.after) : 0
465
+ const intervalMs = Math.max(MIN_TAIL_INTERVAL_MS, Math.round((opts.intervalSeconds ?? DEFAULT_TAIL_INTERVAL_SECONDS) * 1000))
466
+ const pageLimit = normalizeTailLimit(opts.limit)
467
+
468
+ while (true) {
469
+ const previousCursor = cursor
470
+ const result = await tailOnce(fetcher, opts.runId, cursor, opts.limit)
471
+ cursor = result.cursor
472
+ for (const event of result.events) {
473
+ opts.onEvent?.(event)
474
+ }
475
+ // A full page means more events may already be persisted — keep draining
476
+ // back-to-back (no sleep). Exit conditions are only evaluated on a SHORT
477
+ // page: a terminal runStatus on a full page would otherwise truncate a
478
+ // completed run that has more unread pages.
479
+ if (result.events.length >= pageLimit) {
480
+ // Protocol invariant: a full page always implies the server advanced
481
+ // the cursor past what we asked for. If it didn't, draining back-to-back
482
+ // would spin hot forever on a misbehaving/stalled server. We pick the
483
+ // simpler of the two acceptable fixes (apply the normal poll sleep
484
+ // before retrying, rather than failing after one retry) because it
485
+ // matches this loop's existing backoff-on-no-progress shape (see the
486
+ // MIN_TAIL_INTERVAL_MS floor above) without introducing a new
487
+ // retry-count/error path.
488
+ if (cursor > previousCursor) {
489
+ continue
490
+ }
491
+ await sleep(intervalMs)
492
+ continue
493
+ }
494
+ if (opts.once || TERMINAL_RUN_STATUSES.has(result.runStatus)) {
495
+ return result
496
+ }
497
+ await sleep(intervalMs)
498
+ }
499
+ }
500
+
501
+ function hasFlag(args: string[], flag: string): boolean {
502
+ return args.includes(flag)
503
+ }
504
+
505
+ function argValue(args: string[], flag: string): string | null {
506
+ const index = args.indexOf(flag)
507
+ if (index === -1 || index + 1 >= args.length || args[index + 1].startsWith('--')) {
508
+ return null
509
+ }
510
+ return args[index + 1]
511
+ }
512
+
513
+ function numberArg(args: string[], flag: string): number | undefined {
514
+ const raw = argValue(args, flag)
515
+ if (!raw) {
516
+ return undefined
517
+ }
518
+ const parsed = Number(raw)
519
+ return Number.isFinite(parsed) ? parsed : undefined
520
+ }
521
+
522
+ function positionalArgs(args: string[]): string[] {
523
+ const values: string[] = []
524
+ for (let i = 0; i < args.length; i += 1) {
525
+ const arg = args[i]
526
+ if (arg.startsWith('--')) {
527
+ if (argValue(args, arg) !== null) {
528
+ i += 1
529
+ }
530
+ continue
531
+ }
532
+ values.push(arg)
533
+ }
534
+ return values
535
+ }
536
+
537
+ function emit(io: WorkbenchCommandIo, human: string, payload: Record<string, unknown>) {
538
+ io.print(io.json ? JSON.stringify(payload) : human)
539
+ }
540
+
541
+ function formatSessionStatus(result: SessionStatusResult): string {
542
+ if (result.session) {
543
+ const lines = [`session ${result.session.id} ${result.session.status}`]
544
+ for (const run of result.session.runs ?? []) {
545
+ lines.push(` run ${run.id} ${run.status}`)
546
+ }
547
+ return lines.join('\n')
548
+ }
549
+ const sessions = result.sessions ?? []
550
+ return ['workspace sessions', ...sessions.map(session => `${session.id} ${session.status}`)].join('\n')
551
+ }
552
+
553
+ function formatSessionFinish(result: SessionFinishResult): string {
554
+ if (result.outcome === 'no-changes') {
555
+ const branch = result.branch ?? 'the session branch'
556
+ return result.branchDeleted === false
557
+ ? `no changes on ${branch}; branch NOT deleted (delete it manually or retry \`orizu session finish\`)`
558
+ : `no changes on ${branch}; branch deleted`
559
+ }
560
+ const manifest = result.manifest ?? {}
561
+ const id = typeof manifest.id === 'string' ? manifest.id : '(unknown)'
562
+ const status = typeof manifest.status === 'string' ? manifest.status : '(unknown)'
563
+ const proposed = (manifest.proposedState ?? {}) as Record<string, unknown>
564
+ const files = typeof proposed.filesChanged === 'number' ? proposed.filesChanged : 0
565
+ const additions = typeof proposed.additions === 'number' ? proposed.additions : 0
566
+ const deletions = typeof proposed.deletions === 'number' ? proposed.deletions : 0
567
+ return `manifest ${id} ${status} repo_merge (${files} files, +${additions}/-${deletions})`
568
+ }
569
+
570
+ function formatRunStatus(run: WorkbenchRun): string {
571
+ const sequence = typeof run.latestSequence === 'number' ? ` latestSequence=${run.latestSequence}` : ''
572
+ return `run ${run.id} ${run.status}${sequence}`
573
+ }
574
+
575
+ function formatEventLine(event: WorkbenchRunEvent): string {
576
+ const payload = event.payload && Object.keys(event.payload).length > 0 ? ` ${JSON.stringify(event.payload)}` : ''
577
+ return `${event.sequence} ${event.eventType}${payload}`
578
+ }
579
+
580
+ function terminalStatusForCommand(command: string): string | null {
581
+ if (command === 'complete') return 'succeeded'
582
+ if (command === 'fail') return 'failed'
583
+ if (command === 'cancel') return 'cancelled'
584
+ return null
585
+ }
586
+
587
+ const USAGE_LINE =
588
+ 'Usage: orizu session <start|status|end|finish> ... | orizu run <start|status|tail|complete|fail|cancel> ... (run tail --once drains all currently persisted events, then exits without following)'
589
+
590
+ // In --json mode ANY command failure — bad usage, or an operational failure
591
+ // such as a 401/403/404/409/network error surfaced by assertOk — must still
592
+ // land as a single machine-readable document (`{"error": ...}`) on stdout,
593
+ // never human help text and never a plain-text message on stderr. An agent
594
+ // parsing --json stdout should never have to also watch stderr for the most
595
+ // common failure modes. Non-JSON mode is unchanged: the message prints as-is.
596
+ // Stack traces are never printed either way (only `error.message`).
597
+ function emitJsonError(io: WorkbenchCommandIo, message: string): number {
598
+ io.print(io.json ? JSON.stringify({ error: message }) : message)
599
+ return 1
600
+ }
601
+
602
+ export async function workbenchCommand(args: string[], io: WorkbenchCommandIo): Promise<number> {
603
+ try {
604
+ return await dispatchWorkbenchCommand(args, io)
605
+ } catch (error) {
606
+ const message = error instanceof Error ? error.message : String(error)
607
+ if (io.json) {
608
+ return emitJsonError(io, message)
609
+ }
610
+ throw error
611
+ }
612
+ }
613
+
614
+ async function dispatchWorkbenchCommand(args: string[], io: WorkbenchCommandIo): Promise<number> {
615
+ const positional = positionalArgs(args)
616
+ const group = positional[0]
617
+ const subcommand = positional[1]
618
+ const fetcher = io.fetcher
619
+
620
+ if (group === 'session') {
621
+ if (subcommand === 'start') {
622
+ const result = await runSessionStart({
623
+ fetcher,
624
+ projectSlug: argValue(args, '--project'),
625
+ clientInfo: { source: 'orizu-cli' },
626
+ })
627
+ emit(io, `session started: ${result.session.id}`, result as unknown as Record<string, unknown>)
628
+ return 0
629
+ }
630
+ if (subcommand === 'status') {
631
+ const status = argValue(args, '--status')
632
+ if (status && !SESSION_STATUSES.has(status)) {
633
+ throw new Error('Usage: orizu session status [--session <id> | --status active|ended] [--json]')
634
+ }
635
+ const result = await runSessionStatus({ fetcher, sessionId: argValue(args, '--session'), status })
636
+ emit(io, formatSessionStatus(result), result as unknown as Record<string, unknown>)
637
+ return 0
638
+ }
639
+ if (subcommand === 'end') {
640
+ const result = await runSessionEnd({ fetcher, sessionId: argValue(args, '--session') || '' })
641
+ const hint =
642
+ result.branchStatus === 'has-changes'
643
+ ? '\nsession branch has changes — run `orizu session finish --session ' + result.session.id + '` to promote them'
644
+ : ''
645
+ emit(io, `session ended: ${result.session.id}${hint}`, result as unknown as Record<string, unknown>)
646
+ return 0
647
+ }
648
+ if (subcommand === 'finish') {
649
+ const result = await runSessionFinish({
650
+ fetcher,
651
+ sessionId: argValue(args, '--session') || '',
652
+ projectSlug: argValue(args, '--project'),
653
+ })
654
+ emit(io, formatSessionFinish(result), result as unknown as Record<string, unknown>)
655
+ return 0
656
+ }
657
+ }
658
+
659
+ if (group === 'run') {
660
+ if (subcommand === 'start') {
661
+ const result = await runWorkbenchRunStart({
662
+ fetcher,
663
+ sessionId: argValue(args, '--session') || '',
664
+ title: argValue(args, '--title') || '',
665
+ projectSlug: argValue(args, '--project'),
666
+ })
667
+ emit(io, `run started: ${result.run.id}`, result as unknown as Record<string, unknown>)
668
+ return 0
669
+ }
670
+ if (subcommand === 'status') {
671
+ const result = await runWorkbenchRunStatus({ fetcher, runId: argValue(args, '--run') || '' })
672
+ emit(io, formatRunStatus(result.run), result as unknown as Record<string, unknown>)
673
+ return 0
674
+ }
675
+ if (subcommand === 'tail') {
676
+ await tailWorkbenchRun({
677
+ fetcher,
678
+ runId: argValue(args, '--run') || '',
679
+ after: numberArg(args, '--after'),
680
+ intervalSeconds: numberArg(args, '--interval'),
681
+ once: hasFlag(args, '--once'),
682
+ sleep: io.sleep,
683
+ onEvent: event => io.print(io.json ? JSON.stringify(event) : formatEventLine(event)),
684
+ })
685
+ return 0
686
+ }
687
+ const status = terminalStatusForCommand(subcommand || '')
688
+ if (status) {
689
+ const result = await runWorkbenchRunTerminal({
690
+ fetcher,
691
+ runId: argValue(args, '--run') || '',
692
+ status,
693
+ summary: argValue(args, '--summary'),
694
+ })
695
+ emit(io, formatRunStatus(result.run), result as unknown as Record<string, unknown>)
696
+ return 0
697
+ }
698
+ }
699
+
700
+ return emitJsonError(io, USAGE_LINE)
701
+ }