orizu 0.5.52 → 0.5.53

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 (65) hide show
  1. package/dist/auth-commands.js +2 -2
  2. package/dist/cli-console-output.d.ts +7 -1
  3. package/dist/cli-console-output.d.ts.map +1 -1
  4. package/dist/cli-console-output.js +16 -3
  5. package/dist/cli-stops.d.ts +14 -0
  6. package/dist/cli-stops.d.ts.map +1 -0
  7. package/dist/cli-stops.js +22 -0
  8. package/dist/egress-allowlist-cli.d.ts +29 -2
  9. package/dist/egress-allowlist-cli.d.ts.map +1 -1
  10. package/dist/egress-allowlist-cli.js +16 -89
  11. package/dist/egress-allowlist-workflow.d.ts +17 -0
  12. package/dist/egress-allowlist-workflow.d.ts.map +1 -0
  13. package/dist/egress-allowlist-workflow.js +183 -0
  14. package/dist/instruction-delivery.d.ts +4 -4
  15. package/dist/instruction-delivery.d.ts.map +1 -1
  16. package/dist/instruction-delivery.js +32 -46
  17. package/dist/instruction-prune.d.ts +24 -0
  18. package/dist/instruction-prune.d.ts.map +1 -0
  19. package/dist/instruction-prune.js +33 -0
  20. package/dist/instruction-set-sync/index.d.ts +0 -2
  21. package/dist/instruction-set-sync/index.d.ts.map +1 -1
  22. package/dist/instruction-set-sync/index.js +3 -36
  23. package/dist/instruction-set-update/index.d.ts +41 -3
  24. package/dist/instruction-set-update/index.d.ts.map +1 -1
  25. package/dist/instruction-set-update/index.js +156 -132
  26. package/dist/instruction-sets-cli.d.ts.map +1 -1
  27. package/dist/instruction-sets-cli.js +21 -2
  28. package/dist/instruction-sync-lock.d.ts +9 -0
  29. package/dist/instruction-sync-lock.d.ts.map +1 -0
  30. package/dist/instruction-sync-lock.js +24 -0
  31. package/dist/json-output-scrub.d.ts +3 -0
  32. package/dist/json-output-scrub.d.ts.map +1 -0
  33. package/dist/json-output-scrub.js +70 -0
  34. package/dist/one-request-write.d.ts.map +1 -1
  35. package/dist/one-request-write.js +2 -9
  36. package/dist/workbench-cli.d.ts +2 -1
  37. package/dist/workbench-cli.d.ts.map +1 -1
  38. package/dist/workbench-cli.js +79 -593
  39. package/dist/workbench-workflows.d.ts +56 -0
  40. package/dist/workbench-workflows.d.ts.map +1 -0
  41. package/dist/workbench-workflows.js +665 -0
  42. package/dist/workspace-sync-workflow.d.ts +115 -0
  43. package/dist/workspace-sync-workflow.d.ts.map +1 -0
  44. package/dist/workspace-sync-workflow.js +642 -0
  45. package/dist/workspace-sync.d.ts +45 -97
  46. package/dist/workspace-sync.d.ts.map +1 -1
  47. package/dist/workspace-sync.js +53 -494
  48. package/package.json +1 -1
  49. package/src/auth-commands.ts +2 -2
  50. package/src/cli-console-output.ts +16 -2
  51. package/src/cli-stops.ts +27 -0
  52. package/src/egress-allowlist-cli.ts +18 -90
  53. package/src/egress-allowlist-workflow.ts +198 -0
  54. package/src/instruction-delivery.ts +31 -47
  55. package/src/instruction-prune.ts +59 -0
  56. package/src/instruction-set-sync/index.ts +3 -43
  57. package/src/instruction-set-update/index.ts +173 -122
  58. package/src/instruction-sets-cli.ts +23 -2
  59. package/src/instruction-sync-lock.ts +27 -0
  60. package/src/json-output-scrub.ts +73 -0
  61. package/src/one-request-write.ts +2 -10
  62. package/src/workbench-cli.ts +83 -684
  63. package/src/workbench-workflows.ts +815 -0
  64. package/src/workspace-sync-workflow.ts +838 -0
  65. package/src/workspace-sync.ts +63 -630
@@ -5,37 +5,8 @@
5
5
  * do not print. The thin `workbenchCommand` entry point owns CLI argument
6
6
  * parsing and human/JSON output.
7
7
  */
8
- import { spawnSync } from 'child_process';
9
- import { authedFetch } from './http.js';
10
8
  import { findUnknownOption } from './option-validation.js';
11
- import { hasStagedChanges, NOTHING_STAGED_ERROR, unstageOrizuSkillLinks } from './orizu-skill-links.js';
12
9
  import { formatRunEventDigest } from './run-event-digest.js';
13
- import { createStartIntentId, isAmbiguousStartResponseStatus, isStartIntentId, START_RESPONSE_LOSS_MAX_RETRIES, } from './start-intent.js';
14
- import { getWorkspaceRoot, workspaceExists } from './workspace.js';
15
- import { attachedWorkspaceId, stringOrNull } from './workspace-sync.js';
16
- function defaultSessionGit(args, opts) {
17
- try {
18
- // Strip repo-context env (set by git when running inside hooks) so the
19
- // child git is scoped strictly to cwd/args (same pattern as github-setup).
20
- const env = { ...process.env };
21
- delete env.GIT_DIR;
22
- delete env.GIT_WORK_TREE;
23
- delete env.GIT_INDEX_FILE;
24
- const result = spawnSync('git', args, { cwd: opts?.cwd, encoding: 'utf8', env });
25
- return {
26
- status: result.status ?? 1,
27
- stdout: typeof result.stdout === 'string' ? result.stdout : '',
28
- stderr: typeof result.stderr === 'string' ? result.stderr : '',
29
- };
30
- }
31
- catch {
32
- return { status: 1, stdout: '', stderr: 'git is not available' };
33
- }
34
- }
35
- /** True when `root` is the workbench clone: contract marker + a git repo. */
36
- function isWorkbenchClone(root, git) {
37
- return workspaceExists(root) && git(['rev-parse', '--git-dir'], { cwd: root }).status === 0;
38
- }
39
10
  /** A bounded repository wait exhausted without losing the immutable identity
40
11
  * needed to resume safely. CLI callers can show the command; library callers
41
12
  * can use the structured fields directly. */
@@ -61,15 +32,7 @@ export class SessionRepositoryPendingError extends Error {
61
32
  this.resumeCommand = resumeCommand;
62
33
  }
63
34
  }
64
- const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'cancelled']);
65
35
  const SESSION_STATUSES = new Set(['active', 'ended']);
66
- const DEFAULT_TAIL_LIMIT = 100;
67
- const MAX_TAIL_LIMIT = 500;
68
- const DEFAULT_TAIL_INTERVAL_SECONDS = 2;
69
- // Floor for the continuous poll sleep so `--interval 0` (or negatives) cannot
70
- // hot-spin against the API while a non-terminal run is quiet. Draining full
71
- // pages intentionally skips the sleep — that is catch-up, not polling.
72
- const MIN_TAIL_INTERVAL_MS = 500;
73
36
  const SESSION_START_OPTIONS = new Set([
74
37
  '--project',
75
38
  '--workspace',
@@ -91,547 +54,48 @@ const WORKBENCH_VALUE_OPTIONS = new Set([
91
54
  '--title',
92
55
  '--workspace',
93
56
  ]);
94
- function defaultSleep(ms) {
95
- return new Promise(resolve => setTimeout(resolve, ms));
96
- }
97
57
  export const SESSION_REPOSITORY_PENDING_RETRY_MS = 2_000;
98
58
  export const SESSION_REPOSITORY_PENDING_MAX_RETRIES = 30;
99
- function numberOrFallback(value, fallback) {
100
- return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
101
- }
102
- function normalizeTailLimit(limit) {
103
- return typeof limit === 'number' && Number.isFinite(limit) && limit > 0
104
- ? Math.min(Math.floor(limit), MAX_TAIL_LIMIT)
105
- : DEFAULT_TAIL_LIMIT;
106
- }
107
- function normalizeProjectSlug(value) {
108
- const raw = stringOrNull(value);
109
- if (!raw) {
110
- return null;
111
- }
112
- const segments = raw.split('/').map(segment => segment.trim()).filter(Boolean);
113
- return segments.length > 0 ? segments[segments.length - 1] : null;
114
- }
115
- function resolveAttachedWorkspaceId(opts) {
116
- const explicit = stringOrNull(opts.workspaceId ?? undefined);
117
- if (explicit) {
118
- return explicit;
119
- }
120
- const root = getWorkspaceRoot(opts.cwd);
121
- const attached = attachedWorkspaceId(root);
122
- if (!attached) {
123
- throw new Error('This workspace is not attached yet; run `orizu workspace sync` first to attach it.');
124
- }
125
- return attached;
126
- }
127
- async function readJson(response) {
128
- try {
129
- return await response.json();
130
- }
131
- catch {
132
- return {};
133
- }
134
- }
135
- async function responseMessage(response) {
136
- try {
137
- const data = await response.clone().json();
138
- return stringOrNull(data.error) || stringOrNull(data.message) || response.statusText || String(response.status);
139
- }
140
- catch {
141
- try {
142
- const text = await response.text();
143
- return text.trim() || response.statusText || String(response.status);
144
- }
145
- catch {
146
- return response.statusText || String(response.status);
147
- }
148
- }
149
- }
150
- async function assertOk(response, action) {
151
- if (response.ok) {
152
- return;
153
- }
154
- throw new Error(`${action} failed (${response.status}): ${await responseMessage(response)}`);
155
- }
156
- function fetcherFrom(opts) {
157
- return opts.fetcher ?? authedFetch;
158
- }
159
- function requiredBodyObject(data, key, action) {
160
- const value = data[key];
161
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
162
- throw new Error(`${action} response did not include ${key}`);
163
- }
164
- return value;
165
- }
166
- function sessionFrom(data, action) {
167
- return requiredBodyObject(data, 'session', action);
168
- }
169
- function runFrom(data, action) {
170
- return requiredBodyObject(data, 'run', action);
171
- }
172
- function sessionsFrom(data) {
173
- return Array.isArray(data.sessions) ? data.sessions : [];
174
- }
175
- function eventsFrom(data) {
176
- return Array.isArray(data.events) ? data.events : [];
177
- }
178
- /**
179
- * Fetch the freshly-cut remote session branch and check out a local tracking
180
- * branch, so a human can start committing immediately. Best-effort by design:
181
- * every failure (not the clone, no git, fetch/checkout error) returns a
182
- * 'skipped' result with a human-usable detail — it NEVER throws, because
183
- * checkout ergonomics must never fail a successfully started session.
184
- */
185
- function checkoutSessionBranchLocally(cwd, branch, git) {
186
- const root = getWorkspaceRoot(cwd);
187
- try {
188
- if (!isWorkbenchClone(root, git)) {
189
- return { branch, state: 'skipped', detail: 'not inside the workbench clone' };
190
- }
191
- if (git(['fetch', 'origin', branch], { cwd: root }).status !== 0) {
192
- return { branch, state: 'skipped', detail: `could not fetch origin/${branch}` };
193
- }
194
- const haveLocal = git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], { cwd: root }).status === 0;
195
- const checkout = haveLocal
196
- ? git(['checkout', branch], { cwd: root })
197
- : git(['checkout', '-b', branch, '--track', `origin/${branch}`], { cwd: root });
198
- if (checkout.status !== 0) {
199
- const reason = checkout.stderr.trim() || `exit ${checkout.status}`;
200
- return { branch, state: 'skipped', detail: `checkout failed: ${reason}` };
201
- }
202
- return {
203
- branch,
204
- state: 'checked-out',
205
- detail: haveLocal ? `switched to existing local branch ${branch}` : `tracking origin/${branch}`,
206
- };
207
- }
208
- catch (error) {
209
- const reason = error instanceof Error ? error.message : String(error);
210
- return { branch, state: 'skipped', detail: reason };
211
- }
212
- }
213
- /** Local ahead/behind vs origin/<branch>; counts are null when not computable. */
214
- function computeSessionBranchSync(cwd, branch, git) {
215
- const root = getWorkspaceRoot(cwd);
216
- try {
217
- if (!isWorkbenchClone(root, git)) {
218
- return { branch, ahead: null, behind: null };
219
- }
220
- const counts = git(['rev-list', '--left-right', '--count', `origin/${branch}...${branch}`], { cwd: root });
221
- if (counts.status !== 0) {
222
- return { branch, ahead: null, behind: null };
223
- }
224
- // `--left-right --count origin/b...b` prints "<origin-only>\t<local-only>".
225
- const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(counts.stdout);
226
- if (!match) {
227
- return { branch, ahead: null, behind: null };
228
- }
229
- return { branch, ahead: Number(match[2]), behind: Number(match[1]) };
230
- }
231
- catch {
232
- return { branch, ahead: null, behind: null };
233
- }
234
- }
59
+ // The workflows run on Effect in workbench-workflows (ORI-2734), loaded only
60
+ // when a command runs, so CLI startup and the hosted session CLI never load
61
+ // Effect. These exports keep their signatures and add no stop handlers.
62
+ const workflows = () => import('./workbench-workflows.js');
235
63
  export async function runSessionStart(opts = {}) {
236
- const fetcher = fetcherFrom(opts);
237
- const workspaceId = resolveAttachedWorkspaceId(opts);
238
- const projectSlug = normalizeProjectSlug(opts.projectSlug);
239
- const suppliedStartIntentId = stringOrNull(opts.startIntentId);
240
- if (suppliedStartIntentId &&
241
- !isStartIntentId(suppliedStartIntentId)) {
242
- throw new Error('session start --start-intent must be a UUIDv4');
243
- }
244
- const startIntentId = suppliedStartIntentId ??
245
- createStartIntentId(opts.generateStartIntentId);
246
- const suppliedReconcileSessionId = stringOrNull(opts.reconcileSessionId);
247
- if (suppliedReconcileSessionId && !suppliedStartIntentId) {
248
- throw new Error('session start --session requires --start-intent');
249
- }
250
- const body = { startIntentId };
251
- if (projectSlug) {
252
- body.projectSlug = projectSlug;
253
- }
254
- if (opts.clientInfo) {
255
- body.clientInfo = opts.clientInfo;
256
- }
257
- let response;
258
- let data;
259
- let reconcileSessionId = suppliedReconcileSessionId;
260
- let repositoryPendingAttempts = 0;
261
- let ambiguousResponseAttempts = 0;
262
- for (;;) {
263
- try {
264
- response = await fetcher(`/api/cli/workspaces/${encodeURIComponent(workspaceId)}/sessions`, {
265
- method: 'POST',
266
- headers: { 'Content-Type': 'application/json' },
267
- body: JSON.stringify({
268
- ...body,
269
- ...(reconcileSessionId
270
- ? { sessionId: reconcileSessionId }
271
- : {}),
272
- }),
273
- });
274
- }
275
- catch (error) {
276
- if (ambiguousResponseAttempts <
277
- START_RESPONSE_LOSS_MAX_RETRIES) {
278
- ambiguousResponseAttempts += 1;
279
- continue;
280
- }
281
- throw error;
282
- }
283
- data = await readJson(response.clone());
284
- if (isAmbiguousStartResponseStatus(response.status) &&
285
- ambiguousResponseAttempts <
286
- START_RESPONSE_LOSS_MAX_RETRIES) {
287
- ambiguousResponseAttempts += 1;
288
- continue;
289
- }
290
- const repository = data.repository &&
291
- typeof data.repository === 'object' &&
292
- !Array.isArray(data.repository)
293
- ? data.repository
294
- : null;
295
- const pendingSession = response.status === 202 &&
296
- repository?.state === 'pending'
297
- ? sessionFrom(data, 'Session start')
298
- : null;
299
- const pendingSessionId = pendingSession
300
- ? stringOrNull(pendingSession.id)
301
- : null;
302
- if (pendingSessionId) {
303
- if (repositoryPendingAttempts >=
304
- SESSION_REPOSITORY_PENDING_MAX_RETRIES) {
305
- throw new SessionRepositoryPendingError({
306
- sessionId: pendingSessionId,
307
- startIntentId,
308
- projectSlug,
309
- workspaceDirectory: stringOrNull(opts.cwd),
310
- });
311
- }
312
- repositoryPendingAttempts += 1;
313
- ambiguousResponseAttempts = 0;
314
- reconcileSessionId = pendingSessionId;
315
- const retryAfterSeconds = typeof repository?.retryAfterSeconds === 'number' &&
316
- Number.isInteger(repository.retryAfterSeconds) &&
317
- repository.retryAfterSeconds >= 1 &&
318
- repository.retryAfterSeconds <= 60
319
- ? repository.retryAfterSeconds
320
- : SESSION_REPOSITORY_PENDING_RETRY_MS / 1_000;
321
- await (opts.sleep ?? defaultSleep)(retryAfterSeconds * 1_000);
322
- continue;
323
- }
324
- break;
325
- }
326
- await assertOk(response, 'Session start');
327
- const session = sessionFrom(data, 'Session start');
328
- // The session is started; local checkout is best-effort ergonomics only,
329
- // and only when explicitly requested (the CLI human path). The temporary
330
- // Artifacts compatibility branch is `main` in a separate remote; never run
331
- // the legacy `git fetch origin/main` flow against the caller's current
332
- // checkout.
333
- const branch = stringOrNull(session.repoBranch);
334
- const artifactsRepositoryId = stringOrNull(session.workingRepositoryId);
335
- const checkout = artifactsRepositoryId
336
- ? null
337
- : branch && opts.localCheckout
338
- ? checkoutSessionBranchLocally(opts.cwd, branch, opts.git ?? defaultSessionGit)
339
- : branch
340
- ? { branch, state: 'skipped', detail: 'local checkout not requested' }
341
- : null;
342
- return { session, checkout };
64
+ const { runPlain, sessionStart } = await workflows();
65
+ return runPlain(sessionStart(opts));
343
66
  }
344
67
  export async function runSessionStatus(opts = {}) {
345
- const fetcher = fetcherFrom(opts);
346
- const sessionId = stringOrNull(opts.sessionId ?? undefined);
347
- if (sessionId) {
348
- const response = await fetcher(`/api/cli/sessions/${encodeURIComponent(sessionId)}`);
349
- await assertOk(response, 'Session status');
350
- const data = await readJson(response);
351
- const session = sessionFrom(data, 'Session status');
352
- const branch = stringOrNull(session.repoBranch);
353
- const artifactsRepositoryId = stringOrNull(session.workingRepositoryId);
354
- const branchSync = branch && !artifactsRepositoryId
355
- ? computeSessionBranchSync(opts.cwd, branch, opts.git ?? defaultSessionGit)
356
- : null;
357
- return { session, branchSync };
358
- }
359
- const workspaceId = resolveAttachedWorkspaceId(opts);
360
- const status = stringOrNull(opts.status ?? undefined);
361
- const query = status ? `?status=${encodeURIComponent(status)}` : '';
362
- const response = await fetcher(`/api/cli/workspaces/${encodeURIComponent(workspaceId)}/sessions${query}`);
363
- await assertOk(response, 'Session status');
364
- const data = await readJson(response);
365
- return { sessions: sessionsFrom(data) };
68
+ const { runPlain, sessionStatus } = await workflows();
69
+ return runPlain(sessionStatus(opts));
366
70
  }
367
71
  export async function runSessionEnd(opts) {
368
- const fetcher = fetcherFrom(opts);
369
- if (!stringOrNull(opts.sessionId)) {
370
- throw new Error('Usage: orizu session end --session <id> [--json]');
371
- }
372
- const response = await fetcher(`/api/cli/sessions/${encodeURIComponent(opts.sessionId)}`, {
373
- method: 'PATCH',
374
- headers: { 'Content-Type': 'application/json' },
375
- body: JSON.stringify({ status: 'ended' }),
376
- });
377
- await assertOk(response, 'Session end');
378
- const data = await readJson(response);
379
- return {
380
- session: sessionFrom(data, 'Session end'),
381
- branchStatus: stringOrNull(data.branchStatus) ?? undefined,
382
- };
383
- }
384
- /**
385
- * Finish-time local checks (ALI-1028): when run inside the workbench clone
386
- * with the session branch checked out, uncommitted or unpushed local work is
387
- * reported (it would silently miss the manifest otherwise), and the opt-in
388
- * `--push` stages/commits/pushes it first. Raw git remains the default
389
- * workflow — without --push nothing local is mutated.
390
- */
391
- async function prepareLocalSessionBranch(opts, fetcher) {
392
- const git = opts.git ?? defaultSessionGit;
393
- const localWarnings = [];
394
- // Resolve the server-owned repository mode before invoking Git at all.
395
- // Artifacts temporarily exposes repoBranch=main for hosted compatibility,
396
- // but that branch belongs to the private working repository. Treating it as
397
- // a legacy branch in the caller's current checkout can stage, commit, and
398
- // push origin/main before the server has a chance to reject finish-branch.
399
- const response = await fetcher(`/api/cli/sessions/${encodeURIComponent(opts.sessionId)}`);
400
- if (!response.ok) {
401
- // Best-effort: the finish call right after will surface the real error.
402
- return { localWarnings, pushed: false };
403
- }
404
- const session = sessionFrom(await readJson(response), 'Session status');
405
- if (stringOrNull(session.workingRepositoryId)) {
406
- if (opts.push) {
407
- throw new Error('session finish --push is not supported for an Artifacts-backed session; the hosted session owns its private repository');
408
- }
409
- return { localWarnings, pushed: false };
410
- }
411
- const root = getWorkspaceRoot(opts.cwd);
412
- if (!isWorkbenchClone(root, git)) {
413
- if (opts.push) {
414
- throw new Error('session finish --push must run inside the workbench clone (it stages, commits, and pushes the session branch checkout)');
415
- }
416
- return { localWarnings, pushed: false };
417
- }
418
- const branch = stringOrNull(session.repoBranch);
419
- if (!branch) {
420
- return { localWarnings, pushed: false };
421
- }
422
- const head = git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root });
423
- const onSessionBranch = head.status === 0 && head.stdout.trim() === branch;
424
- if (!onSessionBranch) {
425
- if (opts.push) {
426
- const current = head.status === 0 ? head.stdout.trim() : '(unknown)';
427
- throw new Error(`session finish --push requires the session branch checkout: HEAD is ${current}, not ${branch}`);
428
- }
429
- return { localWarnings, pushed: false };
430
- }
431
- const status = git(['status', '--porcelain'], { cwd: root });
432
- const hasUncommitted = status.status === 0 && status.stdout.trim() !== '';
433
- if (opts.push) {
434
- if (hasUncommitted) {
435
- const add = git(['add', '-A'], { cwd: root });
436
- if (add.status !== 0) {
437
- throw new Error(`session finish --push: git add failed: ${add.stderr.trim() || `exit ${add.status}`}`);
438
- }
439
- // A hosted session's Orizu skill links are sandbox-local; never ship
440
- // them (ORI-2442). Commit only if something else is still staged.
441
- const skillGit = (args) => {
442
- const result = git([...args], { cwd: root });
443
- return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr };
444
- };
445
- const unstage = unstageOrizuSkillLinks(skillGit);
446
- if (!unstage.ok) {
447
- throw new Error(`session finish --push: ${unstage.error}`);
448
- }
449
- const staged = hasStagedChanges(skillGit);
450
- if (typeof staged !== 'boolean') {
451
- throw new Error(`session finish --push: ${staged.error}`);
452
- }
453
- if (!staged && unstage.unstaged.length === 0) {
454
- throw new Error(`session finish --push: ${NOTHING_STAGED_ERROR}`);
455
- }
456
- if (staged) {
457
- const message = stringOrNull(opts.message) ?? `Session ${opts.sessionId} work (orizu session finish --push)`;
458
- const commit = git(['commit', '-m', message], { cwd: root });
459
- if (commit.status !== 0) {
460
- throw new Error(`session finish --push: git commit failed: ${commit.stderr.trim() || `exit ${commit.status}`}`);
461
- }
462
- }
463
- }
464
- const push = git(['push', 'origin', branch], { cwd: root });
465
- if (push.status !== 0) {
466
- throw new Error(`session finish --push: git push failed: ${push.stderr.trim() || `exit ${push.status}`}`);
467
- }
468
- return { localWarnings, pushed: true };
469
- }
470
- if (hasUncommitted) {
471
- localWarnings.push(`uncommitted changes in your ${branch} checkout — they won't be in the manifest. ` +
472
- `Commit and push them first (git add/commit/push), or re-run with --push.`);
473
- }
474
- const counts = git(['rev-list', '--count', `origin/${branch}..${branch}`], { cwd: root });
475
- const unpushed = counts.status === 0 ? Number.parseInt(counts.stdout.trim(), 10) : 0;
476
- if (Number.isFinite(unpushed) && unpushed > 0) {
477
- localWarnings.push(`${unpushed} unpushed commit${unpushed === 1 ? '' : 's'} on ${branch} — they won't be in the manifest. ` +
478
- `Run \`git push origin ${branch}\` first, or re-run with --push.`);
479
- }
480
- return { localWarnings, pushed: false };
72
+ const { runPlain, sessionEnd } = await workflows();
73
+ return runPlain(sessionEnd(opts));
481
74
  }
482
75
  export async function runSessionFinish(opts) {
483
- const fetcher = fetcherFrom(opts);
484
- if (!stringOrNull(opts.sessionId)) {
485
- throw new Error('Usage: orizu session finish --session <id> [--project <team/project>] [--push [--message <text>]] [--json]');
486
- }
487
- const { localWarnings, pushed } = await prepareLocalSessionBranch(opts, fetcher);
488
- const body = {};
489
- const projectSlug = normalizeProjectSlug(opts.projectSlug);
490
- if (projectSlug) {
491
- body.projectSlug = projectSlug;
492
- }
493
- const response = await fetcher(`/api/cli/sessions/${encodeURIComponent(opts.sessionId)}/finish-branch`, {
494
- method: 'POST',
495
- headers: { 'Content-Type': 'application/json' },
496
- body: JSON.stringify(body),
497
- });
498
- await assertOk(response, 'Session finish');
499
- const data = await readJson(response);
500
- const manifest = data.manifest && typeof data.manifest === 'object' && !Array.isArray(data.manifest)
501
- ? data.manifest
502
- : undefined;
503
- return {
504
- outcome: stringOrNull(data.outcome) || 'unknown',
505
- branch: stringOrNull(data.branch) ?? undefined,
506
- branchDeleted: typeof data.branchDeleted === 'boolean' ? data.branchDeleted : undefined,
507
- manifest,
508
- localWarnings: localWarnings.length > 0 ? localWarnings : undefined,
509
- pushed: pushed || undefined,
510
- };
76
+ const { runPlain, sessionFinish } = await workflows();
77
+ return runPlain(sessionFinish(opts));
511
78
  }
512
79
  export async function runWorkbenchRunStart(opts) {
513
- const fetcher = fetcherFrom(opts);
514
- if (!stringOrNull(opts.sessionId) || !stringOrNull(opts.title)) {
515
- throw new Error('Usage: orizu run start --session <id> --title <title> [--project <team/project>] [--json]');
516
- }
517
- const projectSlug = normalizeProjectSlug(opts.projectSlug);
518
- const body = {
519
- workspaceSessionId: opts.sessionId,
520
- title: opts.title,
521
- };
522
- if (projectSlug) {
523
- body.projectSlug = projectSlug;
524
- }
525
- const response = await fetcher('/api/cli/workbench-runs', {
526
- method: 'POST',
527
- headers: { 'Content-Type': 'application/json' },
528
- body: JSON.stringify(body),
529
- });
530
- if (response.status === 409) {
531
- // PR #1284 contract: starting a run on an ended session is a 409.
532
- const message = await responseMessage(response);
533
- const hint = /ended/i.test(message) ? ' Run `orizu session start` to open a new session.' : '';
534
- throw new Error(`Run start failed (409): ${message}${hint}`);
535
- }
536
- await assertOk(response, 'Run start');
537
- const data = await readJson(response);
538
- return { run: runFrom(data, 'Run start') };
80
+ const { runPlain, runStart } = await workflows();
81
+ return runPlain(runStart(opts));
539
82
  }
540
83
  export async function runWorkbenchRunStatus(opts) {
541
- const fetcher = fetcherFrom(opts);
542
- if (!stringOrNull(opts.runId)) {
543
- throw new Error('Usage: orizu run status --run <id> [--json]');
544
- }
545
- const response = await fetcher(`/api/cli/workbench-runs/${encodeURIComponent(opts.runId)}`);
546
- await assertOk(response, 'Run status');
547
- const data = await readJson(response);
548
- return { run: runFrom(data, 'Run status') };
84
+ const { runPlain, runStatus } = await workflows();
85
+ return runPlain(runStatus(opts));
549
86
  }
550
87
  export async function runWorkbenchRunTerminal(opts) {
551
- const fetcher = fetcherFrom(opts);
552
- if (!stringOrNull(opts.runId) || !TERMINAL_RUN_STATUSES.has(opts.status)) {
553
- throw new Error('Usage: orizu run <complete|fail|cancel> --run <id> [--summary <text>] [--json]');
554
- }
555
- const body = { status: opts.status };
556
- if (opts.summary) {
557
- body.summary = { note: opts.summary };
558
- }
559
- if (opts.evidence) {
560
- body.evidence = opts.evidence;
561
- }
562
- const response = await fetcher(`/api/cli/workbench-runs/${encodeURIComponent(opts.runId)}`, {
563
- method: 'PATCH',
564
- headers: { 'Content-Type': 'application/json' },
565
- body: JSON.stringify(body),
566
- });
567
- if (response.status === 409) {
568
- // PR #1284 contract: "Run already <status>" names the STORED status when a
569
- // different terminal status was requested — report it plus a status hint.
570
- const message = await responseMessage(response);
571
- const alreadyMatch = /^Run already (\S+)/.exec(message);
572
- if (alreadyMatch) {
573
- throw new Error(`Run update rejected (409): run is already '${alreadyMatch[1]}' (requested '${opts.status}'). Check \`orizu run status --run ${opts.runId}\`.`);
574
- }
575
- throw new Error(`Run update failed (409): ${message}`);
576
- }
577
- await assertOk(response, 'Run update');
578
- const data = await readJson(response);
579
- return { run: runFrom(data, 'Run update') };
580
- }
581
- export async function tailOnce(fetcher, runId, cursor, limit = DEFAULT_TAIL_LIMIT) {
582
- // The API requires an integer cursor: only send back whole non-negative
583
- // sequences (PR #1284 contract — non-numeric/negative `after` is a 400).
584
- const safeCursor = Number.isFinite(cursor) && cursor > 0 ? Math.floor(cursor) : 0;
585
- const safeLimit = normalizeTailLimit(limit);
586
- const response = await fetcher(`/api/cli/workbench-runs/${encodeURIComponent(runId)}/events?after=${safeCursor}&limit=${safeLimit}`);
587
- await assertOk(response, 'Run tail');
588
- const data = await readJson(response);
589
- return {
590
- events: eventsFrom(data),
591
- cursor: numberOrFallback(data.cursor, safeCursor),
592
- runStatus: stringOrNull(data.runStatus) || 'unknown',
593
- };
88
+ const { runPlain, runTerminal } = await workflows();
89
+ return runPlain(runTerminal(opts));
90
+ }
91
+ export async function tailOnce(fetcher, runId, cursor, limit) {
92
+ const workflow = await workflows();
93
+ return workflow.runPlain(workflow.tailOnce(fetcher, runId, cursor, limit));
594
94
  }
95
+ /** Also the hosted session CLI's attached tail: same signature, no stop handlers. */
595
96
  export async function tailWorkbenchRun(opts) {
596
- const fetcher = fetcherFrom(opts);
597
- if (!stringOrNull(opts.runId)) {
598
- throw new Error('Usage: orizu run tail --run <id> [--after <seq>] [--interval <seconds>] [--once] [--json] (--once drains all currently persisted events, then exits without following)');
599
- }
600
- const sleep = opts.sleep ?? defaultSleep;
601
- let cursor = typeof opts.after === 'number' && Number.isFinite(opts.after) && opts.after > 0 ? Math.floor(opts.after) : 0;
602
- const intervalMs = Math.max(MIN_TAIL_INTERVAL_MS, Math.round((opts.intervalSeconds ?? DEFAULT_TAIL_INTERVAL_SECONDS) * 1000));
603
- const pageLimit = normalizeTailLimit(opts.limit);
604
- while (true) {
605
- const previousCursor = cursor;
606
- const result = await tailOnce(fetcher, opts.runId, cursor, opts.limit);
607
- cursor = result.cursor;
608
- for (const event of result.events) {
609
- opts.onEvent?.(event);
610
- }
611
- // A full page means more events may already be persisted — keep draining
612
- // back-to-back (no sleep). Exit conditions are only evaluated on a SHORT
613
- // page: a terminal runStatus on a full page would otherwise truncate a
614
- // completed run that has more unread pages.
615
- if (result.events.length >= pageLimit) {
616
- // Protocol invariant: a full page always implies the server advanced
617
- // the cursor past what we asked for. If it didn't, draining back-to-back
618
- // would spin hot forever on a misbehaving/stalled server. We pick the
619
- // simpler of the two acceptable fixes (apply the normal poll sleep
620
- // before retrying, rather than failing after one retry) because it
621
- // matches this loop's existing backoff-on-no-progress shape (see the
622
- // MIN_TAIL_INTERVAL_MS floor above) without introducing a new
623
- // retry-count/error path.
624
- if (cursor > previousCursor) {
625
- continue;
626
- }
627
- await sleep(intervalMs);
628
- continue;
629
- }
630
- if (opts.once || TERMINAL_RUN_STATUSES.has(result.runStatus)) {
631
- return result;
632
- }
633
- await sleep(intervalMs);
634
- }
97
+ const { runPlain, tail } = await workflows();
98
+ return runPlain(tail(opts));
635
99
  }
636
100
  function hasFlag(args, flag) {
637
101
  return args.includes(flag);
@@ -880,6 +344,25 @@ export async function workbenchCommand(args, io) {
880
344
  throw error;
881
345
  }
882
346
  }
347
+ /**
348
+ * Ctrl-C (130) and SIGTERM (143) end a local command's waits and reads. A
349
+ * write already sent finishes and its reply prints before the stop's exit
350
+ * code. A session start stopped while its repository is pending prints how to
351
+ * resume it.
352
+ */
353
+ function finishStoppable(io, outcome, print) {
354
+ if ('value' in outcome) {
355
+ print(outcome.value);
356
+ return outcome.interrupted ?? 0;
357
+ }
358
+ if (outcome.pending) {
359
+ const { sessionId, startIntentId, resumeCommand } = new SessionRepositoryPendingError(outcome.pending);
360
+ const message = 'Session start interrupted while its repository is still provisioning. ' +
361
+ `Resume the same session without creating a duplicate: ${resumeCommand}`;
362
+ io.print(io.json ? JSON.stringify({ error: message, sessionId, startIntentId, resumeCommand }) : message);
363
+ }
364
+ return outcome.interrupted;
365
+ }
883
366
  async function dispatchWorkbenchCommand(args, io) {
884
367
  const positional = positionalArgs(args);
885
368
  const group = positional[0];
@@ -906,7 +389,8 @@ async function dispatchWorkbenchCommand(args, io) {
906
389
  if (unknownOption) {
907
390
  return emitJsonError(io, `unknown option ${unknownOption}\nUsage: orizu session start [--project <team/project>] [--workspace <dir>] [--start-intent <uuid> --session <uuid>] [--json]`);
908
391
  }
909
- const result = await runSessionStart({
392
+ const { runStoppable, sessionStart } = await workflows();
393
+ const outcome = await runStoppable((settled, stop) => sessionStart({
910
394
  fetcher,
911
395
  cwd: workspaceDir,
912
396
  git: io.git,
@@ -917,32 +401,33 @@ async function dispatchWorkbenchCommand(args, io) {
917
401
  clientInfo: { source: 'orizu-cli' },
918
402
  generateStartIntentId: io.generateStartIntentId,
919
403
  sleep: io.sleep,
920
- });
921
- emit(io, formatSessionStart(result), result);
922
- return 0;
404
+ }, settled, stop));
405
+ return finishStoppable(io, outcome, result => emit(io, formatSessionStart(result), result));
923
406
  }
924
407
  if (subcommand === 'status') {
925
408
  const status = argValue(args, '--status');
926
409
  if (status && !SESSION_STATUSES.has(status)) {
927
410
  throw new Error('Usage: orizu session status [--session <id> | --status active|ended] [--json]');
928
411
  }
929
- const result = await runSessionStatus({
412
+ const { runStoppable, sessionStatus } = await workflows();
413
+ const outcome = await runStoppable(() => sessionStatus({
930
414
  fetcher,
931
415
  cwd: workspaceDir,
932
416
  git: io.git,
933
417
  sessionId: argValue(args, '--session'),
934
418
  status,
935
- });
936
- emit(io, formatSessionStatus(result), result);
937
- return 0;
419
+ }, true));
420
+ return finishStoppable(io, outcome, result => emit(io, formatSessionStatus(result), result));
938
421
  }
939
422
  if (subcommand === 'end') {
940
- const result = await runSessionEnd({ fetcher, sessionId: argValue(args, '--session') || '' });
941
- const hint = result.branchStatus === 'has-changes'
942
- ? '\nsession branch has changes — run `orizu session finish --session ' + result.session.id + '` to promote them'
943
- : '';
944
- emit(io, `session ended: ${result.session.id}${hint}`, result);
945
- return 0;
423
+ const { runStoppable, sessionEnd } = await workflows();
424
+ const outcome = await runStoppable(settled => sessionEnd({ fetcher, sessionId: argValue(args, '--session') || '' }, settled));
425
+ return finishStoppable(io, outcome, result => {
426
+ const hint = result.branchStatus === 'has-changes'
427
+ ? '\nsession branch has changes — run `orizu session finish --session ' + result.session.id + '` to promote them'
428
+ : '';
429
+ emit(io, `session ended: ${result.session.id}${hint}`, result);
430
+ });
946
431
  }
947
432
  if (subcommand === 'finish') {
948
433
  const message = argValue(args, '--message');
@@ -950,7 +435,8 @@ async function dispatchWorkbenchCommand(args, io) {
950
435
  if (message && !push) {
951
436
  throw new Error('`--message` only applies with `--push` (orizu session finish --session <id> --push --message <text>)');
952
437
  }
953
- const result = await runSessionFinish({
438
+ const { runStoppable, sessionFinish } = await workflows();
439
+ const outcome = await runStoppable(settled => sessionFinish({
954
440
  fetcher,
955
441
  cwd: workspaceDir,
956
442
  git: io.git,
@@ -958,29 +444,29 @@ async function dispatchWorkbenchCommand(args, io) {
958
444
  projectSlug: argValue(args, '--project'),
959
445
  push,
960
446
  message,
961
- });
962
- emit(io, formatSessionFinish(result), result);
963
- return 0;
447
+ }, settled, true));
448
+ return finishStoppable(io, outcome, result => emit(io, formatSessionFinish(result), result));
964
449
  }
965
450
  }
966
451
  if (group === 'run') {
967
452
  if (subcommand === 'start') {
968
- const result = await runWorkbenchRunStart({
453
+ const { runStoppable, runStart } = await workflows();
454
+ const outcome = await runStoppable(settled => runStart({
969
455
  fetcher,
970
456
  sessionId: argValue(args, '--session') || '',
971
457
  title: argValue(args, '--title') || '',
972
458
  projectSlug: argValue(args, '--project'),
973
- });
974
- emit(io, `run started: ${result.run.id}`, result);
975
- return 0;
459
+ }, settled));
460
+ return finishStoppable(io, outcome, result => emit(io, `run started: ${result.run.id}`, result));
976
461
  }
977
462
  if (subcommand === 'status') {
978
- const result = await runWorkbenchRunStatus({ fetcher, runId: argValue(args, '--run') || '' });
979
- emit(io, formatRunStatus(result.run), result);
980
- return 0;
463
+ const { runStoppable, runStatus } = await workflows();
464
+ const outcome = await runStoppable(() => runStatus({ fetcher, runId: argValue(args, '--run') || '' }, true));
465
+ return finishStoppable(io, outcome, result => emit(io, formatRunStatus(result.run), result));
981
466
  }
982
467
  if (subcommand === 'tail') {
983
- await tailWorkbenchRun({
468
+ const { runStoppable, tail } = await workflows();
469
+ const outcome = await runStoppable(() => tail({
984
470
  fetcher,
985
471
  runId: argValue(args, '--run') || '',
986
472
  after: numberArg(args, '--after'),
@@ -988,19 +474,19 @@ async function dispatchWorkbenchCommand(args, io) {
988
474
  once: hasFlag(args, '--once'),
989
475
  sleep: io.sleep,
990
476
  onEvent: event => io.print(io.json ? JSON.stringify(event) : formatEventLine(event)),
991
- });
992
- return 0;
477
+ }, true));
478
+ return finishStoppable(io, outcome, () => { });
993
479
  }
994
480
  const status = terminalStatusForCommand(subcommand || '');
995
481
  if (status) {
996
- const result = await runWorkbenchRunTerminal({
482
+ const { runStoppable, runTerminal } = await workflows();
483
+ const outcome = await runStoppable(settled => runTerminal({
997
484
  fetcher,
998
485
  runId: argValue(args, '--run') || '',
999
486
  status,
1000
487
  summary: argValue(args, '--summary'),
1001
- });
1002
- emit(io, formatRunStatus(result.run), result);
1003
- return 0;
488
+ }, settled));
489
+ return finishStoppable(io, outcome, result => emit(io, formatRunStatus(result.run), result));
1004
490
  }
1005
491
  }
1006
492
  return emitJsonError(io, USAGE_LINE);