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.
- package/dist/credentials.js +37 -0
- package/dist/deploy-key-merge-core.js +258 -0
- package/dist/hosted-boot.js +19 -0
- package/dist/hosted-session-cli.js +6 -1
- package/dist/index.js +15 -12
- package/dist/manifests-cli.js +77 -5
- package/dist/merge-job.js +325 -0
- package/dist/vercel-sandbox-provider.js +2 -2
- package/dist/workbench-cli.js +78 -2
- package/package.json +1 -1
- package/src/credentials.ts +40 -0
- package/src/deploy-key-merge-core.ts +359 -0
- package/src/hosted-boot.ts +23 -1
- package/src/hosted-session-cli.ts +6 -1
- package/src/index.ts +21 -14
- package/src/manifests-cli.ts +102 -9
- package/src/merge-job.ts +462 -0
- package/src/vercel-sandbox-provider.ts +12 -2
- package/src/workbench-cli.ts +100 -4
- package/vendor/orizu-gepa-python/src/orizu_gepa/optimizer.py +5 -3
- package/vendor/skills/orizu-cli/SKILL.md +1 -0
- package/vendor/skills/orizu-cli/references/cli-reference.md +1 -0
package/src/merge-job.ts
ADDED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|
package/src/workbench-cli.ts
CHANGED
|
@@ -20,7 +20,20 @@ export interface WorkbenchSleep {
|
|
|
20
20
|
(ms: number): Promise<void>
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|