orizu 0.5.3 → 0.5.5
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/artifact-pull.js +51 -7
- package/dist/help.js +12 -0
- package/dist/hosted-harness-opencode.js +68 -9
- package/dist/hosted-loop.js +39 -4
- package/dist/index.js +3 -3
- package/package.json +1 -1
- package/src/artifact-pull.ts +68 -7
- package/src/help.ts +12 -0
- package/src/hosted-harness-opencode.ts +91 -10
- package/src/hosted-loop.ts +39 -6
- package/src/index.ts +3 -3
package/dist/artifact-pull.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Runner/optimizer artifact
|
|
3
|
-
*
|
|
4
|
-
* content-addressed zip from storage,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* Runner/optimizer artifact commands: list (discover names, version ids, and
|
|
3
|
+
* labels) and pull (symmetric with `runners push` / `optimizers push` —
|
|
4
|
+
* resolve by id or name, download the content-addressed zip from storage,
|
|
5
|
+
* and extract it into --out). Pull writes version metadata to
|
|
6
|
+
* .orizu/pull.json — .orizu/ is excluded from artifact zips (see
|
|
7
|
+
* artifact-archive.ts), so a pulled directory re-pushes with an identical
|
|
8
|
+
* content hash.
|
|
8
9
|
*
|
|
9
10
|
* Lives outside index.ts per the CLI index line ratchet (ALI-976): this module
|
|
10
11
|
* owns its own argument parsing and printing via injected io.
|
|
@@ -49,7 +50,50 @@ async function parseJsonPayload(response, context) {
|
|
|
49
50
|
`Body preview: ${sanitizeTerminalText(rawBody.slice(0, 180))}`);
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
|
-
export async function
|
|
53
|
+
export async function runnerOptimizerCommand(args, io) {
|
|
54
|
+
if (args[1] === 'list') {
|
|
55
|
+
await listArtifactsCommand(args, io);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
await pullArtifactCommand(args, io);
|
|
59
|
+
}
|
|
60
|
+
async function listArtifactsCommand(args, io) {
|
|
61
|
+
const kind = args[0] === 'runners' ? 'runner' : 'optimizer';
|
|
62
|
+
const plural = kind === 'runner' ? 'runners' : 'optimizers';
|
|
63
|
+
const fetcher = io.fetcher || authedFetch;
|
|
64
|
+
const project = argValue(args, '--project') || await io.resolveProjectSlug(null);
|
|
65
|
+
const response = await fetcher(`/api/cli/${plural}?project=${encodeURIComponent(project)}`);
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
throw new Error(`Failed to fetch ${plural}: ${await response.text()}`);
|
|
68
|
+
}
|
|
69
|
+
const data = await parseJsonPayload(response, `${plural} list`);
|
|
70
|
+
if (io.json) {
|
|
71
|
+
io.print(JSON.stringify(data));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const items = data[plural] || [];
|
|
75
|
+
if (items.length === 0) {
|
|
76
|
+
io.print(`No ${plural} found.`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const rows = items.map(item => ({
|
|
80
|
+
id: sanitizeTerminalText(item.id),
|
|
81
|
+
name: sanitizeTerminalText(item.name),
|
|
82
|
+
versions: String(item.versionCount ?? 0),
|
|
83
|
+
latest: sanitizeTerminalText(item.latestVersionId || '-'),
|
|
84
|
+
labels: sanitizeTerminalText((item.labels || []).map(entry => entry.label).join(', ') || '-'),
|
|
85
|
+
}));
|
|
86
|
+
const idWidth = Math.max('ID'.length, ...rows.map(row => row.id.length));
|
|
87
|
+
const nameWidth = Math.max('NAME'.length, ...rows.map(row => row.name.length));
|
|
88
|
+
const versionsWidth = Math.max('VERSIONS'.length, ...rows.map(row => row.versions.length));
|
|
89
|
+
const latestWidth = Math.max('LATEST VERSION'.length, ...rows.map(row => row.latest.length));
|
|
90
|
+
io.print(`${'ID'.padEnd(idWidth)} ${'NAME'.padEnd(nameWidth)} ${'VERSIONS'.padEnd(versionsWidth)} ${'LATEST VERSION'.padEnd(latestWidth)} LABELS`);
|
|
91
|
+
io.print(`${'-'.repeat(idWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(versionsWidth)} ${'-'.repeat(latestWidth)} ${'-'.repeat('LABELS'.length)}`);
|
|
92
|
+
rows.forEach(row => {
|
|
93
|
+
io.print(`${row.id.padEnd(idWidth)} ${row.name.padEnd(nameWidth)} ${row.versions.padEnd(versionsWidth)} ${row.latest.padEnd(latestWidth)} ${row.labels}`);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
async function pullArtifactCommand(args, io) {
|
|
53
97
|
const kind = args[0] === 'runners' ? 'runner' : 'optimizer';
|
|
54
98
|
const fetcher = io.fetcher || authedFetch;
|
|
55
99
|
const positionalRef = args[2];
|
package/dist/help.js
CHANGED
|
@@ -344,6 +344,12 @@ export const COMMAND_DOCS = [
|
|
|
344
344
|
summary: 'Push a local runner artifact directory.',
|
|
345
345
|
group: 'Scorers and runners',
|
|
346
346
|
},
|
|
347
|
+
{
|
|
348
|
+
path: ['runners', 'list'],
|
|
349
|
+
usage: 'orizu runners list [--project <team/project>] [--json]',
|
|
350
|
+
summary: 'List runner artifacts with version counts, latest version ids, and labels.',
|
|
351
|
+
group: 'Scorers and runners',
|
|
352
|
+
},
|
|
347
353
|
{
|
|
348
354
|
path: ['runners', 'pull'],
|
|
349
355
|
usage: 'orizu runners pull <runner-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <version-id>] [--json]',
|
|
@@ -362,6 +368,12 @@ export const COMMAND_DOCS = [
|
|
|
362
368
|
summary: 'Push a local optimizer artifact directory.',
|
|
363
369
|
group: 'Optimizations',
|
|
364
370
|
},
|
|
371
|
+
{
|
|
372
|
+
path: ['optimizers', 'list'],
|
|
373
|
+
usage: 'orizu optimizers list [--project <team/project>] [--json]',
|
|
374
|
+
summary: 'List optimizer artifacts with version counts, latest version ids, and labels.',
|
|
375
|
+
group: 'Optimizations',
|
|
376
|
+
},
|
|
365
377
|
{
|
|
366
378
|
path: ['optimizers', 'pull'],
|
|
367
379
|
usage: 'orizu optimizers pull <optimizer-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <version-id>] [--json]',
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
* `OPENCODE_PINNED_VERSION`; a bump that switches OpenCode to CRLF framing
|
|
49
49
|
* would break both ports and must be re-verified.
|
|
50
50
|
*/
|
|
51
|
+
import { readFileSync } from 'fs';
|
|
51
52
|
// OpenCode is HARD-PINNED to this version everywhere it is installed/launched.
|
|
52
53
|
// AUDIT RISK #1 (SSE fragility): the entire event model rides OpenCode's
|
|
53
54
|
// undocumented `/event` SSE part schema (`message.part.updated`, `session.idle`,
|
|
@@ -696,15 +697,34 @@ export function buildOpenCodeConfigContent(opts) {
|
|
|
696
697
|
permission: opts.permission ?? { '*': { '*': 'allow' } },
|
|
697
698
|
});
|
|
698
699
|
}
|
|
700
|
+
const OPENCODE_READY_TIMEOUT_MS = 180_000;
|
|
701
|
+
const OPENCODE_READY_POLL_MS = 500;
|
|
702
|
+
const OPENCODE_READY_PROBE_TIMEOUT_MS = 2_000;
|
|
703
|
+
const OPENCODE_LOG_TAIL_BYTES = 2_000;
|
|
704
|
+
function readLogTail(logPath) {
|
|
705
|
+
if (!logPath)
|
|
706
|
+
return null;
|
|
707
|
+
try {
|
|
708
|
+
const content = readFileSync(logPath, 'utf8');
|
|
709
|
+
if (!content.trim())
|
|
710
|
+
return null;
|
|
711
|
+
return content.slice(-OPENCODE_LOG_TAIL_BYTES).trim();
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
699
717
|
/**
|
|
700
|
-
* Launch `opencode serve` locally
|
|
701
|
-
*
|
|
702
|
-
*
|
|
703
|
-
*
|
|
704
|
-
*
|
|
705
|
-
*
|
|
718
|
+
* Launch `opencode serve` locally and WAIT until it answers HTTP. OpenCode's
|
|
719
|
+
* first boot performs a one-time sqlite migration before it listens; returning
|
|
720
|
+
* the baseUrl without waiting raced the harness's first `POST /session` into a
|
|
721
|
+
* connection-refused "fetch failed" (ALI-1034, found live in QA-3). Any HTTP
|
|
722
|
+
* status counts as ready — we only need the socket to be accepting. Process
|
|
723
|
+
* supervision is otherwise intentionally minimal for this slice; the pinned
|
|
724
|
+
* version (`OPENCODE_PINNED_VERSION`) is the one the sandbox image must install
|
|
725
|
+
* (see the SSE-fragility note on that constant).
|
|
706
726
|
*/
|
|
707
|
-
export function spawnOpenCode(opts) {
|
|
727
|
+
export async function spawnOpenCode(opts) {
|
|
708
728
|
const port = opts.port ?? DEFAULT_OPENCODE_PORT;
|
|
709
729
|
const env = {
|
|
710
730
|
OPENCODE_CONFIG_CONTENT: buildOpenCodeConfigContent(opts),
|
|
@@ -724,6 +744,45 @@ export function spawnOpenCode(opts) {
|
|
|
724
744
|
stderr: 'ignore',
|
|
725
745
|
});
|
|
726
746
|
});
|
|
727
|
-
const child = spawnImpl(cmd, { cwd: opts.cwd, env });
|
|
728
|
-
|
|
747
|
+
const child = spawnImpl(cmd, { cwd: opts.cwd, env, logPath: opts.logPath });
|
|
748
|
+
const baseUrl = `http://localhost:${port}`;
|
|
749
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
750
|
+
const timeoutMs = opts.readyTimeoutMs ?? OPENCODE_READY_TIMEOUT_MS;
|
|
751
|
+
const pollMs = opts.readyPollMs ?? OPENCODE_READY_POLL_MS;
|
|
752
|
+
const startedAt = Date.now();
|
|
753
|
+
for (;;) {
|
|
754
|
+
if (opts.signal?.aborted) {
|
|
755
|
+
child.kill();
|
|
756
|
+
throw new Error('opencode serve readiness wait aborted');
|
|
757
|
+
}
|
|
758
|
+
try {
|
|
759
|
+
const probe = new AbortController();
|
|
760
|
+
const timer = setTimeout(() => probe.abort(), OPENCODE_READY_PROBE_TIMEOUT_MS);
|
|
761
|
+
// Link the run signal so an abort tears down an in-flight probe
|
|
762
|
+
// immediately instead of waiting out the per-probe timeout.
|
|
763
|
+
const probeSignal = opts.signal ? AbortSignal.any([opts.signal, probe.signal]) : probe.signal;
|
|
764
|
+
try {
|
|
765
|
+
// Any HTTP response (even 404) proves the server is accepting.
|
|
766
|
+
const res = await fetchImpl(`${baseUrl}/session`, { signal: probeSignal });
|
|
767
|
+
await cancelBody(res.body);
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
finally {
|
|
771
|
+
clearTimeout(timer);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
catch {
|
|
775
|
+
// not listening yet — fall through to the timeout check + sleep
|
|
776
|
+
}
|
|
777
|
+
// Soft budget: checked between probes, so the total wait can overrun by up
|
|
778
|
+
// to one probe timeout + poll interval. Negligible against the default.
|
|
779
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
780
|
+
child.kill();
|
|
781
|
+
const tail = readLogTail(opts.logPath);
|
|
782
|
+
throw new Error(`opencode serve did not become ready on ${baseUrl} within ${timeoutMs}ms` +
|
|
783
|
+
(tail ? ` — log tail: ${tail}` : ' (no log output captured)'));
|
|
784
|
+
}
|
|
785
|
+
await new Promise(resolve => setTimeout(resolve, pollMs));
|
|
786
|
+
}
|
|
787
|
+
return { baseUrl, port, stop: () => child.kill(), readyAfterMs: Date.now() - startedAt };
|
|
729
788
|
}
|
package/dist/hosted-loop.js
CHANGED
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
* against a fake broker + fake OpenCode server with no real binary.
|
|
34
34
|
*/
|
|
35
35
|
import { spawnSync, spawn as nodeSpawn } from 'child_process';
|
|
36
|
-
import { existsSync, readFileSync } from 'fs';
|
|
36
|
+
import { closeSync, existsSync, openSync, readFileSync } from 'fs';
|
|
37
|
+
import { tmpdir } from 'os';
|
|
37
38
|
import { join } from 'path';
|
|
38
39
|
import { PREBAKED_MARKER_PATH, SETUP_HOOK_RELATIVE_PATH, parsePrebakedMarker, } from './hosted-runtime-assets.js';
|
|
39
40
|
import { OPENCODE_PINNED_VERSION, createOpenCodeHarness, spawnOpenCode, } from './hosted-harness-opencode.js';
|
|
@@ -218,13 +219,35 @@ function installOpenCodePinned(version) {
|
|
|
218
219
|
}
|
|
219
220
|
/** Node child_process spawner for `opencode serve` (the sandbox runtime is
|
|
220
221
|
* node24 — Bun.spawn is not guaranteed present, so spawnOpenCode is given this
|
|
221
|
-
* runtime-agnostic spawner).
|
|
222
|
+
* runtime-agnostic spawner). When a logPath is provided, opencode's
|
|
223
|
+
* stdout/stderr land there so a readiness timeout can surface the real boot
|
|
224
|
+
* failure instead of a bare "fetch failed" (ALI-1034). */
|
|
222
225
|
function nodeChildSpawner(cmd, opts) {
|
|
226
|
+
let stdio = 'ignore';
|
|
227
|
+
let fd = null;
|
|
228
|
+
if (opts.logPath) {
|
|
229
|
+
try {
|
|
230
|
+
fd = openSync(opts.logPath, 'a');
|
|
231
|
+
stdio = ['ignore', fd, fd];
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// log capture is best-effort — never block the spawn on it
|
|
235
|
+
}
|
|
236
|
+
}
|
|
223
237
|
const child = nodeSpawn(cmd[0], cmd.slice(1), {
|
|
224
238
|
cwd: opts.cwd,
|
|
225
239
|
env: { ...process.env, ...opts.env },
|
|
226
|
-
stdio
|
|
240
|
+
stdio,
|
|
227
241
|
});
|
|
242
|
+
if (fd !== null) {
|
|
243
|
+
// The child holds its own copy of the descriptor.
|
|
244
|
+
try {
|
|
245
|
+
closeSync(fd);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// already closed
|
|
249
|
+
}
|
|
250
|
+
}
|
|
228
251
|
return { kill: () => child.kill() };
|
|
229
252
|
}
|
|
230
253
|
export async function runHostedLoop(opts) {
|
|
@@ -386,13 +409,25 @@ export async function runHostedLoop(opts) {
|
|
|
386
409
|
});
|
|
387
410
|
}
|
|
388
411
|
const spawnImpl = opts.spawnOpenCode ?? spawnOpenCode;
|
|
389
|
-
|
|
412
|
+
// The default spawnOpenCode WAITS for the server to answer HTTP before
|
|
413
|
+
// returning — OpenCode's first boot runs a one-time sqlite migration, and
|
|
414
|
+
// starting the harness before the server listens raced straight into a
|
|
415
|
+
// connection-refused "fetch failed" (ALI-1034).
|
|
416
|
+
spawned = await spawnImpl({
|
|
390
417
|
model: context.model,
|
|
391
418
|
cwd: context.workspaceDir,
|
|
392
419
|
port: context.opencodePort,
|
|
393
420
|
env: context.anthropicDummyKey ? { ANTHROPIC_API_KEY: context.anthropicDummyKey } : undefined,
|
|
394
421
|
spawn: nodeChildSpawner,
|
|
422
|
+
logPath: join(tmpdir(), `opencode-serve-${context.runId}.log`),
|
|
423
|
+
signal,
|
|
395
424
|
});
|
|
425
|
+
if (typeof spawned.readyAfterMs === 'number') {
|
|
426
|
+
await sink.append({
|
|
427
|
+
kind: 'artifact',
|
|
428
|
+
payload: { step: 'opencode_ready', ok: true, waitedMs: spawned.readyAfterMs },
|
|
429
|
+
});
|
|
430
|
+
}
|
|
396
431
|
harness = (opts.createHarness ?? ((baseUrl) => createOpenCodeHarness({ baseUrl })))(spawned.baseUrl);
|
|
397
432
|
}
|
|
398
433
|
const started = await harness.start({
|
package/dist/index.js
CHANGED
|
@@ -27,7 +27,7 @@ import { formatTaskCreateError } from './task-create-error.js';
|
|
|
27
27
|
import { renderAgentSetupPrompt } from './setup-prompt.js';
|
|
28
28
|
import { getWorkspaceRoot, initOrizuWorkspace, workspaceExists, } from './workspace.js';
|
|
29
29
|
import { connectorsCommand } from './connectors-cli.js';
|
|
30
|
-
import { ARTIFACT_MAX_BYTES,
|
|
30
|
+
import { ARTIFACT_MAX_BYTES, runnerOptimizerCommand } from './artifact-pull.js';
|
|
31
31
|
import { killSwitchCommand } from './kill-switch-cli.js';
|
|
32
32
|
import { egressAllowlistCommand } from './egress-allowlist-cli.js';
|
|
33
33
|
import { manifestsCommand } from './manifests-cli.js';
|
|
@@ -5001,8 +5001,8 @@ export async function main(rawArgs = process.argv.slice(2)) {
|
|
|
5001
5001
|
await pushRunnerArtifact(command === 'runners' ? 'runner' : 'optimizer');
|
|
5002
5002
|
return;
|
|
5003
5003
|
}
|
|
5004
|
-
if ((command === 'runners' || command === 'optimizers') && subcommand === 'pull') {
|
|
5005
|
-
await
|
|
5004
|
+
if ((command === 'runners' || command === 'optimizers') && (subcommand === 'pull' || subcommand === 'list')) {
|
|
5005
|
+
await runnerOptimizerCommand(cliArgs, { json: hasJsonFlag(), print: printLine, resolveProjectSlug });
|
|
5006
5006
|
return;
|
|
5007
5007
|
}
|
|
5008
5008
|
if (command === 'runners' && subcommand === 'exec') {
|
package/package.json
CHANGED
package/src/artifact-pull.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Runner/optimizer artifact
|
|
3
|
-
*
|
|
4
|
-
* content-addressed zip from storage,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* Runner/optimizer artifact commands: list (discover names, version ids, and
|
|
3
|
+
* labels) and pull (symmetric with `runners push` / `optimizers push` —
|
|
4
|
+
* resolve by id or name, download the content-addressed zip from storage,
|
|
5
|
+
* and extract it into --out). Pull writes version metadata to
|
|
6
|
+
* .orizu/pull.json — .orizu/ is excluded from artifact zips (see
|
|
7
|
+
* artifact-archive.ts), so a pulled directory re-pushes with an identical
|
|
8
|
+
* content hash.
|
|
8
9
|
*
|
|
9
10
|
* Lives outside index.ts per the CLI index line ratchet (ALI-976): this module
|
|
10
11
|
* owns its own argument parsing and printing via injected io.
|
|
@@ -84,7 +85,67 @@ interface PullArtifactResponse {
|
|
|
84
85
|
labels?: Array<{ label: string; runnerVersionId?: string; optimizerVersionId?: string }>
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
|
|
88
|
+
interface ArtifactListEntry {
|
|
89
|
+
id: string
|
|
90
|
+
name: string
|
|
91
|
+
description?: string | null
|
|
92
|
+
optimizerFamily?: string | null
|
|
93
|
+
latestVersionId?: string | null
|
|
94
|
+
versionCount?: number
|
|
95
|
+
labels?: Array<{ label: string; runnerVersionId?: string; optimizerVersionId?: string }>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function runnerOptimizerCommand(args: string[], io: PullArtifactIo) {
|
|
99
|
+
if (args[1] === 'list') {
|
|
100
|
+
await listArtifactsCommand(args, io)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
await pullArtifactCommand(args, io)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function listArtifactsCommand(args: string[], io: PullArtifactIo) {
|
|
107
|
+
const kind: PullableArtifactKind = args[0] === 'runners' ? 'runner' : 'optimizer'
|
|
108
|
+
const plural = kind === 'runner' ? 'runners' : 'optimizers'
|
|
109
|
+
const fetcher = io.fetcher || authedFetch
|
|
110
|
+
const project = argValue(args, '--project') || await io.resolveProjectSlug(null)
|
|
111
|
+
|
|
112
|
+
const response = await fetcher(`/api/cli/${plural}?project=${encodeURIComponent(project)}`)
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
throw new Error(`Failed to fetch ${plural}: ${await response.text()}`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const data = await parseJsonPayload<Record<string, ArtifactListEntry[]>>(response, `${plural} list`)
|
|
118
|
+
if (io.json) {
|
|
119
|
+
io.print(JSON.stringify(data))
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const items = data[plural] || []
|
|
124
|
+
if (items.length === 0) {
|
|
125
|
+
io.print(`No ${plural} found.`)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const rows = items.map(item => ({
|
|
130
|
+
id: sanitizeTerminalText(item.id),
|
|
131
|
+
name: sanitizeTerminalText(item.name),
|
|
132
|
+
versions: String(item.versionCount ?? 0),
|
|
133
|
+
latest: sanitizeTerminalText(item.latestVersionId || '-'),
|
|
134
|
+
labels: sanitizeTerminalText((item.labels || []).map(entry => entry.label).join(', ') || '-'),
|
|
135
|
+
}))
|
|
136
|
+
const idWidth = Math.max('ID'.length, ...rows.map(row => row.id.length))
|
|
137
|
+
const nameWidth = Math.max('NAME'.length, ...rows.map(row => row.name.length))
|
|
138
|
+
const versionsWidth = Math.max('VERSIONS'.length, ...rows.map(row => row.versions.length))
|
|
139
|
+
const latestWidth = Math.max('LATEST VERSION'.length, ...rows.map(row => row.latest.length))
|
|
140
|
+
|
|
141
|
+
io.print(`${'ID'.padEnd(idWidth)} ${'NAME'.padEnd(nameWidth)} ${'VERSIONS'.padEnd(versionsWidth)} ${'LATEST VERSION'.padEnd(latestWidth)} LABELS`)
|
|
142
|
+
io.print(`${'-'.repeat(idWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(versionsWidth)} ${'-'.repeat(latestWidth)} ${'-'.repeat('LABELS'.length)}`)
|
|
143
|
+
rows.forEach(row => {
|
|
144
|
+
io.print(`${row.id.padEnd(idWidth)} ${row.name.padEnd(nameWidth)} ${row.versions.padEnd(versionsWidth)} ${row.latest.padEnd(latestWidth)} ${row.labels}`)
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function pullArtifactCommand(args: string[], io: PullArtifactIo) {
|
|
88
149
|
const kind: PullableArtifactKind = args[0] === 'runners' ? 'runner' : 'optimizer'
|
|
89
150
|
const fetcher = io.fetcher || authedFetch
|
|
90
151
|
const positionalRef = args[2]
|
package/src/help.ts
CHANGED
|
@@ -369,6 +369,12 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
|
|
|
369
369
|
summary: 'Push a local runner artifact directory.',
|
|
370
370
|
group: 'Scorers and runners',
|
|
371
371
|
},
|
|
372
|
+
{
|
|
373
|
+
path: ['runners', 'list'],
|
|
374
|
+
usage: 'orizu runners list [--project <team/project>] [--json]',
|
|
375
|
+
summary: 'List runner artifacts with version counts, latest version ids, and labels.',
|
|
376
|
+
group: 'Scorers and runners',
|
|
377
|
+
},
|
|
372
378
|
{
|
|
373
379
|
path: ['runners', 'pull'],
|
|
374
380
|
usage: 'orizu runners pull <runner-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <version-id>] [--json]',
|
|
@@ -387,6 +393,12 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
|
|
|
387
393
|
summary: 'Push a local optimizer artifact directory.',
|
|
388
394
|
group: 'Optimizations',
|
|
389
395
|
},
|
|
396
|
+
{
|
|
397
|
+
path: ['optimizers', 'list'],
|
|
398
|
+
usage: 'orizu optimizers list [--project <team/project>] [--json]',
|
|
399
|
+
summary: 'List optimizer artifacts with version counts, latest version ids, and labels.',
|
|
400
|
+
group: 'Optimizations',
|
|
401
|
+
},
|
|
390
402
|
{
|
|
391
403
|
path: ['optimizers', 'pull'],
|
|
392
404
|
usage: 'orizu optimizers pull <optimizer-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <version-id>] [--json]',
|
|
@@ -49,6 +49,8 @@
|
|
|
49
49
|
* would break both ports and must be re-verified.
|
|
50
50
|
*/
|
|
51
51
|
|
|
52
|
+
import { readFileSync } from 'fs'
|
|
53
|
+
|
|
52
54
|
import type {
|
|
53
55
|
AgentHarness,
|
|
54
56
|
HarnessEvent,
|
|
@@ -781,24 +783,62 @@ export interface SpawnOpenCodeOptions extends OpenCodeConfigOptions {
|
|
|
781
783
|
/** Extra env merged over the config-content injection. */
|
|
782
784
|
env?: Record<string, string>
|
|
783
785
|
/** Injectable spawner (defaults to Bun.spawn) so this stays unit-testable. */
|
|
784
|
-
spawn?: (
|
|
786
|
+
spawn?: (
|
|
787
|
+
cmd: string[],
|
|
788
|
+
opts: { cwd?: string; env: Record<string, string>; logPath?: string }
|
|
789
|
+
) => { kill: () => void }
|
|
790
|
+
/** File the spawner should append opencode's stdout/stderr to. On readiness
|
|
791
|
+
* timeout its tail is included in the thrown error — the bare "fetch failed"
|
|
792
|
+
* this replaces was undiagnosable (ALI-1034). */
|
|
793
|
+
logPath?: string
|
|
794
|
+
/** Overall readiness budget. OpenCode's FIRST boot runs a one-time sqlite
|
|
795
|
+
* migration ("may take a few minutes") before it listens, so this must
|
|
796
|
+
* comfortably cover a cold snapshot. */
|
|
797
|
+
readyTimeoutMs?: number
|
|
798
|
+
/** Poll interval while waiting for the server to answer. */
|
|
799
|
+
readyPollMs?: number
|
|
800
|
+
/** Abort (e.g. run cancelled) while still waiting for readiness. */
|
|
801
|
+
signal?: AbortSignal
|
|
802
|
+
/** Injectable readiness probe (defaults to global fetch) for tests. */
|
|
803
|
+
fetchImpl?: typeof fetch
|
|
785
804
|
}
|
|
786
805
|
|
|
787
806
|
export interface SpawnedOpenCode {
|
|
788
807
|
baseUrl: string
|
|
789
808
|
port: number
|
|
790
809
|
stop: () => void
|
|
810
|
+
/** How long the server took to answer its first request (set by the real
|
|
811
|
+
* spawnOpenCode; injected test spawners may omit it). */
|
|
812
|
+
readyAfterMs?: number
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const OPENCODE_READY_TIMEOUT_MS = 180_000
|
|
816
|
+
const OPENCODE_READY_POLL_MS = 500
|
|
817
|
+
const OPENCODE_READY_PROBE_TIMEOUT_MS = 2_000
|
|
818
|
+
const OPENCODE_LOG_TAIL_BYTES = 2_000
|
|
819
|
+
|
|
820
|
+
function readLogTail(logPath: string | undefined): string | null {
|
|
821
|
+
if (!logPath) return null
|
|
822
|
+
try {
|
|
823
|
+
const content = readFileSync(logPath, 'utf8')
|
|
824
|
+
if (!content.trim()) return null
|
|
825
|
+
return content.slice(-OPENCODE_LOG_TAIL_BYTES).trim()
|
|
826
|
+
} catch {
|
|
827
|
+
return null
|
|
828
|
+
}
|
|
791
829
|
}
|
|
792
830
|
|
|
793
831
|
/**
|
|
794
|
-
* Launch `opencode serve` locally
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
799
|
-
*
|
|
832
|
+
* Launch `opencode serve` locally and WAIT until it answers HTTP. OpenCode's
|
|
833
|
+
* first boot performs a one-time sqlite migration before it listens; returning
|
|
834
|
+
* the baseUrl without waiting raced the harness's first `POST /session` into a
|
|
835
|
+
* connection-refused "fetch failed" (ALI-1034, found live in QA-3). Any HTTP
|
|
836
|
+
* status counts as ready — we only need the socket to be accepting. Process
|
|
837
|
+
* supervision is otherwise intentionally minimal for this slice; the pinned
|
|
838
|
+
* version (`OPENCODE_PINNED_VERSION`) is the one the sandbox image must install
|
|
839
|
+
* (see the SSE-fragility note on that constant).
|
|
800
840
|
*/
|
|
801
|
-
export function spawnOpenCode(opts: SpawnOpenCodeOptions): SpawnedOpenCode {
|
|
841
|
+
export async function spawnOpenCode(opts: SpawnOpenCodeOptions): Promise<SpawnedOpenCode> {
|
|
802
842
|
const port = opts.port ?? DEFAULT_OPENCODE_PORT
|
|
803
843
|
const env: Record<string, string> = {
|
|
804
844
|
OPENCODE_CONFIG_CONTENT: buildOpenCodeConfigContent(opts),
|
|
@@ -818,6 +858,47 @@ export function spawnOpenCode(opts: SpawnOpenCodeOptions): SpawnedOpenCode {
|
|
|
818
858
|
stderr: 'ignore',
|
|
819
859
|
})
|
|
820
860
|
})
|
|
821
|
-
const child = spawnImpl(cmd, { cwd: opts.cwd, env })
|
|
822
|
-
|
|
861
|
+
const child = spawnImpl(cmd, { cwd: opts.cwd, env, logPath: opts.logPath })
|
|
862
|
+
const baseUrl = `http://localhost:${port}`
|
|
863
|
+
|
|
864
|
+
const fetchImpl = opts.fetchImpl ?? (globalThis.fetch as typeof fetch)
|
|
865
|
+
const timeoutMs = opts.readyTimeoutMs ?? OPENCODE_READY_TIMEOUT_MS
|
|
866
|
+
const pollMs = opts.readyPollMs ?? OPENCODE_READY_POLL_MS
|
|
867
|
+
const startedAt = Date.now()
|
|
868
|
+
for (;;) {
|
|
869
|
+
if (opts.signal?.aborted) {
|
|
870
|
+
child.kill()
|
|
871
|
+
throw new Error('opencode serve readiness wait aborted')
|
|
872
|
+
}
|
|
873
|
+
try {
|
|
874
|
+
const probe = new AbortController()
|
|
875
|
+
const timer = setTimeout(() => probe.abort(), OPENCODE_READY_PROBE_TIMEOUT_MS)
|
|
876
|
+
// Link the run signal so an abort tears down an in-flight probe
|
|
877
|
+
// immediately instead of waiting out the per-probe timeout.
|
|
878
|
+
const probeSignal = opts.signal ? AbortSignal.any([opts.signal, probe.signal]) : probe.signal
|
|
879
|
+
try {
|
|
880
|
+
// Any HTTP response (even 404) proves the server is accepting.
|
|
881
|
+
const res = await fetchImpl(`${baseUrl}/session`, { signal: probeSignal })
|
|
882
|
+
await cancelBody(res.body)
|
|
883
|
+
break
|
|
884
|
+
} finally {
|
|
885
|
+
clearTimeout(timer)
|
|
886
|
+
}
|
|
887
|
+
} catch {
|
|
888
|
+
// not listening yet — fall through to the timeout check + sleep
|
|
889
|
+
}
|
|
890
|
+
// Soft budget: checked between probes, so the total wait can overrun by up
|
|
891
|
+
// to one probe timeout + poll interval. Negligible against the default.
|
|
892
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
893
|
+
child.kill()
|
|
894
|
+
const tail = readLogTail(opts.logPath)
|
|
895
|
+
throw new Error(
|
|
896
|
+
`opencode serve did not become ready on ${baseUrl} within ${timeoutMs}ms` +
|
|
897
|
+
(tail ? ` — log tail: ${tail}` : ' (no log output captured)')
|
|
898
|
+
)
|
|
899
|
+
}
|
|
900
|
+
await new Promise(resolve => setTimeout(resolve, pollMs))
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
return { baseUrl, port, stop: () => child.kill(), readyAfterMs: Date.now() - startedAt }
|
|
823
904
|
}
|
package/src/hosted-loop.ts
CHANGED
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
import { spawnSync, spawn as nodeSpawn } from 'child_process'
|
|
37
|
-
import { existsSync, readFileSync } from 'fs'
|
|
37
|
+
import { closeSync, existsSync, openSync, readFileSync } from 'fs'
|
|
38
|
+
import { tmpdir } from 'os'
|
|
38
39
|
import { join } from 'path'
|
|
39
40
|
|
|
40
41
|
import type { AgentHarness, HarnessEvent, HarnessPrompt } from './hosted-harness.js'
|
|
@@ -156,7 +157,7 @@ export interface RunHostedLoopOptions {
|
|
|
156
157
|
/** Build the in-process Claude-Agent-SDK harness (default: real SDK loader).
|
|
157
158
|
* Injectable so the swap test drives a fake `query()` with no real SDK. */
|
|
158
159
|
createClaudeHarness?: () => AgentHarness
|
|
159
|
-
spawnOpenCode?: (opts: SpawnOpenCodeOptions) => SpawnedOpenCode
|
|
160
|
+
spawnOpenCode?: (opts: SpawnOpenCodeOptions) => SpawnedOpenCode | Promise<SpawnedOpenCode>
|
|
160
161
|
installOpenCode?: () => Promise<InstallResult>
|
|
161
162
|
now?: () => number
|
|
162
163
|
signal?: AbortSignal
|
|
@@ -402,16 +403,36 @@ function installOpenCodePinned(version: string): InstallResult {
|
|
|
402
403
|
|
|
403
404
|
/** Node child_process spawner for `opencode serve` (the sandbox runtime is
|
|
404
405
|
* node24 — Bun.spawn is not guaranteed present, so spawnOpenCode is given this
|
|
405
|
-
* runtime-agnostic spawner).
|
|
406
|
+
* runtime-agnostic spawner). When a logPath is provided, opencode's
|
|
407
|
+
* stdout/stderr land there so a readiness timeout can surface the real boot
|
|
408
|
+
* failure instead of a bare "fetch failed" (ALI-1034). */
|
|
406
409
|
function nodeChildSpawner(
|
|
407
410
|
cmd: string[],
|
|
408
|
-
opts: { cwd?: string; env: Record<string, string
|
|
411
|
+
opts: { cwd?: string; env: Record<string, string>; logPath?: string }
|
|
409
412
|
): { kill: () => void } {
|
|
413
|
+
let stdio: 'ignore' | Array<'ignore' | number> = 'ignore'
|
|
414
|
+
let fd: number | null = null
|
|
415
|
+
if (opts.logPath) {
|
|
416
|
+
try {
|
|
417
|
+
fd = openSync(opts.logPath, 'a')
|
|
418
|
+
stdio = ['ignore', fd, fd]
|
|
419
|
+
} catch {
|
|
420
|
+
// log capture is best-effort — never block the spawn on it
|
|
421
|
+
}
|
|
422
|
+
}
|
|
410
423
|
const child = nodeSpawn(cmd[0], cmd.slice(1), {
|
|
411
424
|
cwd: opts.cwd,
|
|
412
425
|
env: { ...process.env, ...opts.env },
|
|
413
|
-
stdio
|
|
426
|
+
stdio,
|
|
414
427
|
})
|
|
428
|
+
if (fd !== null) {
|
|
429
|
+
// The child holds its own copy of the descriptor.
|
|
430
|
+
try {
|
|
431
|
+
closeSync(fd)
|
|
432
|
+
} catch {
|
|
433
|
+
// already closed
|
|
434
|
+
}
|
|
435
|
+
}
|
|
415
436
|
return { kill: () => child.kill() }
|
|
416
437
|
}
|
|
417
438
|
|
|
@@ -575,13 +596,25 @@ export async function runHostedLoop(opts: RunHostedLoopOptions): Promise<HostedL
|
|
|
575
596
|
}
|
|
576
597
|
|
|
577
598
|
const spawnImpl = opts.spawnOpenCode ?? spawnOpenCode
|
|
578
|
-
|
|
599
|
+
// The default spawnOpenCode WAITS for the server to answer HTTP before
|
|
600
|
+
// returning — OpenCode's first boot runs a one-time sqlite migration, and
|
|
601
|
+
// starting the harness before the server listens raced straight into a
|
|
602
|
+
// connection-refused "fetch failed" (ALI-1034).
|
|
603
|
+
spawned = await spawnImpl({
|
|
579
604
|
model: context.model,
|
|
580
605
|
cwd: context.workspaceDir,
|
|
581
606
|
port: context.opencodePort,
|
|
582
607
|
env: context.anthropicDummyKey ? { ANTHROPIC_API_KEY: context.anthropicDummyKey } : undefined,
|
|
583
608
|
spawn: nodeChildSpawner,
|
|
609
|
+
logPath: join(tmpdir(), `opencode-serve-${context.runId}.log`),
|
|
610
|
+
signal,
|
|
584
611
|
})
|
|
612
|
+
if (typeof spawned.readyAfterMs === 'number') {
|
|
613
|
+
await sink.append({
|
|
614
|
+
kind: 'artifact',
|
|
615
|
+
payload: { step: 'opencode_ready', ok: true, waitedMs: spawned.readyAfterMs },
|
|
616
|
+
})
|
|
617
|
+
}
|
|
585
618
|
|
|
586
619
|
harness = (opts.createHarness ?? ((baseUrl: string) => createOpenCodeHarness({ baseUrl })))(
|
|
587
620
|
spawned.baseUrl
|
package/src/index.ts
CHANGED
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
workspaceExists,
|
|
60
60
|
} from './workspace.js'
|
|
61
61
|
import { connectorsCommand } from './connectors-cli.js'
|
|
62
|
-
import { ARTIFACT_MAX_BYTES,
|
|
62
|
+
import { ARTIFACT_MAX_BYTES, runnerOptimizerCommand } from './artifact-pull.js'
|
|
63
63
|
import { killSwitchCommand } from './kill-switch-cli.js'
|
|
64
64
|
import { egressAllowlistCommand } from './egress-allowlist-cli.js'
|
|
65
65
|
import { manifestsCommand } from './manifests-cli.js'
|
|
@@ -6467,8 +6467,8 @@ export async function main(rawArgs = process.argv.slice(2)) {
|
|
|
6467
6467
|
return
|
|
6468
6468
|
}
|
|
6469
6469
|
|
|
6470
|
-
if ((command === 'runners' || command === 'optimizers') && subcommand === 'pull') {
|
|
6471
|
-
await
|
|
6470
|
+
if ((command === 'runners' || command === 'optimizers') && (subcommand === 'pull' || subcommand === 'list')) {
|
|
6471
|
+
await runnerOptimizerCommand(cliArgs, { json: hasJsonFlag(), print: printLine, resolveProjectSlug }); return
|
|
6472
6472
|
}
|
|
6473
6473
|
|
|
6474
6474
|
if (command === 'runners' && subcommand === 'exec') {
|