orizu 0.5.13 → 0.5.15
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/dist/github-setup.js +60 -6
- package/dist/help.js +15 -3
- package/dist/hosted-boot.js +22 -4
- package/dist/hosted-harness-opencode.js +144 -1
- package/dist/hosted-loop-lifecycle.js +20 -0
- package/dist/hosted-loop.js +96 -6
- package/dist/hosted-run-event-sink.js +91 -18
- package/dist/hosted-session-cli.js +133 -11
- package/dist/index.js +20 -7
- package/dist/merge-job.js +26 -3
- package/dist/option-validation.js +54 -0
- package/dist/workbench-cli.js +31 -5
- package/package.json +1 -1
- package/src/github-setup.ts +66 -7
- package/src/help.ts +15 -3
- package/src/hosted-boot.ts +26 -4
- package/src/hosted-harness-opencode.ts +204 -1
- package/src/hosted-loop-lifecycle.ts +21 -0
- package/src/hosted-loop.ts +116 -3
- package/src/hosted-run-event-sink.ts +89 -22
- package/src/hosted-session-cli.ts +144 -11
- package/src/index.ts +22 -6
- package/src/merge-job.ts +25 -6
- package/src/option-validation.ts +63 -0
- package/src/workbench-cli.ts +34 -5
- package/vendor/skills/orizu-cli/SKILL.md +2 -2
|
@@ -115,6 +115,26 @@ export class CriticalDeliveryError extends Error {
|
|
|
115
115
|
this.name = 'CriticalDeliveryError';
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Raised when `finish()` itself is undeliverable (ALI-1065 finding 2): a
|
|
120
|
+
* CriticalDeliveryError thrown WHILE DELIVERING A TERMINAL TRANSITION, carrying
|
|
121
|
+
* the intended status + finish options so the caller can retry the delivery
|
|
122
|
+
* with the ORIGINAL outcome. Before this, the error surfaced as a bare
|
|
123
|
+
* CriticalDeliveryError and the loop's generic catch re-finished the run
|
|
124
|
+
* 'failed' — a ~1s transport blip on the SUCCESS terminal recorded a succeeded
|
|
125
|
+
* run as failed. Subclasses CriticalDeliveryError so existing classification
|
|
126
|
+
* (drop-vs-surface) is unchanged.
|
|
127
|
+
*/
|
|
128
|
+
export class TerminalDeliveryError extends CriticalDeliveryError {
|
|
129
|
+
intendedStatus;
|
|
130
|
+
finishOptions;
|
|
131
|
+
constructor(message, intendedStatus, finishOptions) {
|
|
132
|
+
super(message);
|
|
133
|
+
this.name = 'TerminalDeliveryError';
|
|
134
|
+
this.intendedStatus = intendedStatus;
|
|
135
|
+
this.finishOptions = finishOptions;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
118
138
|
/**
|
|
119
139
|
* Raised on a 403 from the RunAPI. Named for what a 403 actually is — NOT "the
|
|
120
140
|
* run is terminal" (that is 404/410). A 403 means the caller may no longer write
|
|
@@ -423,13 +443,28 @@ export function createRunEventSink(options) {
|
|
|
423
443
|
diag(`finish(${status}) called on an already-sealed sink — ignored (idempotent)`);
|
|
424
444
|
return;
|
|
425
445
|
}
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
446
|
+
// ALI-1065 finding 2: a delivery failure ANYWHERE inside finish (the
|
|
447
|
+
// transcript tail is a critical append; the terminal PATCH is the
|
|
448
|
+
// transition itself) must carry the INTENDED status out to the caller.
|
|
449
|
+
// Without this, the loop's generic catch converted an undeliverable
|
|
450
|
+
// SUCCESS terminal into a recorded 'failed'. RunTerminalError/RunAuthError
|
|
451
|
+
// pass through unchanged — they are classifications, not delivery
|
|
452
|
+
// failures.
|
|
453
|
+
try {
|
|
454
|
+
// Pre-terminal appends (still writable): flush coalesced tokens, then the
|
|
455
|
+
// bounded transcript tail (the seal below refuses any later append).
|
|
456
|
+
await flushTokens();
|
|
457
|
+
if (opts?.transcript) {
|
|
458
|
+
await postEvent(AGENT_TRANSCRIPT_EVENT_TYPE, { text: tail(opts.transcript, TRANSCRIPT_TAIL_MAX_CHARS), truncated: opts.transcript.length > TRANSCRIPT_TAIL_MAX_CHARS }, true);
|
|
459
|
+
}
|
|
460
|
+
await patchTerminal(status, opts);
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
if (error instanceof CriticalDeliveryError && !(error instanceof TerminalDeliveryError)) {
|
|
464
|
+
throw new TerminalDeliveryError(error.message, status, opts);
|
|
465
|
+
}
|
|
466
|
+
throw error;
|
|
431
467
|
}
|
|
432
|
-
await patchTerminal(status, opts);
|
|
433
468
|
}
|
|
434
469
|
return {
|
|
435
470
|
append,
|
|
@@ -513,6 +548,42 @@ export async function drainHarnessToSink(events, sink, opts = {}) {
|
|
|
513
548
|
if (opts.beforeFinish)
|
|
514
549
|
await opts.beforeFinish();
|
|
515
550
|
};
|
|
551
|
+
// ALI-1065 finding 3: the server now rejects appends to a terminal run with
|
|
552
|
+
// 410 (the run was cancelled/finished out from under this writer). The sink
|
|
553
|
+
// seals itself and raises RunTerminalError; the drain must treat that as a
|
|
554
|
+
// clean server-side termination — stop consuming and report 'cancelled' —
|
|
555
|
+
// not crash the loop into a spurious re-finish (Orizu records win; the
|
|
556
|
+
// stored status is whatever the server committed). The hook still runs so
|
|
557
|
+
// partial work is harvested, but with a sealed sink its own appends no-op.
|
|
558
|
+
const handleServerTerminal = async () => {
|
|
559
|
+
await runBeforeFinish();
|
|
560
|
+
return 'cancelled';
|
|
561
|
+
};
|
|
562
|
+
// finish() can ALSO surface RunTerminalError (its pre-terminal token flush /
|
|
563
|
+
// transcript append hits the 410 before the PATCH ever runs) — accept the
|
|
564
|
+
// server's termination the same way instead of crashing the drain. A finish
|
|
565
|
+
// whose PATCH meets an already-terminal run does NOT throw (patchTerminal
|
|
566
|
+
// seals on a terminal 409), so this wrapper changes nothing on that path.
|
|
567
|
+
//
|
|
568
|
+
// SEALED-BEFORE-FINISH: the beforeFinish hook's own best-effort appends
|
|
569
|
+
// (auto-harvest events) swallow errors — including the RunTerminalError of a
|
|
570
|
+
// 410 that SEALED the sink. finish() on a sealed sink is an idempotent no-op,
|
|
571
|
+
// so without this check the drain would report the intended status as
|
|
572
|
+
// delivered when in fact the server terminated the run out from under us.
|
|
573
|
+
const finishAccepting = async (status, finishOpts) => {
|
|
574
|
+
await runBeforeFinish();
|
|
575
|
+
if (sink.sealed)
|
|
576
|
+
return 'cancelled';
|
|
577
|
+
try {
|
|
578
|
+
await sink.finish(status, finishOpts);
|
|
579
|
+
return status;
|
|
580
|
+
}
|
|
581
|
+
catch (error) {
|
|
582
|
+
if (error instanceof RunTerminalError)
|
|
583
|
+
return 'cancelled';
|
|
584
|
+
throw error;
|
|
585
|
+
}
|
|
586
|
+
};
|
|
516
587
|
for await (const event of events) {
|
|
517
588
|
if (event.kind === 'execution_complete') {
|
|
518
589
|
// A prompt the caller/agent stopped is a cancellation, not a failure: the
|
|
@@ -520,40 +591,42 @@ export async function drainHarnessToSink(events, sink, opts = {}) {
|
|
|
520
591
|
// and stop()-initiated paths. A genuine failure (success:false, no abort
|
|
521
592
|
// flag) still lands 'failed'.
|
|
522
593
|
if (event.payload.aborted === true) {
|
|
523
|
-
|
|
524
|
-
await sink.finish('cancelled', {
|
|
594
|
+
return finishAccepting('cancelled', {
|
|
525
595
|
summary: opts.summary,
|
|
526
596
|
evidence: opts.evidence,
|
|
527
597
|
transcript: opts.transcript,
|
|
528
598
|
});
|
|
529
|
-
return 'cancelled';
|
|
530
599
|
}
|
|
531
600
|
const success = event.payload.success !== false;
|
|
532
601
|
const status = success ? 'succeeded' : 'failed';
|
|
533
602
|
const summary = success
|
|
534
603
|
? opts.summary
|
|
535
604
|
: { ...(opts.summary ?? {}), error: event.payload.error ?? 'execution failed' };
|
|
536
|
-
|
|
537
|
-
await sink.finish(status, { summary, evidence: opts.evidence, transcript: opts.transcript });
|
|
538
|
-
return status;
|
|
605
|
+
return finishAccepting(status, { summary, evidence: opts.evidence, transcript: opts.transcript });
|
|
539
606
|
}
|
|
540
607
|
if (event.kind === 'error') {
|
|
541
|
-
|
|
542
|
-
await sink.finish('failed', {
|
|
608
|
+
return finishAccepting('failed', {
|
|
543
609
|
summary: { ...(opts.summary ?? {}), error: event.payload.error ?? 'agent error' },
|
|
544
610
|
evidence: opts.evidence,
|
|
545
611
|
transcript: opts.transcript,
|
|
546
612
|
});
|
|
547
|
-
return 'failed';
|
|
548
613
|
}
|
|
549
|
-
|
|
614
|
+
try {
|
|
615
|
+
await sink.append(event);
|
|
616
|
+
}
|
|
617
|
+
catch (error) {
|
|
618
|
+
if (error instanceof RunTerminalError)
|
|
619
|
+
return handleServerTerminal();
|
|
620
|
+
throw error;
|
|
621
|
+
}
|
|
550
622
|
}
|
|
551
623
|
// The harness contract guarantees a terminal event; reaching here means the
|
|
552
624
|
// stream ended abnormally. Fail closed so the run never dangles non-terminal.
|
|
553
625
|
await runBeforeFinish();
|
|
554
|
-
|
|
626
|
+
if (sink.sealed)
|
|
627
|
+
return 'cancelled';
|
|
628
|
+
return finishAccepting('failed', {
|
|
555
629
|
summary: { ...(opts.summary ?? {}), error: 'harness stream ended without a terminal event' },
|
|
556
630
|
transcript: opts.transcript,
|
|
557
631
|
});
|
|
558
|
-
return 'failed';
|
|
559
632
|
}
|
|
@@ -34,21 +34,25 @@
|
|
|
34
34
|
* is injectable, so the whole flow runs in-process against fakes in the
|
|
35
35
|
* local-sim end-to-end test.
|
|
36
36
|
*/
|
|
37
|
+
import { readFileSync } from 'fs';
|
|
37
38
|
import { createLocalSimProvider, } from './sandbox-provider.js';
|
|
38
39
|
import { bootstrapHostedSandbox, createBootstrapRunEventSink, } from './hosted-bootstrap.js';
|
|
39
40
|
import { resumeRunEventSink } from './hosted-run-event-sink.js';
|
|
40
41
|
import { AGENT_GIT_IDENTITY, BEARER_BASENAME } from './hosted-runtime-assets.js';
|
|
41
42
|
import { createVercelProvider } from './vercel-sandbox-provider.js';
|
|
42
43
|
import { buildEgressPolicy, DEFAULT_EGRESS_CANARY_HOST } from './egress-policy.js';
|
|
43
|
-
import { hostedLoopCommand } from './hosted-loop.js';
|
|
44
|
+
import { DEFAULT_HOSTED_MODEL, hostedLoopCommand } from './hosted-loop.js';
|
|
44
45
|
import { hostedBootCommand } from './hosted-boot.js';
|
|
45
46
|
import { mergeJobCommand } from './merge-job.js';
|
|
46
47
|
import { authedFetch } from './http.js';
|
|
47
48
|
import { resolveBaseUrl } from './http.js';
|
|
49
|
+
import { findUnknownOption } from './option-validation.js';
|
|
48
50
|
import { getWorkspaceRoot } from './workspace.js';
|
|
49
51
|
import { attachedWorkspaceId } from './workspace-sync.js';
|
|
50
52
|
import { tailWorkbenchRun } from './workbench-cli.js';
|
|
51
|
-
|
|
53
|
+
// Default hosted model — SINGLE source of truth in hosted-loop-lifecycle.ts
|
|
54
|
+
// (ALI-1086); re-exported here so existing importers keep working.
|
|
55
|
+
export { DEFAULT_HOSTED_MODEL };
|
|
52
56
|
export const DEFAULT_DURATION_MINUTES = 60;
|
|
53
57
|
export const MAX_DURATION_MINUTES = 24 * 60;
|
|
54
58
|
export const DEFAULT_ROTATION_INTERVAL_MS = 55 * 60 * 1000;
|
|
@@ -60,6 +64,13 @@ export const DEFAULT_AGENT_TOKEN_TTL_MINUTES = 60;
|
|
|
60
64
|
* before declaring the detached launch a failure. */
|
|
61
65
|
export const DEFAULT_READINESS_TIMEOUT_MS = 30 * 1000;
|
|
62
66
|
export const DEFAULT_READINESS_POLL_MS = 500;
|
|
67
|
+
/** Must match the coordinator Worker's session-start request ceiling. */
|
|
68
|
+
export const MAX_HOSTED_START_BODY_BYTES = 128 * 1024;
|
|
69
|
+
/** `workspace_sessions.id` and `workbench_runs.id` are PostgreSQL UUIDs. The
|
|
70
|
+
* server creates both before its coordinator poke, and the Worker injects the
|
|
71
|
+
* session id into the final DO body. A representative UUID makes the local
|
|
72
|
+
* projection byte-exact without creating either server-side row. */
|
|
73
|
+
const GENERATED_HOSTED_UUID_PLACEHOLDER = '00000000-0000-4000-8000-000000000000';
|
|
63
74
|
/** Non-secret placeholder OpenCode uses to form requests; the firewall proxy
|
|
64
75
|
* overrides the real Anthropic header (model-key brokering). NEVER a real key. */
|
|
65
76
|
export const ANTHROPIC_DUMMY_KEY = 'sk-ant-orizu-proxy-broker-placeholder';
|
|
@@ -588,6 +599,10 @@ export async function startHostedSession(opts) {
|
|
|
588
599
|
return base;
|
|
589
600
|
}
|
|
590
601
|
function argVal(args, flag) {
|
|
602
|
+
const inlinePrefix = `${flag}=`;
|
|
603
|
+
const inlineArg = args.find(arg => arg.startsWith(inlinePrefix));
|
|
604
|
+
if (inlineArg)
|
|
605
|
+
return inlineArg.slice(inlinePrefix.length);
|
|
591
606
|
const index = args.indexOf(flag);
|
|
592
607
|
if (index === -1 || index + 1 >= args.length)
|
|
593
608
|
return null;
|
|
@@ -595,7 +610,7 @@ function argVal(args, flag) {
|
|
|
595
610
|
return value.startsWith('--') ? null : value;
|
|
596
611
|
}
|
|
597
612
|
function hasFlag(args, flag) {
|
|
598
|
-
return args.
|
|
613
|
+
return args.some(arg => arg === flag || arg.startsWith(`${flag}=`));
|
|
599
614
|
}
|
|
600
615
|
function numFlag(args, flag) {
|
|
601
616
|
const raw = argVal(args, flag);
|
|
@@ -604,7 +619,7 @@ function numFlag(args, flag) {
|
|
|
604
619
|
const parsed = Number(raw);
|
|
605
620
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
606
621
|
}
|
|
607
|
-
const HOSTED_USAGE = 'Usage: orizu session start --hosted --task "<prompt>" [--duration <min> (default 60, max 1440)] ' +
|
|
622
|
+
const HOSTED_USAGE = 'Usage: orizu session start --hosted (--task "<prompt>" | --task-file <path>) [--duration <min> (default 60, max 1440)] ' +
|
|
608
623
|
'[--model <provider/model>] [--reasoning-effort <level>] [--project <team/project>] ' +
|
|
609
624
|
'[--title <title>] [--tail]\n' +
|
|
610
625
|
'Default: the Orizu server provisions the sandbox (no VERCEL_* or model key needed on your machine); ' +
|
|
@@ -614,6 +629,40 @@ const HOSTED_USAGE = 'Usage: orizu session start --hosted --task "<prompt>" [--d
|
|
|
614
629
|
'Removal is planned after DO-coordinator validation (ALI-1055).\n' +
|
|
615
630
|
'Note (v0, --operator only): if you disconnect, the agent token expires within its TTL (<=60m) and the run ' +
|
|
616
631
|
'finishes cleanly; reconnect with `orizu run tail --run <id>`. The sandbox timeout is independent (up to 24h).';
|
|
632
|
+
const HOSTED_START_OPTIONS = new Set([
|
|
633
|
+
'--hosted',
|
|
634
|
+
'--task',
|
|
635
|
+
'--task-file',
|
|
636
|
+
'--workspace',
|
|
637
|
+
'--duration',
|
|
638
|
+
'--model',
|
|
639
|
+
'--reasoning-effort',
|
|
640
|
+
'--project',
|
|
641
|
+
'--title',
|
|
642
|
+
'--tail',
|
|
643
|
+
'--operator',
|
|
644
|
+
'--provider',
|
|
645
|
+
'--runtime',
|
|
646
|
+
'--image',
|
|
647
|
+
'--snapshot',
|
|
648
|
+
'--model-key',
|
|
649
|
+
'--json',
|
|
650
|
+
]);
|
|
651
|
+
const HOSTED_START_VALUE_OPTIONS = new Set([
|
|
652
|
+
'--task',
|
|
653
|
+
'--task-file',
|
|
654
|
+
'--workspace',
|
|
655
|
+
'--duration',
|
|
656
|
+
'--model',
|
|
657
|
+
'--reasoning-effort',
|
|
658
|
+
'--project',
|
|
659
|
+
'--title',
|
|
660
|
+
'--provider',
|
|
661
|
+
'--runtime',
|
|
662
|
+
'--image',
|
|
663
|
+
'--snapshot',
|
|
664
|
+
'--model-key',
|
|
665
|
+
]);
|
|
617
666
|
/** Printed on EVERY `--operator` use (founder-locked: deprecate NOW, remove
|
|
618
667
|
* after Phase 6 validation). Loud on purpose. */
|
|
619
668
|
export const OPERATOR_DEPRECATION_WARNING = '! DEPRECATED: --operator (operator-provisioned hosted sessions) is deprecated; ' +
|
|
@@ -625,21 +674,65 @@ export const OPERATOR_DEPRECATION_WARNING = '! DEPRECATED: --operator (operator-
|
|
|
625
674
|
* CLI never reads VERCEL_* or a model key on this path — the server holds them.
|
|
626
675
|
*/
|
|
627
676
|
export async function startHostedViaServer(args, workspaceId, task, tail, io, fetcher = (path, init) => authedFetch(path, init)) {
|
|
677
|
+
const durationMinutes = numFlag(args, '--duration');
|
|
678
|
+
const model = argVal(args, '--model') ?? undefined;
|
|
679
|
+
const reasoningEffort = argVal(args, '--reasoning-effort') ?? undefined;
|
|
680
|
+
const projectSlug = argVal(args, '--project') ?? undefined;
|
|
681
|
+
const title = argVal(args, '--title') ?? undefined;
|
|
628
682
|
const payload = {
|
|
629
683
|
workspaceId,
|
|
630
684
|
task,
|
|
631
|
-
durationMinutes
|
|
632
|
-
model
|
|
633
|
-
reasoningEffort
|
|
634
|
-
projectSlug
|
|
635
|
-
title
|
|
685
|
+
durationMinutes,
|
|
686
|
+
model,
|
|
687
|
+
reasoningEffort,
|
|
688
|
+
projectSlug,
|
|
689
|
+
title,
|
|
636
690
|
};
|
|
691
|
+
const serializedPayload = JSON.stringify(payload);
|
|
692
|
+
const payloadBytes = new TextEncoder().encode(serializedPayload).byteLength;
|
|
693
|
+
if (payloadBytes > MAX_HOSTED_START_BODY_BYTES) {
|
|
694
|
+
const message = `Hosted task prompt is too large: request body is ${payloadBytes} bytes; ` +
|
|
695
|
+
`maximum is ${MAX_HOSTED_START_BODY_BYTES} bytes (128 KiB).`;
|
|
696
|
+
if (io.json)
|
|
697
|
+
io.print(JSON.stringify({ ok: false, error: message }));
|
|
698
|
+
else
|
|
699
|
+
io.printErr?.(message);
|
|
700
|
+
return 1;
|
|
701
|
+
}
|
|
702
|
+
// Mirror the exact JSON shape that reaches the session Durable Object after
|
|
703
|
+
// the API creates its UUID run/session rows, normalizes optional strings, and
|
|
704
|
+
// the Worker injects sessionId. This second guard keeps near-boundary prompts
|
|
705
|
+
// from triggering server-side state that would only be compensated after a
|
|
706
|
+
// coordinator 413. Keep this field list in sync with pokeCoordinatorStart in
|
|
707
|
+
// app/api/cli/hosted-sessions/route.ts.
|
|
708
|
+
const normalizedModel = model?.trim() || undefined;
|
|
709
|
+
const normalizedReasoningEffort = reasoningEffort?.trim() || undefined;
|
|
710
|
+
const coordinatorPayload = JSON.stringify({
|
|
711
|
+
runId: GENERATED_HOSTED_UUID_PLACEHOLDER,
|
|
712
|
+
task,
|
|
713
|
+
durationMinutes: durationMinutes !== undefined && durationMinutes > 0
|
|
714
|
+
? durationMinutes
|
|
715
|
+
: undefined,
|
|
716
|
+
model: normalizedModel,
|
|
717
|
+
reasoningEffort: normalizedReasoningEffort,
|
|
718
|
+
sessionId: GENERATED_HOSTED_UUID_PLACEHOLDER,
|
|
719
|
+
});
|
|
720
|
+
const coordinatorPayloadBytes = new TextEncoder().encode(coordinatorPayload).byteLength;
|
|
721
|
+
if (coordinatorPayloadBytes > MAX_HOSTED_START_BODY_BYTES) {
|
|
722
|
+
const message = `Hosted task prompt is too large: coordinator request body would be ${coordinatorPayloadBytes} bytes ` +
|
|
723
|
+
`after generated run/session IDs; maximum is ${MAX_HOSTED_START_BODY_BYTES} bytes (128 KiB).`;
|
|
724
|
+
if (io.json)
|
|
725
|
+
io.print(JSON.stringify({ ok: false, error: message }));
|
|
726
|
+
else
|
|
727
|
+
io.printErr?.(message);
|
|
728
|
+
return 1;
|
|
729
|
+
}
|
|
637
730
|
let response;
|
|
638
731
|
try {
|
|
639
732
|
response = await fetcher('/api/cli/hosted-sessions', {
|
|
640
733
|
method: 'POST',
|
|
641
734
|
headers: { 'Content-Type': 'application/json' },
|
|
642
|
-
body:
|
|
735
|
+
body: serializedPayload,
|
|
643
736
|
});
|
|
644
737
|
}
|
|
645
738
|
catch (error) {
|
|
@@ -707,7 +800,36 @@ export async function hostedCommand(args, io, deps = {}) {
|
|
|
707
800
|
io.printErr?.('Usage: orizu internal <hosted-loop --context <path> | hosted-boot | merge-job>');
|
|
708
801
|
return 1;
|
|
709
802
|
}
|
|
710
|
-
const
|
|
803
|
+
const unknownOption = findUnknownOption(args, HOSTED_START_OPTIONS, HOSTED_START_VALUE_OPTIONS);
|
|
804
|
+
if (unknownOption) {
|
|
805
|
+
const followsTaskFlag = args.some((arg, index) => arg === '--task' &&
|
|
806
|
+
(args[index + 1] === unknownOption || args[index + 1]?.startsWith(`${unknownOption}=`)));
|
|
807
|
+
const taskValueHint = followsTaskFlag
|
|
808
|
+
? '\nTask text beginning with a dash must use --task=<prompt> or --task-file <path>.'
|
|
809
|
+
: '';
|
|
810
|
+
io.printErr?.(`unknown option ${unknownOption}${taskValueHint}\n${HOSTED_USAGE}`);
|
|
811
|
+
return 1;
|
|
812
|
+
}
|
|
813
|
+
const inlineTask = argVal(args, '--task');
|
|
814
|
+
const taskFile = argVal(args, '--task-file');
|
|
815
|
+
if (hasFlag(args, '--task') && hasFlag(args, '--task-file')) {
|
|
816
|
+
io.printErr?.(`Use either --task or --task-file, not both.\n${HOSTED_USAGE}`);
|
|
817
|
+
return 1;
|
|
818
|
+
}
|
|
819
|
+
let task = inlineTask;
|
|
820
|
+
if (taskFile) {
|
|
821
|
+
try {
|
|
822
|
+
task = readFileSync(taskFile, 'utf8');
|
|
823
|
+
}
|
|
824
|
+
catch (error) {
|
|
825
|
+
io.printErr?.(`Could not read task file '${taskFile}': ${error instanceof Error ? error.message : String(error)}`);
|
|
826
|
+
return 1;
|
|
827
|
+
}
|
|
828
|
+
if (task.trim().length === 0) {
|
|
829
|
+
io.printErr?.(`Task file '${taskFile}' is empty or contains only whitespace. Add a task prompt and try again.`);
|
|
830
|
+
return 1;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
711
833
|
if (!task) {
|
|
712
834
|
io.printErr?.(HOSTED_USAGE);
|
|
713
835
|
return 1;
|
package/dist/index.js
CHANGED
|
@@ -2718,12 +2718,25 @@ async function resolveSetupTeam(teamSlugArg, noInput, dryRun) {
|
|
|
2718
2718
|
}
|
|
2719
2719
|
return createTeamOnServer(name);
|
|
2720
2720
|
}
|
|
2721
|
-
async function resolveSetupProjects(team) {
|
|
2721
|
+
async function resolveSetupProjects(team, noInput, dryRun) {
|
|
2722
2722
|
const projects = await fetchProjects(team.slug);
|
|
2723
|
-
if (projects.length
|
|
2724
|
-
|
|
2723
|
+
if (projects.length > 0) {
|
|
2724
|
+
return projects;
|
|
2725
|
+
}
|
|
2726
|
+
// ALI-1140: a just-created team (or a pre-existing empty one) has no projects
|
|
2727
|
+
// by definition — continue the wizard into creating the first project instead
|
|
2728
|
+
// of dead-ending on the path setup itself offered.
|
|
2729
|
+
if (noInput || dryRun) {
|
|
2730
|
+
throw new Error(`Team '${team.slug}' has no projects. Create one with \`orizu projects create --name <name> --team ${team.slug}\`, then re-run setup.`);
|
|
2725
2731
|
}
|
|
2726
|
-
|
|
2732
|
+
printLine(` Team '${sanitizeTerminalText(team.slug)}' has no projects yet. Let's create the first one.`);
|
|
2733
|
+
const name = await askText(' Project name?', '');
|
|
2734
|
+
if (!name) {
|
|
2735
|
+
throw new Error('Project name is required to create a project.');
|
|
2736
|
+
}
|
|
2737
|
+
const project = await createProjectOnServer(team.slug, name);
|
|
2738
|
+
printLine(` Created project ${sanitizeTerminalText(`${project.teamSlug}/${project.slug}`)}`);
|
|
2739
|
+
return [project];
|
|
2727
2740
|
}
|
|
2728
2741
|
function projectSeedsFromProjects(projects) {
|
|
2729
2742
|
return projects.map(project => ({
|
|
@@ -2848,7 +2861,7 @@ async function setupCommand() {
|
|
|
2848
2861
|
let projects;
|
|
2849
2862
|
if (auth.state === 'signed-in' && !validateOnly) {
|
|
2850
2863
|
const team = await resolveSetupTeam(teamSlug, noInput, dryRun);
|
|
2851
|
-
const serverProjects = await resolveSetupProjects(team);
|
|
2864
|
+
const serverProjects = await resolveSetupProjects(team, noInput, dryRun);
|
|
2852
2865
|
teamSlug = team.slug;
|
|
2853
2866
|
teamId = team.id;
|
|
2854
2867
|
projects = projectSeedsFromProjects(serverProjects);
|
|
@@ -2866,10 +2879,10 @@ async function setupCommand() {
|
|
|
2866
2879
|
hostedAttached = (await runInteractiveHostedSetup({ teamSlug: team.slug, targetDir: workspaceRoot, local: hasArg('--local'), activeInstallation: link.status === 'active' }, { print: printLine, fetcher: authedFetch })).attached;
|
|
2867
2880
|
}
|
|
2868
2881
|
catch (error) {
|
|
2869
|
-
throw new Error(`Hosted setup failed: ${getErrorMessage(error)}
|
|
2882
|
+
throw new Error(`Hosted setup failed: ${getErrorMessage(error)} Re-running \`orizu setup\` is safe — provisioning adopts the existing workbench repo and resumes where it left off (or pass --local to scaffold a local-only workspace).`);
|
|
2870
2883
|
}
|
|
2871
2884
|
}
|
|
2872
|
-
}
|
|
2885
|
+
} // hint must not blame local state (ALI-1143): provisioning failures are server-side, and both provisioning (adopt) and the clone step (skip-if-cloned, ALI-1069) are idempotent on re-run
|
|
2873
2886
|
}
|
|
2874
2887
|
else if (!teamSlug && !noInput && !validateOnly) {
|
|
2875
2888
|
teamSlug = await askText(' Team slug?', 'local-team');
|
package/dist/merge-job.js
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
* (plus the shape-pattern net). Nothing here ever logs a secret.
|
|
40
40
|
*/
|
|
41
41
|
import { mergeBranchWithDeployKey, } from './deploy-key-merge-core.js';
|
|
42
|
+
import { assertSecureTokenTransport } from './http.js';
|
|
42
43
|
import { redactSecrets } from './secret-redaction.js';
|
|
43
44
|
// -- Frozen env contract --------------------------------------------------------
|
|
44
45
|
export const REQUIRED_MERGE_JOB_ENV_VARS = [
|
|
@@ -60,7 +61,27 @@ export function resolveMergeJobEnv(env) {
|
|
|
60
61
|
const coordinatorUrl = req('ORIZU_COORDINATOR_URL');
|
|
61
62
|
const baseUrl = req('ORIZU_BASE_URL');
|
|
62
63
|
if (missing.length > 0)
|
|
63
|
-
return { ok: false, missing };
|
|
64
|
+
return { ok: false, missing, invalid: [] };
|
|
65
|
+
const origins = [
|
|
66
|
+
['ORIZU_COORDINATOR_URL', coordinatorUrl],
|
|
67
|
+
['ORIZU_BASE_URL', baseUrl],
|
|
68
|
+
];
|
|
69
|
+
const invalid = origins.flatMap(([name, value]) => {
|
|
70
|
+
try {
|
|
71
|
+
const parsed = new URL(value);
|
|
72
|
+
// Userinfo is never accepted, including on loopback: rejecting it at the
|
|
73
|
+
// env boundary keeps configured credentials out of every retry/error path.
|
|
74
|
+
if (parsed.username || parsed.password)
|
|
75
|
+
return [name];
|
|
76
|
+
assertSecureTokenTransport(value);
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return [name];
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
if (invalid.length > 0)
|
|
84
|
+
return { ok: false, missing: [], invalid };
|
|
64
85
|
return {
|
|
65
86
|
ok: true,
|
|
66
87
|
value: {
|
|
@@ -312,8 +333,10 @@ export async function runMergeJob(opts) {
|
|
|
312
333
|
export async function mergeJobCommand(io) {
|
|
313
334
|
const resolved = resolveMergeJobEnv(process.env);
|
|
314
335
|
if (!resolved.ok) {
|
|
315
|
-
|
|
316
|
-
|
|
336
|
+
const detail = resolved.missing.length > 0
|
|
337
|
+
? `missing required env: ${resolved.missing.join(', ')}`
|
|
338
|
+
: `invalid or insecure origin env: ${resolved.invalid.join(', ')}`;
|
|
339
|
+
io.printErr?.(`merge-job: ${detail} (the MergeJobCoordinator sandbox env contract)`);
|
|
317
340
|
return 1;
|
|
318
341
|
}
|
|
319
342
|
const log = (line) => io.printErr?.(`[merge-job] ${line}`);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
function optionName(arg) {
|
|
2
|
+
const equalsIndex = arg.indexOf('=');
|
|
3
|
+
return equalsIndex === -1 ? arg : arg.slice(0, equalsIndex);
|
|
4
|
+
}
|
|
5
|
+
function isNegativeNumericValue(arg) {
|
|
6
|
+
return arg.length > 1 && !arg.startsWith('--') && Number.isFinite(Number(arg));
|
|
7
|
+
}
|
|
8
|
+
function isUnambiguouslySingleDashValue(arg) {
|
|
9
|
+
// A short option cannot contain whitespace immediately after its dash. This
|
|
10
|
+
// keeps Markdown-list prompts such as `--task "- fix the test"` available
|
|
11
|
+
// without letting a misspelled option such as `--task -taks` get swallowed.
|
|
12
|
+
return /^-\s/.test(arg) || isNegativeNumericValue(arg);
|
|
13
|
+
}
|
|
14
|
+
function followsValueOption(args, index, valueOptions) {
|
|
15
|
+
if (index === 0)
|
|
16
|
+
return false;
|
|
17
|
+
const previous = args[index - 1];
|
|
18
|
+
return previous.startsWith('--') && !previous.includes('=') && valueOptions.has(previous);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Return the first dash-prefixed option that is not in a command's explicit
|
|
22
|
+
* allowlist. An unambiguously value-like single-dash token immediately
|
|
23
|
+
* following a value-taking option is data: this keeps inputs such as
|
|
24
|
+
* `--duration -5` and `--task "- fix the test"` available without allowing a
|
|
25
|
+
* stray token or mistyped short option.
|
|
26
|
+
*
|
|
27
|
+
* `valueOptions` permits the unambiguous `--option=value` form. A separate
|
|
28
|
+
* token beginning with `--`, or an option-like single-dash word, is parsed as
|
|
29
|
+
* an option so a missing value cannot swallow a typo. Callers whose value is
|
|
30
|
+
* itself option-like can use the inline form; Markdown list markers and finite
|
|
31
|
+
* negative numbers remain safe as separate values.
|
|
32
|
+
*/
|
|
33
|
+
export function findUnknownOption(args, allowedOptions, valueOptions = new Set()) {
|
|
34
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
35
|
+
const arg = args[index];
|
|
36
|
+
if (!arg.startsWith('-') || arg === '-')
|
|
37
|
+
continue;
|
|
38
|
+
if (!arg.startsWith('--')) {
|
|
39
|
+
if (isUnambiguouslySingleDashValue(arg) && followsValueOption(args, index, valueOptions))
|
|
40
|
+
continue;
|
|
41
|
+
return arg;
|
|
42
|
+
}
|
|
43
|
+
const name = optionName(arg);
|
|
44
|
+
if (!allowedOptions.has(name))
|
|
45
|
+
return name;
|
|
46
|
+
const hasInlineValue = arg.length > name.length;
|
|
47
|
+
if (hasInlineValue) {
|
|
48
|
+
if (!valueOptions.has(name))
|
|
49
|
+
return arg;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
package/dist/workbench-cli.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { spawnSync } from 'child_process';
|
|
9
9
|
import { authedFetch } from './http.js';
|
|
10
|
+
import { findUnknownOption } from './option-validation.js';
|
|
10
11
|
import { getWorkspaceRoot, workspaceExists } from './workspace.js';
|
|
11
12
|
import { attachedWorkspaceId, stringOrNull } from './workspace-sync.js';
|
|
12
13
|
function defaultSessionGit(args, opts) {
|
|
@@ -41,6 +42,19 @@ const DEFAULT_TAIL_INTERVAL_SECONDS = 2;
|
|
|
41
42
|
// hot-spin against the API while a non-terminal run is quiet. Draining full
|
|
42
43
|
// pages intentionally skips the sleep — that is catch-up, not polling.
|
|
43
44
|
const MIN_TAIL_INTERVAL_MS = 500;
|
|
45
|
+
const SESSION_START_OPTIONS = new Set(['--project', '--workspace', '--json']);
|
|
46
|
+
const WORKBENCH_VALUE_OPTIONS = new Set([
|
|
47
|
+
'--after',
|
|
48
|
+
'--interval',
|
|
49
|
+
'--message',
|
|
50
|
+
'--project',
|
|
51
|
+
'--run',
|
|
52
|
+
'--session',
|
|
53
|
+
'--status',
|
|
54
|
+
'--summary',
|
|
55
|
+
'--title',
|
|
56
|
+
'--workspace',
|
|
57
|
+
]);
|
|
44
58
|
function defaultSleep(ms) {
|
|
45
59
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
46
60
|
}
|
|
@@ -475,11 +489,19 @@ function hasFlag(args, flag) {
|
|
|
475
489
|
return args.includes(flag);
|
|
476
490
|
}
|
|
477
491
|
function argValue(args, flag) {
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
492
|
+
const inlinePrefix = `${flag}=`;
|
|
493
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
494
|
+
const arg = args[index];
|
|
495
|
+
if (arg.startsWith(inlinePrefix)) {
|
|
496
|
+
return arg.slice(inlinePrefix.length);
|
|
497
|
+
}
|
|
498
|
+
if (arg === flag) {
|
|
499
|
+
return index + 1 < args.length && !args[index + 1].startsWith('--')
|
|
500
|
+
? args[index + 1]
|
|
501
|
+
: null;
|
|
502
|
+
}
|
|
481
503
|
}
|
|
482
|
-
return
|
|
504
|
+
return null;
|
|
483
505
|
}
|
|
484
506
|
function numberArg(args, flag) {
|
|
485
507
|
const raw = argValue(args, flag);
|
|
@@ -494,7 +516,7 @@ function positionalArgs(args) {
|
|
|
494
516
|
for (let i = 0; i < args.length; i += 1) {
|
|
495
517
|
const arg = args[i];
|
|
496
518
|
if (arg.startsWith('--')) {
|
|
497
|
-
if (argValue(args, arg) !== null) {
|
|
519
|
+
if (!arg.includes('=') && WORKBENCH_VALUE_OPTIONS.has(arg) && argValue(args.slice(i), arg) !== null) {
|
|
498
520
|
i += 1;
|
|
499
521
|
}
|
|
500
522
|
continue;
|
|
@@ -700,6 +722,10 @@ async function dispatchWorkbenchCommand(args, io) {
|
|
|
700
722
|
// not run from inside it (defaults to cwd).
|
|
701
723
|
const workspaceDir = argValue(args, '--workspace') ?? undefined;
|
|
702
724
|
if (subcommand === 'start') {
|
|
725
|
+
const unknownOption = findUnknownOption(args, SESSION_START_OPTIONS, WORKBENCH_VALUE_OPTIONS);
|
|
726
|
+
if (unknownOption) {
|
|
727
|
+
return emitJsonError(io, `unknown option ${unknownOption}\nUsage: orizu session start [--project <team/project>] [--workspace <dir>] [--json]`);
|
|
728
|
+
}
|
|
703
729
|
const result = await runSessionStart({
|
|
704
730
|
fetcher,
|
|
705
731
|
cwd: workspaceDir,
|