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,462 @@
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
+
42
+ import {
43
+ mergeBranchWithDeployKey,
44
+ type DeployKeyMergeResult,
45
+ type GitCommandRunner,
46
+ } from './deploy-key-merge-core.js'
47
+ import { redactSecrets } from './secret-redaction.js'
48
+
49
+ export type MergeJobFetch = (url: string, init?: RequestInit) => Promise<Response>
50
+
51
+ // -- Frozen env contract --------------------------------------------------------
52
+
53
+ export const REQUIRED_MERGE_JOB_ENV_VARS = [
54
+ 'ORIZU_BOOT_SECRET',
55
+ 'ORIZU_MERGE_JOB_ID',
56
+ 'ORIZU_COORDINATOR_URL',
57
+ 'ORIZU_BASE_URL',
58
+ ] as const
59
+
60
+ export interface MergeJobEnv {
61
+ bootSecret: string
62
+ /** The DO addressing key (`merge:<manifestId>:<expectedHeadSha>`). */
63
+ jobKey: string
64
+ coordinatorUrl: string
65
+ baseUrl: string
66
+ }
67
+
68
+ export function resolveMergeJobEnv(
69
+ env: Record<string, string | undefined>
70
+ ): { ok: true; value: MergeJobEnv } | { ok: false; missing: string[] } {
71
+ const missing: string[] = []
72
+ const req = (name: string): string => {
73
+ const value = env[name]?.trim()
74
+ if (!value) missing.push(name)
75
+ return value ?? ''
76
+ }
77
+ const bootSecret = req('ORIZU_BOOT_SECRET')
78
+ const jobKey = req('ORIZU_MERGE_JOB_ID')
79
+ const coordinatorUrl = req('ORIZU_COORDINATOR_URL')
80
+ const baseUrl = req('ORIZU_BASE_URL')
81
+ if (missing.length > 0) return { ok: false, missing }
82
+ return {
83
+ ok: true,
84
+ value: {
85
+ bootSecret,
86
+ jobKey,
87
+ coordinatorUrl: coordinatorUrl.replace(/\/+$/, ''),
88
+ baseUrl: baseUrl.replace(/\/+$/, ''),
89
+ },
90
+ }
91
+ }
92
+
93
+ // -- Terminal statuses (mirrors the merge_jobs enum; terminal subset only) -------
94
+
95
+ export type MergeJobTerminalStatus = 'succeeded' | 'conflict' | 'superseded' | 'failed'
96
+
97
+ export interface MergeJobReport {
98
+ status: MergeJobTerminalStatus
99
+ result: {
100
+ merged: boolean
101
+ sha: string | null
102
+ alreadyMerged: boolean
103
+ conflict: boolean
104
+ } | null
105
+ errorDetail: string | null
106
+ }
107
+
108
+ /** Map the shared core's outcome to the job's terminal status. */
109
+ export function terminalFromMergeResult(result: DeployKeyMergeResult): MergeJobReport {
110
+ if (result.superseded) {
111
+ return {
112
+ status: 'superseded',
113
+ result: null,
114
+ errorDetail: 'pre-push gate refused: manifest no longer appliable',
115
+ }
116
+ }
117
+ if (result.conflict) {
118
+ return {
119
+ status: 'conflict',
120
+ result: { merged: false, sha: null, alreadyMerged: false, conflict: true },
121
+ errorDetail: null,
122
+ }
123
+ }
124
+ return {
125
+ status: 'succeeded',
126
+ result: {
127
+ merged: result.merged,
128
+ sha: result.sha,
129
+ alreadyMerged: result.alreadyMerged,
130
+ conflict: false,
131
+ },
132
+ errorDetail: null,
133
+ }
134
+ }
135
+
136
+ // -- Step a: boot secret → job token (DO stream-through) ------------------------
137
+
138
+ export interface ExchangedJobToken {
139
+ token: string
140
+ /** The control-plane job id (merge_jobs uuid) — payload/gate URLs use it. */
141
+ jobId: string
142
+ }
143
+
144
+ export async function exchangeJobToken(opts: {
145
+ coordinatorUrl: string
146
+ jobKey: string
147
+ bootSecret: string
148
+ fetchImpl: MergeJobFetch
149
+ attempts?: number
150
+ backoffMs?: number
151
+ sleep?: (ms: number) => Promise<void>
152
+ log?: (line: string) => void
153
+ }): Promise<ExchangedJobToken> {
154
+ const attempts = Math.max(1, opts.attempts ?? 5)
155
+ const baseBackoff = opts.backoffMs ?? 500
156
+ const sleep = opts.sleep ?? ((ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms)))
157
+ const url = `${opts.coordinatorUrl}/merge-jobs/${encodeURIComponent(opts.jobKey)}/job-token`
158
+ let lastDetail = 'no attempt made'
159
+ for (let attempt = 0; attempt < attempts; attempt++) {
160
+ try {
161
+ const res = await opts.fetchImpl(url, {
162
+ method: 'GET',
163
+ headers: { Authorization: `Bearer ${opts.bootSecret}` },
164
+ })
165
+ if (res.ok) {
166
+ const data = (await res.json().catch(() => null)) as { token?: unknown; jobId?: unknown } | null
167
+ const token = data && typeof data.token === 'string' ? data.token : ''
168
+ const jobId = data && typeof data.jobId === 'string' ? data.jobId : ''
169
+ if (token && jobId) {
170
+ return { token, jobId }
171
+ }
172
+ lastDetail = 'job-token response carried no token/jobId'
173
+ } else {
174
+ lastDetail = `job-token exchange returned ${res.status}`
175
+ }
176
+ } catch (error) {
177
+ lastDetail = error instanceof Error ? error.message : String(error)
178
+ }
179
+ if (attempt < attempts - 1) {
180
+ const delay = baseBackoff * 2 ** attempt
181
+ opts.log?.(`job-token exchange attempt ${attempt + 1}/${attempts} failed (${lastDetail}); retrying in ${delay}ms`)
182
+ await sleep(delay)
183
+ }
184
+ }
185
+ throw new Error(`job-token exchange failed after ${attempts} attempts: ${lastDetail}`)
186
+ }
187
+
188
+ // -- Step b: payload pull (spec + key, single-use, direct from control plane) ----
189
+
190
+ export interface MergeJobSpec {
191
+ op: 'merge'
192
+ jobId: string
193
+ manifestId: string
194
+ workspaceId: string
195
+ repoFullName: string
196
+ base: string
197
+ sourceBranch: string
198
+ expectedHeadSha: string
199
+ commitMessage: string
200
+ }
201
+
202
+ export interface MergeJobPayload {
203
+ spec: MergeJobSpec
204
+ privateKeyPem: string
205
+ }
206
+
207
+ export async function fetchMergeJobPayload(opts: {
208
+ baseUrl: string
209
+ jobId: string
210
+ token: string
211
+ fetchImpl: MergeJobFetch
212
+ }): Promise<MergeJobPayload> {
213
+ const url = `${opts.baseUrl}/api/coordinator/merge-jobs/${encodeURIComponent(opts.jobId)}/payload`
214
+ const res = await opts.fetchImpl(url, {
215
+ method: 'GET',
216
+ headers: { Authorization: `Bearer ${opts.token}` },
217
+ })
218
+ if (!res.ok) {
219
+ throw new Error(`payload pull returned ${res.status}`)
220
+ }
221
+ const data = (await res.json().catch(() => null)) as Partial<MergeJobPayload> | null
222
+ const spec = data?.spec
223
+ const privateKeyPem = typeof data?.privateKeyPem === 'string' ? data.privateKeyPem : ''
224
+ if (!spec || typeof spec !== 'object' || !privateKeyPem) {
225
+ throw new Error('payload response was missing spec/privateKeyPem')
226
+ }
227
+ if (spec.op !== 'merge') {
228
+ // D9 op discriminator: this entrypoint implements ONLY the merge op.
229
+ throw new Error(`unsupported job op: ${String(spec.op)}`)
230
+ }
231
+ for (const field of ['repoFullName', 'base', 'sourceBranch', 'expectedHeadSha', 'commitMessage'] as const) {
232
+ if (typeof spec[field] !== 'string' || spec[field].length === 0) {
233
+ throw new Error(`payload spec is missing ${field}`)
234
+ }
235
+ }
236
+ return { spec: spec as MergeJobSpec, privateKeyPem }
237
+ }
238
+
239
+ // -- Step c helper: the D8 gate (fail closed on the push, honest on the report) --
240
+
241
+ export type GateOutcome = 'proceed' | 'superseded' | 'unavailable'
242
+
243
+ export async function checkPrePushGate(opts: {
244
+ baseUrl: string
245
+ jobId: string
246
+ token: string
247
+ fetchImpl: MergeJobFetch
248
+ }): Promise<GateOutcome> {
249
+ const url = `${opts.baseUrl}/api/coordinator/merge-jobs/${encodeURIComponent(opts.jobId)}/gate`
250
+ try {
251
+ const res = await opts.fetchImpl(url, {
252
+ method: 'GET',
253
+ headers: { Authorization: `Bearer ${opts.token}` },
254
+ })
255
+ if (!res.ok) {
256
+ return 'unavailable'
257
+ }
258
+ const data = (await res.json().catch(() => null)) as { proceed?: unknown } | null
259
+ if (data && data.proceed === true) return 'proceed'
260
+ if (data && data.proceed === false) return 'superseded'
261
+ return 'unavailable'
262
+ } catch {
263
+ return 'unavailable'
264
+ }
265
+ }
266
+
267
+ // -- Step d: report the terminal state to the DO (boot-secret auth) --------------
268
+
269
+ export async function postMergeJobResult(opts: {
270
+ coordinatorUrl: string
271
+ jobKey: string
272
+ bootSecret: string
273
+ report: MergeJobReport
274
+ fetchImpl: MergeJobFetch
275
+ attempts?: number
276
+ sleep?: (ms: number) => Promise<void>
277
+ log?: (line: string) => void
278
+ }): Promise<boolean> {
279
+ const attempts = Math.max(1, opts.attempts ?? 3)
280
+ const sleep = opts.sleep ?? ((ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms)))
281
+ const url = `${opts.coordinatorUrl}/merge-jobs/${encodeURIComponent(opts.jobKey)}/result`
282
+ for (let attempt = 0; attempt < attempts; attempt++) {
283
+ try {
284
+ const res = await opts.fetchImpl(url, {
285
+ method: 'POST',
286
+ headers: {
287
+ Authorization: `Bearer ${opts.bootSecret}`,
288
+ 'Content-Type': 'application/json',
289
+ },
290
+ body: JSON.stringify({
291
+ status: opts.report.status,
292
+ ...(opts.report.result ? { result: opts.report.result } : {}),
293
+ ...(opts.report.errorDetail ? { errorDetail: opts.report.errorDetail } : {}),
294
+ }),
295
+ })
296
+ if (res.ok) {
297
+ return true
298
+ }
299
+ opts.log?.(`result report attempt ${attempt + 1}/${attempts} returned ${res.status}`)
300
+ } catch (error) {
301
+ opts.log?.(
302
+ `result report attempt ${attempt + 1}/${attempts} failed: ${error instanceof Error ? error.message : String(error)}`
303
+ )
304
+ }
305
+ if (attempt < attempts - 1) {
306
+ await sleep(500 * 2 ** attempt)
307
+ }
308
+ }
309
+ return false
310
+ }
311
+
312
+ // -- Orchestration ----------------------------------------------------------------
313
+
314
+ const MAX_DETAIL_LENGTH = 1500
315
+
316
+ export interface RunMergeJobOptions {
317
+ env: MergeJobEnv
318
+ fetchImpl?: MergeJobFetch
319
+ /** Injectable git runner (tests). Default: the core's real spawn. */
320
+ runGit?: GitCommandRunner
321
+ sleep?: (ms: number) => Promise<void>
322
+ log?: (line: string) => void
323
+ tokenAttempts?: number
324
+ tokenBackoffMs?: number
325
+ }
326
+
327
+ export interface MergeJobRunResult {
328
+ /** Whether the terminal state reached the DO — the command's success bar. */
329
+ reported: boolean
330
+ status: MergeJobTerminalStatus
331
+ detail: string | null
332
+ }
333
+
334
+ export async function runMergeJob(opts: RunMergeJobOptions): Promise<MergeJobRunResult> {
335
+ const env = opts.env
336
+ const fetchImpl = opts.fetchImpl ?? (globalThis.fetch as MergeJobFetch)
337
+ const log = opts.log ?? ((): void => {})
338
+ const secrets: string[] = [env.bootSecret]
339
+ const redact = (value: string): string =>
340
+ redactSecrets(value, { secrets }).slice(0, MAX_DETAIL_LENGTH)
341
+
342
+ let report: MergeJobReport
343
+ try {
344
+ // a. boot secret → single-use job token (+ the control-plane job id).
345
+ const exchanged = await exchangeJobToken({
346
+ coordinatorUrl: env.coordinatorUrl,
347
+ jobKey: env.jobKey,
348
+ bootSecret: env.bootSecret,
349
+ fetchImpl,
350
+ attempts: opts.tokenAttempts,
351
+ backoffMs: opts.tokenBackoffMs,
352
+ sleep: opts.sleep,
353
+ log,
354
+ })
355
+ secrets.push(exchanged.token)
356
+ log(`job token exchanged (job ${exchanged.jobId})`)
357
+
358
+ // b. pull spec + key directly from the control plane (single-use release).
359
+ const payload = await fetchMergeJobPayload({
360
+ baseUrl: env.baseUrl,
361
+ jobId: exchanged.jobId,
362
+ token: exchanged.token,
363
+ fetchImpl,
364
+ })
365
+ secrets.push(payload.privateKeyPem)
366
+ log(`payload pulled for ${payload.spec.repoFullName} (${payload.spec.sourceBranch} → ${payload.spec.base})`)
367
+
368
+ // c. the SHARED merge core with the D8 gate immediately pre-push.
369
+ // Object wrapper: TS cannot track closure writes to a let across the
370
+ // await below and would narrow the literal type at the read site.
371
+ const gate: { outcome: GateOutcome } = { outcome: 'proceed' }
372
+ const result = await mergeBranchWithDeployKey(
373
+ payload.spec.repoFullName,
374
+ {
375
+ base: payload.spec.base,
376
+ sourceBranch: payload.spec.sourceBranch,
377
+ expectedHeadSha: payload.spec.expectedHeadSha,
378
+ commitMessage: payload.spec.commitMessage,
379
+ privateKeyPem: payload.privateKeyPem,
380
+ },
381
+ {
382
+ runGit: opts.runGit,
383
+ prePushGate: async () => {
384
+ gate.outcome = await checkPrePushGate({
385
+ baseUrl: env.baseUrl,
386
+ jobId: exchanged.jobId,
387
+ token: exchanged.token,
388
+ fetchImpl,
389
+ })
390
+ log(`pre-push gate: ${gate.outcome}`)
391
+ // FAIL CLOSED on the push for both 'superseded' and 'unavailable'.
392
+ return gate.outcome === 'proceed'
393
+ },
394
+ }
395
+ )
396
+
397
+ if (result.superseded && gate.outcome === 'unavailable') {
398
+ // The gate could not answer — "unknown" is not "rejected". No push
399
+ // happened (fail closed); report a retryable failure, not superseded.
400
+ report = {
401
+ status: 'failed',
402
+ result: null,
403
+ errorDetail: 'pre-push gate unreachable; aborted without pushing',
404
+ }
405
+ } else {
406
+ report = terminalFromMergeResult(result)
407
+ }
408
+ } catch (error) {
409
+ const message = error instanceof Error ? error.message : String(error)
410
+ report = { status: 'failed', result: null, errorDetail: redact(message) }
411
+ }
412
+
413
+ if (report.errorDetail) {
414
+ report = { ...report, errorDetail: redact(report.errorDetail) }
415
+ }
416
+
417
+ const reported = await postMergeJobResult({
418
+ coordinatorUrl: env.coordinatorUrl,
419
+ jobKey: env.jobKey,
420
+ bootSecret: env.bootSecret,
421
+ report,
422
+ fetchImpl,
423
+ sleep: opts.sleep,
424
+ log,
425
+ })
426
+ log(`terminal ${report.status} ${reported ? 'reported' : 'REPORT FAILED'}`)
427
+ return { reported, status: report.status, detail: report.errorDetail }
428
+ }
429
+
430
+ // -- CLI entry (`orizu internal merge-job`) ---------------------------------------
431
+
432
+ export interface MergeJobCommandIo {
433
+ print: (line: string) => void
434
+ printErr?: (line: string) => void
435
+ json?: boolean
436
+ }
437
+
438
+ /**
439
+ * Read the frozen env contract and run the job. Exit 0 on ANY reported
440
+ * terminal outcome (the REPORT, not the merge, is the command's success —
441
+ * a conflict is a correctly-completed job); exit 1 when the env is invalid
442
+ * or the terminal report never reached the DO (the DO's job wall is the
443
+ * backstop then).
444
+ */
445
+ export async function mergeJobCommand(io: MergeJobCommandIo): Promise<number> {
446
+ const resolved = resolveMergeJobEnv(process.env)
447
+ if (!resolved.ok) {
448
+ io.printErr?.(
449
+ `merge-job: missing required env: ${resolved.missing.join(', ')} ` +
450
+ '(the MergeJobCoordinator sandbox env contract)'
451
+ )
452
+ return 1
453
+ }
454
+ const log = (line: string): void => io.printErr?.(`[merge-job] ${line}`)
455
+ const outcome = await runMergeJob({ env: resolved.value, log })
456
+ io.print(
457
+ io.json
458
+ ? JSON.stringify({ ok: outcome.reported, status: outcome.status, detail: outcome.detail })
459
+ : `merge-job finished: ${outcome.status}${outcome.detail ? ` (${outcome.detail})` : ''}${outcome.reported ? '' : ' [terminal report failed]'}`
460
+ )
461
+ return outcome.reported ? 0 : 1
462
+ }
@@ -135,10 +135,20 @@ export interface VercelSdkModule {
135
135
  // same discipline as the ALI-1031 server provider.
136
136
  const VERCEL_SDK_SPECIFIER = ['@vercel', 'sandbox'].join('/')
137
137
 
138
- async function loadVercelModule(): Promise<VercelSdkModule> {
138
+ /**
139
+ * Injectable dynamic-import seam: tests pass an importer that throws so the
140
+ * SDK-absent path is exercised deterministically, regardless of whether
141
+ * `@vercel/sandbox` happens to be resolvable on the host machine. Production
142
+ * callers never pass one — the default is a real dynamic `import()`.
143
+ */
144
+ export type VercelSdkImporter = (specifier: string) => Promise<unknown>
145
+
146
+ export async function loadVercelModule(
147
+ importModule: VercelSdkImporter = specifier => import(specifier)
148
+ ): Promise<VercelSdkModule> {
139
149
  try {
140
150
  const specifier: string = VERCEL_SDK_SPECIFIER
141
- const mod = (await import(specifier)) as unknown as VercelSdkModule
151
+ const mod = (await importModule(specifier)) as VercelSdkModule
142
152
  if (!mod || typeof mod.Sandbox?.create !== 'function') {
143
153
  throw new Error('module did not export a Sandbox class with create()')
144
154
  }
@@ -20,7 +20,20 @@ export interface WorkbenchSleep {
20
20
  (ms: number): Promise<void>
21
21
  }
22
22
 
23
- export interface WorkbenchRunSummary {
23
+ // Per-run model cost/token totals (ALI-1089), aggregated server-side from
24
+ // agent_step_finish event payloads. Null/absent = "no cost data reported"
25
+ // (distinct from a genuine $0 run).
26
+ export interface WorkbenchRunCosts {
27
+ modelCostUsd?: number | null
28
+ tokensInput?: number | null
29
+ tokensOutput?: number | null
30
+ tokensCacheRead?: number | null
31
+ tokensCacheWrite?: number | null
32
+ tokensReasoning?: number | null
33
+ costUpdatedAt?: string | null
34
+ }
35
+
36
+ export interface WorkbenchRunSummary extends WorkbenchRunCosts {
24
37
  id: string
25
38
  status: string
26
39
  }
@@ -38,10 +51,12 @@ export interface WorkbenchSession {
38
51
  createdAt?: string
39
52
  updatedAt?: string
40
53
  runs?: WorkbenchRunSummary[]
54
+ /** Session rollup: sum of the runs' server-aggregated cost totals (ALI-1089). */
55
+ costs?: WorkbenchRunCosts
41
56
  [key: string]: unknown
42
57
  }
43
58
 
44
- export interface WorkbenchRun {
59
+ export interface WorkbenchRun extends WorkbenchRunCosts {
45
60
  id: string
46
61
  workspaceSessionId?: string
47
62
  workspaceId?: string
@@ -816,9 +831,87 @@ function formatSessionStart(result: SessionStartResult): string {
816
831
  return lines.join('\n')
817
832
  }
818
833
 
834
+ // -- Cost/duration rendering (ALI-1089) ---------------------------------------
835
+
836
+ function finiteOrNull(value: unknown): number | null {
837
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
838
+ }
839
+
840
+ function formatUsd(value: number): string {
841
+ return `$${value.toFixed(4)}`
842
+ }
843
+
844
+ /** `tokens: in 2, out 181, cache-read 126968, ...` — only the reported fields. */
845
+ function formatTokenBreakdown(costs: WorkbenchRunCosts): string | null {
846
+ const parts: string[] = []
847
+ const push = (label: string, value: unknown) => {
848
+ const parsed = finiteOrNull(value)
849
+ if (parsed !== null) {
850
+ parts.push(`${label} ${parsed}`)
851
+ }
852
+ }
853
+ push('in', costs.tokensInput)
854
+ push('out', costs.tokensOutput)
855
+ push('cache-read', costs.tokensCacheRead)
856
+ push('cache-write', costs.tokensCacheWrite)
857
+ push('reasoning', costs.tokensReasoning)
858
+ return parts.length > 0 ? `tokens: ${parts.join(', ')}` : null
859
+ }
860
+
861
+ /** `cost $0.0697 tokens: ...` or null when nothing was reported. */
862
+ function formatCostSummary(costs: WorkbenchRunCosts): string | null {
863
+ const cost = finiteOrNull(costs.modelCostUsd)
864
+ const tokens = formatTokenBreakdown(costs)
865
+ if (cost === null && tokens === null) {
866
+ return null
867
+ }
868
+ const parts: string[] = []
869
+ if (cost !== null) {
870
+ parts.push(`cost ${formatUsd(cost)}`)
871
+ }
872
+ if (tokens) {
873
+ parts.push(tokens)
874
+ }
875
+ return parts.join(' ')
876
+ }
877
+
878
+ function formatDurationMs(ms: number): string {
879
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000))
880
+ const hours = Math.floor(totalSeconds / 3600)
881
+ const minutes = Math.floor((totalSeconds % 3600) / 60)
882
+ const seconds = totalSeconds % 60
883
+ if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`
884
+ if (minutes > 0) return `${minutes}m ${seconds}s`
885
+ return `${seconds}s`
886
+ }
887
+
888
+ /**
889
+ * Session wall-clock from the existing startedAt/endedAt columns — no cost
890
+ * math, just duration (infra $ estimation is a later phase). Active sessions
891
+ * measure up to `now` and say so.
892
+ */
893
+ function formatSessionDuration(session: WorkbenchSession, now = Date.now()): string | null {
894
+ const startedAt = typeof session.startedAt === 'string' ? Date.parse(session.startedAt) : Number.NaN
895
+ if (!Number.isFinite(startedAt)) {
896
+ return null
897
+ }
898
+ const endedAt = typeof session.endedAt === 'string' ? Date.parse(session.endedAt) : Number.NaN
899
+ return Number.isFinite(endedAt)
900
+ ? `duration ${formatDurationMs(endedAt - startedAt)}`
901
+ : `duration ${formatDurationMs(now - startedAt)} (so far)`
902
+ }
903
+
819
904
  function formatSessionStatus(result: SessionStatusResult): string {
820
905
  if (result.session) {
821
906
  const lines = [`session ${result.session.id} ${result.session.status}`]
907
+ const duration = formatSessionDuration(result.session)
908
+ if (duration) {
909
+ lines.push(` ${duration}`)
910
+ }
911
+ const rollup = result.session.costs ? formatCostSummary(result.session.costs) : null
912
+ if (rollup) {
913
+ lines.push(` ${rollup}`)
914
+ }
822
915
  const sync = result.branchSync
823
916
  if (sync) {
824
917
  lines.push(
@@ -828,7 +921,9 @@ function formatSessionStatus(result: SessionStatusResult): string {
828
921
  )
829
922
  }
830
923
  for (const run of result.session.runs ?? []) {
831
- lines.push(` run ${run.id} ${run.status}`)
924
+ const cost = finiteOrNull(run.modelCostUsd)
925
+ const suffix = cost !== null ? ` ${formatUsd(cost)}` : ''
926
+ lines.push(` run ${run.id} ${run.status}${suffix}`)
832
927
  }
833
928
  return lines.join('\n')
834
929
  }
@@ -868,7 +963,8 @@ function formatSessionFinishOutcome(result: SessionFinishResult): string {
868
963
 
869
964
  function formatRunStatus(run: WorkbenchRun): string {
870
965
  const sequence = typeof run.latestSequence === 'number' ? ` latestSequence=${run.latestSequence}` : ''
871
- return `run ${run.id} ${run.status}${sequence}`
966
+ const costLine = formatCostSummary(run)
967
+ return `run ${run.id} ${run.status}${sequence}${costLine ? `\n ${costLine}` : ''}`
872
968
  }
873
969
 
874
970
  function formatEventLine(event: WorkbenchRunEvent): string {
@@ -22,7 +22,7 @@ The current parameter value is:
22
22
  <current_candidate>
23
23
  ```
24
24
 
25
- Below is evaluation data showing how this parameter value performed across multiple test cases. The data contains performance metrics, model outputs, judge feedback, and any relevant diagnostic information from the evaluation:
25
+ Below is evaluation data showing how this parameter value performed across multiple test cases. The data contains performance metrics, scorer feedback, errors, and any relevant diagnostic information from the evaluation:
26
26
  ```
27
27
  <evaluation_data>
28
28
  ```
@@ -1867,10 +1867,12 @@ def optimize_loaded_text_candidate(
1867
1867
  def build_reflection_prompt(parent_text: str, parent_results: list[RowEvaluation], config: TextGepaConfig) -> str:
1868
1868
  examples = []
1869
1869
  for result in parent_results:
1870
+ if result.feedback is None or not result.feedback.strip():
1871
+ raise ValueError(
1872
+ f"Scorer feedback is required for reflection row {result.row_id}"
1873
+ )
1870
1874
  examples.append({
1871
1875
  "row_id": result.row_id,
1872
- "input": result.row,
1873
- "output": result.output,
1874
1876
  "score": result.score,
1875
1877
  "feedback": result.feedback,
1876
1878
  "error": result.error,
@@ -41,6 +41,7 @@ Skip this skill when:
41
41
  - Clear local credentials and revoke the current CLI token: `orizu logout`.
42
42
  - Revoke other CLI tokens from the Personal Tokens page in Orizu.
43
43
  - Auth failure loop: `orizu login` → `orizu whoami` → rerun the original command.
44
+ - Hosted sandboxes are pre-authenticated via `ORIZU_TOKEN_FILE` (no `orizu login` needed); an explicit `ORIZU_TOKEN` env var overrides both the token file and stored credentials when set.
44
45
 
45
46
  ## Teams and projects
46
47
 
@@ -33,6 +33,7 @@ Behavior:
33
33
  - `orizu logout` revokes the current PAT remotely when possible and then clears local credentials.
34
34
  - PATs can also be revoked from the Personal Tokens page in Orizu.
35
35
  - PAT authorization follows the owning user's current team/project roles, so demotion or removal takes effect without rotating the token.
36
+ - Hosted sandboxes are pre-authenticated via `ORIZU_TOKEN_FILE` (no `orizu login` needed); an explicit `ORIZU_TOKEN` env var overrides both the token file and stored credentials when set.
36
37
 
37
38
  ### Agent Setup
38
39