orizu 0.5.11 → 0.5.13

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.
@@ -0,0 +1,325 @@
1
+ /**
2
+ * `orizu internal merge-job` — the one-shot merge sandbox ENTRYPOINT
3
+ * (ALI-1084, merge-sandbox-job Phase 2). The MergeJobCoordinator DO launches
4
+ * this inside a minimal, AGENT-FREE sandbox booted from the hosted snapshot;
5
+ * from here the sandbox pulls its own credentials under the established
6
+ * boot-secret PULL model and runs the SHARED merge core
7
+ * (deploy-key-merge-core.ts — the exact code the server path runs; no forked
8
+ * merge logic).
9
+ *
10
+ * FLOW (plan §2.2 step 4):
11
+ * a. exchange ORIZU_BOOT_SECRET at the DO for a single-use JOB TOKEN
12
+ * (`GET {coordinator}/merge-jobs/:jobKey/job-token`) — the token transits
13
+ * Cloudflare; the DEPLOY KEY never does;
14
+ * b. pull `{spec, privateKeyPem}` DIRECTLY from the control plane
15
+ * (`GET {orizu}/api/coordinator/merge-jobs/:jobId/payload`, job-token
16
+ * auth) — single-use, audited server-side before the key is returned;
17
+ * c. run the shared merge core (tmp worktree, 0600 key file, pinned GitHub
18
+ * host keys, expected-head verification, argv-asserted --no-ff merge)
19
+ * with the D8 PRE-PUSH GATE wired in: immediately before `git push` the
20
+ * core calls `GET .../gate`; `proceed: false` aborts WITHOUT pushing
21
+ * (terminal `superseded`). An UNREACHABLE gate also refuses the push
22
+ * (fail closed) but reports `failed` (retryable) — "unknown" is not
23
+ * "rejected";
24
+ * d. POST the terminal result to the DO (boot-secret auth). Reporting the
25
+ * terminal state — not the merge outcome — is this command's success:
26
+ * exit 0 on ANY reported terminal (conflict/superseded included), exit 1
27
+ * only when the env is invalid or the report itself failed.
28
+ *
29
+ * FROZEN ENV CONTRACT (the DO's merge-job sandbox env plan):
30
+ * ORIZU_BOOT_SECRET per-sandbox durable bootstrap secret;
31
+ * ORIZU_MERGE_JOB_ID the job's DO addressing key
32
+ * (`merge:<manifestId>:<expectedHeadSha>` — the
33
+ * control-plane job uuid rides the token response);
34
+ * ORIZU_COORDINATOR_URL the coordinator Worker origin;
35
+ * ORIZU_BASE_URL the Orizu control-plane origin.
36
+ *
37
+ * REDACTION: every reported detail passes through `redactSecrets` with the
38
+ * boot secret, the job token, and the private key PEM on the exact-match list
39
+ * (plus the shape-pattern net). Nothing here ever logs a secret.
40
+ */
41
+ import { mergeBranchWithDeployKey, } from './deploy-key-merge-core.js';
42
+ import { redactSecrets } from './secret-redaction.js';
43
+ // -- Frozen env contract --------------------------------------------------------
44
+ export const REQUIRED_MERGE_JOB_ENV_VARS = [
45
+ 'ORIZU_BOOT_SECRET',
46
+ 'ORIZU_MERGE_JOB_ID',
47
+ 'ORIZU_COORDINATOR_URL',
48
+ 'ORIZU_BASE_URL',
49
+ ];
50
+ export function resolveMergeJobEnv(env) {
51
+ const missing = [];
52
+ const req = (name) => {
53
+ const value = env[name]?.trim();
54
+ if (!value)
55
+ missing.push(name);
56
+ return value ?? '';
57
+ };
58
+ const bootSecret = req('ORIZU_BOOT_SECRET');
59
+ const jobKey = req('ORIZU_MERGE_JOB_ID');
60
+ const coordinatorUrl = req('ORIZU_COORDINATOR_URL');
61
+ const baseUrl = req('ORIZU_BASE_URL');
62
+ if (missing.length > 0)
63
+ return { ok: false, missing };
64
+ return {
65
+ ok: true,
66
+ value: {
67
+ bootSecret,
68
+ jobKey,
69
+ coordinatorUrl: coordinatorUrl.replace(/\/+$/, ''),
70
+ baseUrl: baseUrl.replace(/\/+$/, ''),
71
+ },
72
+ };
73
+ }
74
+ /** Map the shared core's outcome to the job's terminal status. */
75
+ export function terminalFromMergeResult(result) {
76
+ if (result.superseded) {
77
+ return {
78
+ status: 'superseded',
79
+ result: null,
80
+ errorDetail: 'pre-push gate refused: manifest no longer appliable',
81
+ };
82
+ }
83
+ if (result.conflict) {
84
+ return {
85
+ status: 'conflict',
86
+ result: { merged: false, sha: null, alreadyMerged: false, conflict: true },
87
+ errorDetail: null,
88
+ };
89
+ }
90
+ return {
91
+ status: 'succeeded',
92
+ result: {
93
+ merged: result.merged,
94
+ sha: result.sha,
95
+ alreadyMerged: result.alreadyMerged,
96
+ conflict: false,
97
+ },
98
+ errorDetail: null,
99
+ };
100
+ }
101
+ export async function exchangeJobToken(opts) {
102
+ const attempts = Math.max(1, opts.attempts ?? 5);
103
+ const baseBackoff = opts.backoffMs ?? 500;
104
+ const sleep = opts.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
105
+ const url = `${opts.coordinatorUrl}/merge-jobs/${encodeURIComponent(opts.jobKey)}/job-token`;
106
+ let lastDetail = 'no attempt made';
107
+ for (let attempt = 0; attempt < attempts; attempt++) {
108
+ try {
109
+ const res = await opts.fetchImpl(url, {
110
+ method: 'GET',
111
+ headers: { Authorization: `Bearer ${opts.bootSecret}` },
112
+ });
113
+ if (res.ok) {
114
+ const data = (await res.json().catch(() => null));
115
+ const token = data && typeof data.token === 'string' ? data.token : '';
116
+ const jobId = data && typeof data.jobId === 'string' ? data.jobId : '';
117
+ if (token && jobId) {
118
+ return { token, jobId };
119
+ }
120
+ lastDetail = 'job-token response carried no token/jobId';
121
+ }
122
+ else {
123
+ lastDetail = `job-token exchange returned ${res.status}`;
124
+ }
125
+ }
126
+ catch (error) {
127
+ lastDetail = error instanceof Error ? error.message : String(error);
128
+ }
129
+ if (attempt < attempts - 1) {
130
+ const delay = baseBackoff * 2 ** attempt;
131
+ opts.log?.(`job-token exchange attempt ${attempt + 1}/${attempts} failed (${lastDetail}); retrying in ${delay}ms`);
132
+ await sleep(delay);
133
+ }
134
+ }
135
+ throw new Error(`job-token exchange failed after ${attempts} attempts: ${lastDetail}`);
136
+ }
137
+ export async function fetchMergeJobPayload(opts) {
138
+ const url = `${opts.baseUrl}/api/coordinator/merge-jobs/${encodeURIComponent(opts.jobId)}/payload`;
139
+ const res = await opts.fetchImpl(url, {
140
+ method: 'GET',
141
+ headers: { Authorization: `Bearer ${opts.token}` },
142
+ });
143
+ if (!res.ok) {
144
+ throw new Error(`payload pull returned ${res.status}`);
145
+ }
146
+ const data = (await res.json().catch(() => null));
147
+ const spec = data?.spec;
148
+ const privateKeyPem = typeof data?.privateKeyPem === 'string' ? data.privateKeyPem : '';
149
+ if (!spec || typeof spec !== 'object' || !privateKeyPem) {
150
+ throw new Error('payload response was missing spec/privateKeyPem');
151
+ }
152
+ if (spec.op !== 'merge') {
153
+ // D9 op discriminator: this entrypoint implements ONLY the merge op.
154
+ throw new Error(`unsupported job op: ${String(spec.op)}`);
155
+ }
156
+ for (const field of ['repoFullName', 'base', 'sourceBranch', 'expectedHeadSha', 'commitMessage']) {
157
+ if (typeof spec[field] !== 'string' || spec[field].length === 0) {
158
+ throw new Error(`payload spec is missing ${field}`);
159
+ }
160
+ }
161
+ return { spec: spec, privateKeyPem };
162
+ }
163
+ export async function checkPrePushGate(opts) {
164
+ const url = `${opts.baseUrl}/api/coordinator/merge-jobs/${encodeURIComponent(opts.jobId)}/gate`;
165
+ try {
166
+ const res = await opts.fetchImpl(url, {
167
+ method: 'GET',
168
+ headers: { Authorization: `Bearer ${opts.token}` },
169
+ });
170
+ if (!res.ok) {
171
+ return 'unavailable';
172
+ }
173
+ const data = (await res.json().catch(() => null));
174
+ if (data && data.proceed === true)
175
+ return 'proceed';
176
+ if (data && data.proceed === false)
177
+ return 'superseded';
178
+ return 'unavailable';
179
+ }
180
+ catch {
181
+ return 'unavailable';
182
+ }
183
+ }
184
+ // -- Step d: report the terminal state to the DO (boot-secret auth) --------------
185
+ export async function postMergeJobResult(opts) {
186
+ const attempts = Math.max(1, opts.attempts ?? 3);
187
+ const sleep = opts.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
188
+ const url = `${opts.coordinatorUrl}/merge-jobs/${encodeURIComponent(opts.jobKey)}/result`;
189
+ for (let attempt = 0; attempt < attempts; attempt++) {
190
+ try {
191
+ const res = await opts.fetchImpl(url, {
192
+ method: 'POST',
193
+ headers: {
194
+ Authorization: `Bearer ${opts.bootSecret}`,
195
+ 'Content-Type': 'application/json',
196
+ },
197
+ body: JSON.stringify({
198
+ status: opts.report.status,
199
+ ...(opts.report.result ? { result: opts.report.result } : {}),
200
+ ...(opts.report.errorDetail ? { errorDetail: opts.report.errorDetail } : {}),
201
+ }),
202
+ });
203
+ if (res.ok) {
204
+ return true;
205
+ }
206
+ opts.log?.(`result report attempt ${attempt + 1}/${attempts} returned ${res.status}`);
207
+ }
208
+ catch (error) {
209
+ opts.log?.(`result report attempt ${attempt + 1}/${attempts} failed: ${error instanceof Error ? error.message : String(error)}`);
210
+ }
211
+ if (attempt < attempts - 1) {
212
+ await sleep(500 * 2 ** attempt);
213
+ }
214
+ }
215
+ return false;
216
+ }
217
+ // -- Orchestration ----------------------------------------------------------------
218
+ const MAX_DETAIL_LENGTH = 1500;
219
+ export async function runMergeJob(opts) {
220
+ const env = opts.env;
221
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
222
+ const log = opts.log ?? (() => { });
223
+ const secrets = [env.bootSecret];
224
+ const redact = (value) => redactSecrets(value, { secrets }).slice(0, MAX_DETAIL_LENGTH);
225
+ let report;
226
+ try {
227
+ // a. boot secret → single-use job token (+ the control-plane job id).
228
+ const exchanged = await exchangeJobToken({
229
+ coordinatorUrl: env.coordinatorUrl,
230
+ jobKey: env.jobKey,
231
+ bootSecret: env.bootSecret,
232
+ fetchImpl,
233
+ attempts: opts.tokenAttempts,
234
+ backoffMs: opts.tokenBackoffMs,
235
+ sleep: opts.sleep,
236
+ log,
237
+ });
238
+ secrets.push(exchanged.token);
239
+ log(`job token exchanged (job ${exchanged.jobId})`);
240
+ // b. pull spec + key directly from the control plane (single-use release).
241
+ const payload = await fetchMergeJobPayload({
242
+ baseUrl: env.baseUrl,
243
+ jobId: exchanged.jobId,
244
+ token: exchanged.token,
245
+ fetchImpl,
246
+ });
247
+ secrets.push(payload.privateKeyPem);
248
+ log(`payload pulled for ${payload.spec.repoFullName} (${payload.spec.sourceBranch} → ${payload.spec.base})`);
249
+ // c. the SHARED merge core with the D8 gate immediately pre-push.
250
+ // Object wrapper: TS cannot track closure writes to a let across the
251
+ // await below and would narrow the literal type at the read site.
252
+ const gate = { outcome: 'proceed' };
253
+ const result = await mergeBranchWithDeployKey(payload.spec.repoFullName, {
254
+ base: payload.spec.base,
255
+ sourceBranch: payload.spec.sourceBranch,
256
+ expectedHeadSha: payload.spec.expectedHeadSha,
257
+ commitMessage: payload.spec.commitMessage,
258
+ privateKeyPem: payload.privateKeyPem,
259
+ }, {
260
+ runGit: opts.runGit,
261
+ prePushGate: async () => {
262
+ gate.outcome = await checkPrePushGate({
263
+ baseUrl: env.baseUrl,
264
+ jobId: exchanged.jobId,
265
+ token: exchanged.token,
266
+ fetchImpl,
267
+ });
268
+ log(`pre-push gate: ${gate.outcome}`);
269
+ // FAIL CLOSED on the push for both 'superseded' and 'unavailable'.
270
+ return gate.outcome === 'proceed';
271
+ },
272
+ });
273
+ if (result.superseded && gate.outcome === 'unavailable') {
274
+ // The gate could not answer — "unknown" is not "rejected". No push
275
+ // happened (fail closed); report a retryable failure, not superseded.
276
+ report = {
277
+ status: 'failed',
278
+ result: null,
279
+ errorDetail: 'pre-push gate unreachable; aborted without pushing',
280
+ };
281
+ }
282
+ else {
283
+ report = terminalFromMergeResult(result);
284
+ }
285
+ }
286
+ catch (error) {
287
+ const message = error instanceof Error ? error.message : String(error);
288
+ report = { status: 'failed', result: null, errorDetail: redact(message) };
289
+ }
290
+ if (report.errorDetail) {
291
+ report = { ...report, errorDetail: redact(report.errorDetail) };
292
+ }
293
+ const reported = await postMergeJobResult({
294
+ coordinatorUrl: env.coordinatorUrl,
295
+ jobKey: env.jobKey,
296
+ bootSecret: env.bootSecret,
297
+ report,
298
+ fetchImpl,
299
+ sleep: opts.sleep,
300
+ log,
301
+ });
302
+ log(`terminal ${report.status} ${reported ? 'reported' : 'REPORT FAILED'}`);
303
+ return { reported, status: report.status, detail: report.errorDetail };
304
+ }
305
+ /**
306
+ * Read the frozen env contract and run the job. Exit 0 on ANY reported
307
+ * terminal outcome (the REPORT, not the merge, is the command's success —
308
+ * a conflict is a correctly-completed job); exit 1 when the env is invalid
309
+ * or the terminal report never reached the DO (the DO's job wall is the
310
+ * backstop then).
311
+ */
312
+ export async function mergeJobCommand(io) {
313
+ const resolved = resolveMergeJobEnv(process.env);
314
+ if (!resolved.ok) {
315
+ io.printErr?.(`merge-job: missing required env: ${resolved.missing.join(', ')} ` +
316
+ '(the MergeJobCoordinator sandbox env contract)');
317
+ return 1;
318
+ }
319
+ const log = (line) => io.printErr?.(`[merge-job] ${line}`);
320
+ const outcome = await runMergeJob({ env: resolved.value, log });
321
+ io.print(io.json
322
+ ? JSON.stringify({ ok: outcome.reported, status: outcome.status, detail: outcome.detail })
323
+ : `merge-job finished: ${outcome.status}${outcome.detail ? ` (${outcome.detail})` : ''}${outcome.reported ? '' : ' [terminal report failed]'}`);
324
+ return outcome.reported ? 0 : 1;
325
+ }
@@ -54,10 +54,10 @@ export const VERCEL_IMAGE_NOT_READY_BACKOFF_MS = 5000;
54
54
  // constant-fold `import(specifier)` into a static dependency on the SDK — the
55
55
  // same discipline as the ALI-1031 server provider.
56
56
  const VERCEL_SDK_SPECIFIER = ['@vercel', 'sandbox'].join('/');
57
- async function loadVercelModule() {
57
+ export async function loadVercelModule(importModule = specifier => import(specifier)) {
58
58
  try {
59
59
  const specifier = VERCEL_SDK_SPECIFIER;
60
- const mod = (await import(specifier));
60
+ const mod = (await importModule(specifier));
61
61
  if (!mod || typeof mod.Sandbox?.create !== 'function') {
62
62
  throw new Error('module did not export a Sandbox class with create()');
63
63
  }
@@ -528,9 +528,82 @@ function formatSessionStart(result) {
528
528
  }
529
529
  return lines.join('\n');
530
530
  }
531
+ // -- Cost/duration rendering (ALI-1089) ---------------------------------------
532
+ function finiteOrNull(value) {
533
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
534
+ }
535
+ function formatUsd(value) {
536
+ return `$${value.toFixed(4)}`;
537
+ }
538
+ /** `tokens: in 2, out 181, cache-read 126968, ...` — only the reported fields. */
539
+ function formatTokenBreakdown(costs) {
540
+ const parts = [];
541
+ const push = (label, value) => {
542
+ const parsed = finiteOrNull(value);
543
+ if (parsed !== null) {
544
+ parts.push(`${label} ${parsed}`);
545
+ }
546
+ };
547
+ push('in', costs.tokensInput);
548
+ push('out', costs.tokensOutput);
549
+ push('cache-read', costs.tokensCacheRead);
550
+ push('cache-write', costs.tokensCacheWrite);
551
+ push('reasoning', costs.tokensReasoning);
552
+ return parts.length > 0 ? `tokens: ${parts.join(', ')}` : null;
553
+ }
554
+ /** `cost $0.0697 tokens: ...` or null when nothing was reported. */
555
+ function formatCostSummary(costs) {
556
+ const cost = finiteOrNull(costs.modelCostUsd);
557
+ const tokens = formatTokenBreakdown(costs);
558
+ if (cost === null && tokens === null) {
559
+ return null;
560
+ }
561
+ const parts = [];
562
+ if (cost !== null) {
563
+ parts.push(`cost ${formatUsd(cost)}`);
564
+ }
565
+ if (tokens) {
566
+ parts.push(tokens);
567
+ }
568
+ return parts.join(' ');
569
+ }
570
+ function formatDurationMs(ms) {
571
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
572
+ const hours = Math.floor(totalSeconds / 3600);
573
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
574
+ const seconds = totalSeconds % 60;
575
+ if (hours > 0)
576
+ return `${hours}h ${minutes}m ${seconds}s`;
577
+ if (minutes > 0)
578
+ return `${minutes}m ${seconds}s`;
579
+ return `${seconds}s`;
580
+ }
581
+ /**
582
+ * Session wall-clock from the existing startedAt/endedAt columns — no cost
583
+ * math, just duration (infra $ estimation is a later phase). Active sessions
584
+ * measure up to `now` and say so.
585
+ */
586
+ function formatSessionDuration(session, now = Date.now()) {
587
+ const startedAt = typeof session.startedAt === 'string' ? Date.parse(session.startedAt) : Number.NaN;
588
+ if (!Number.isFinite(startedAt)) {
589
+ return null;
590
+ }
591
+ const endedAt = typeof session.endedAt === 'string' ? Date.parse(session.endedAt) : Number.NaN;
592
+ return Number.isFinite(endedAt)
593
+ ? `duration ${formatDurationMs(endedAt - startedAt)}`
594
+ : `duration ${formatDurationMs(now - startedAt)} (so far)`;
595
+ }
531
596
  function formatSessionStatus(result) {
532
597
  if (result.session) {
533
598
  const lines = [`session ${result.session.id} ${result.session.status}`];
599
+ const duration = formatSessionDuration(result.session);
600
+ if (duration) {
601
+ lines.push(` ${duration}`);
602
+ }
603
+ const rollup = result.session.costs ? formatCostSummary(result.session.costs) : null;
604
+ if (rollup) {
605
+ lines.push(` ${rollup}`);
606
+ }
534
607
  const sync = result.branchSync;
535
608
  if (sync) {
536
609
  lines.push(sync.ahead !== null && sync.behind !== null
@@ -538,7 +611,9 @@ function formatSessionStatus(result) {
538
611
  : ` branch ${sync.branch}`);
539
612
  }
540
613
  for (const run of result.session.runs ?? []) {
541
- lines.push(` run ${run.id} ${run.status}`);
614
+ const cost = finiteOrNull(run.modelCostUsd);
615
+ const suffix = cost !== null ? ` ${formatUsd(cost)}` : '';
616
+ lines.push(` run ${run.id} ${run.status}${suffix}`);
542
617
  }
543
618
  return lines.join('\n');
544
619
  }
@@ -575,7 +650,8 @@ function formatSessionFinishOutcome(result) {
575
650
  }
576
651
  function formatRunStatus(run) {
577
652
  const sequence = typeof run.latestSequence === 'number' ? ` latestSequence=${run.latestSequence}` : '';
578
- return `run ${run.id} ${run.status}${sequence}`;
653
+ const costLine = formatCostSummary(run);
654
+ return `run ${run.id} ${run.status}${sequence}${costLine ? `\n ${costLine}` : ''}`;
579
655
  }
580
656
  function formatEventLine(event) {
581
657
  const payload = event.payload && Object.keys(event.payload).length > 0 ? ` ${JSON.stringify(event.payload)}` : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orizu",
3
- "version": "0.5.11",
3
+ "version": "0.5.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -256,6 +256,46 @@ export function resolveEnvBearerToken(): string | null {
256
256
  return null
257
257
  }
258
258
 
259
+ /**
260
+ * THE uniform bearer resolution for every CLI command (ALI-1090). Exactly the
261
+ * order `authedFetch` uses: `ORIZU_TOKEN` env → `ORIZU_TOKEN_FILE` (both via
262
+ * resolveEnvBearerToken, read FRESH per call — never cached, so hosted token
263
+ * rotations are picked up) → stored credentials.json. Commands that need a raw
264
+ * bearer (`orizu env`, the run-gepa wrapper) MUST use this instead of reading
265
+ * credentials.json directly, or they break in hosted sandboxes that are
266
+ * pre-authenticated via ORIZU_TOKEN_FILE and never ran `orizu login`.
267
+ */
268
+ export function resolveAuthTokenForBaseUrl(baseUrl: string): string {
269
+ const envBearer = resolveEnvBearerToken()
270
+ if (envBearer) {
271
+ return envBearer
272
+ }
273
+
274
+ const credentials = getServerCredentials(baseUrl)
275
+ if (!credentials) {
276
+ throw new Error(`Not logged in for ${baseUrl}. Run \`orizu login --server ${baseUrl}\` (or \`--local\`) first.`)
277
+ }
278
+
279
+ return 'accessToken' in credentials ? credentials.accessToken : credentials.apiKey
280
+ }
281
+
282
+ /**
283
+ * Non-throwing "is any auth available?" check (login --no-prompt-if-logged-in,
284
+ * setup status). Mirrors resolveAuthTokenForBaseUrl's order so an env bearer
285
+ * counts as signed in. A misconfigured ORIZU_TOKEN_FILE reports false here —
286
+ * actual requests still fail loudly via resolveEnvBearerToken.
287
+ */
288
+ export function hasResolvableAuth(baseUrl: string): boolean {
289
+ try {
290
+ if (resolveEnvBearerToken()) {
291
+ return true
292
+ }
293
+ } catch {
294
+ // fall through to stored credentials
295
+ }
296
+ return getServerCredentials(baseUrl) !== null
297
+ }
298
+
259
299
  export function setActiveBaseUrl(baseUrl: string | null) {
260
300
  const config = loadCredentialsConfigForWrite()
261
301
  config.activeBaseUrl = baseUrl