orizu 0.5.2 → 0.5.4

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,201 @@
1
+ /**
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.
9
+ *
10
+ * Lives outside index.ts per the CLI index line ratchet (ALI-976): this module
11
+ * owns its own argument parsing and printing via injected io.
12
+ */
13
+ import { spawnSync } from 'child_process';
14
+ import { createHash } from 'crypto';
15
+ import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'fs';
16
+ import { tmpdir } from 'os';
17
+ import { dirname, join } from 'path';
18
+ import { authedFetch } from './http.js';
19
+ // Shared with the runners exec download path in index.ts.
20
+ export const ARTIFACT_MAX_BYTES = 25 * 1024 * 1024;
21
+ function sanitizeTerminalText(value) {
22
+ return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '');
23
+ }
24
+ function expandHomePath(path) {
25
+ if (path.startsWith('~/')) {
26
+ const home = process.env.HOME || '';
27
+ return `${home}/${path.slice(2)}`;
28
+ }
29
+ return path;
30
+ }
31
+ function argValue(args, flag) {
32
+ const index = args.indexOf(flag);
33
+ if (index === -1 || index + 1 >= args.length || args[index + 1].startsWith('--')) {
34
+ return null;
35
+ }
36
+ return args[index + 1];
37
+ }
38
+ async function parseJsonPayload(response, context) {
39
+ const contentType = response.headers.get('content-type') || '';
40
+ const rawBody = await response.text();
41
+ if (!contentType.includes('application/json')) {
42
+ throw new Error(`${context} returned non-JSON response (status ${response.status}). ` +
43
+ `Body preview: ${sanitizeTerminalText(rawBody.slice(0, 180))}`);
44
+ }
45
+ try {
46
+ return JSON.parse(rawBody);
47
+ }
48
+ catch {
49
+ throw new Error(`${context} returned invalid JSON (status ${response.status}). ` +
50
+ `Body preview: ${sanitizeTerminalText(rawBody.slice(0, 180))}`);
51
+ }
52
+ }
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) {
97
+ const kind = args[0] === 'runners' ? 'runner' : 'optimizer';
98
+ const fetcher = io.fetcher || authedFetch;
99
+ const positionalRef = args[2];
100
+ const artifactRef = positionalRef && !positionalRef.startsWith('--') ? positionalRef : null;
101
+ const outDir = argValue(args, '--out');
102
+ const label = argValue(args, '--label');
103
+ const version = argValue(args, '--version');
104
+ // Argument errors surface before project resolution, which may prompt
105
+ // interactively when --project is omitted.
106
+ const plural = kind === 'runner' ? 'runners' : 'optimizers';
107
+ if (!artifactRef || !outDir) {
108
+ throw new Error(`Usage: orizu ${plural} pull <${kind}-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <version-id>] [--json]`);
109
+ }
110
+ if (label && version) {
111
+ throw new Error('Use either --label or --version, not both');
112
+ }
113
+ const project = argValue(args, '--project') || await io.resolveProjectSlug(null);
114
+ const params = new URLSearchParams({ project });
115
+ if (label)
116
+ params.set('label', label);
117
+ if (version)
118
+ params.set('version', version);
119
+ const response = await fetcher(`/api/cli/${plural}/${encodeURIComponent(artifactRef)}?${params.toString()}`);
120
+ if (!response.ok) {
121
+ throw new Error(`Failed to pull ${kind}: ${await response.text()}`);
122
+ }
123
+ const data = await parseJsonPayload(response, `${kind} pull`);
124
+ const artifact = kind === 'runner' ? data.runner : data.optimizer;
125
+ if (!artifact) {
126
+ throw new Error(`Failed to pull ${kind}: malformed response`);
127
+ }
128
+ // Every stored version is content-addressed; a missing hash would silently
129
+ // void the integrity check and the pull.json anchor, so fail instead.
130
+ if (!data.version.contentSha256) {
131
+ throw new Error(`Failed to pull ${kind}: version ${data.version.id} has no content hash`);
132
+ }
133
+ const downloadResponse = await fetcher(`/api/cli/${kind}-versions/${encodeURIComponent(data.version.id)}/download`);
134
+ if (!downloadResponse.ok) {
135
+ throw new Error(`Failed to download ${kind} artifact: ${await downloadResponse.text()}`);
136
+ }
137
+ const zipBytes = new Uint8Array(await downloadResponse.arrayBuffer());
138
+ if (zipBytes.byteLength > ARTIFACT_MAX_BYTES) {
139
+ throw new Error(`${kind} artifact exceeds ${ARTIFACT_MAX_BYTES} bytes`);
140
+ }
141
+ // Versions are content-addressed by zip SHA256 (see the push routes), so a
142
+ // corrupted or mismatched download is detectable before anything is written.
143
+ const actualSha256 = createHash('sha256').update(zipBytes).digest('hex');
144
+ if (actualSha256 !== data.version.contentSha256.toLowerCase()) {
145
+ throw new Error(`${kind} artifact hash mismatch: expected ${data.version.contentSha256}, got ${actualSha256}`);
146
+ }
147
+ // Extraction is additive, so a dirty directory would mix files from other
148
+ // versions and break the pull -> identical re-push hash guarantee.
149
+ const targetDir = expandHomePath(outDir);
150
+ mkdirSync(targetDir, { recursive: true });
151
+ if (readdirSync(targetDir).length > 0) {
152
+ throw new Error(`Output directory is not empty: ${targetDir}`);
153
+ }
154
+ const tempDir = mkdtempSync(join(tmpdir(), `orizu-${kind}-pull-`));
155
+ try {
156
+ const zipPath = join(tempDir, 'artifact.zip');
157
+ writeFileSync(zipPath, zipBytes);
158
+ // Artifacts are self-authored, hash-verified above, and served only to
159
+ // authenticated project members; Info-ZIP additionally strips ../ path
160
+ // components on extraction (same trust model as materializeRunnerVersion).
161
+ const result = spawnSync('unzip', ['-q', zipPath, '-d', targetDir], {
162
+ encoding: 'utf8',
163
+ });
164
+ if (result.error || result.status !== 0) {
165
+ // targetDir was verified empty above, so everything in it came from
166
+ // this extraction — clear it so a re-pull isn't rejected as non-empty.
167
+ for (const entry of readdirSync(targetDir)) {
168
+ rmSync(join(targetDir, entry), { recursive: true, force: true });
169
+ }
170
+ if (result.error) {
171
+ throw result.error;
172
+ }
173
+ throw new Error(`unzip failed: ${sanitizeTerminalText(result.stderr || result.stdout || '')}`);
174
+ }
175
+ }
176
+ finally {
177
+ rmSync(tempDir, { recursive: true, force: true });
178
+ }
179
+ const versionLabels = (data.labels || [])
180
+ .filter(item => (kind === 'runner' ? item.runnerVersionId : item.optimizerVersionId) === data.version.id)
181
+ .map(item => item.label);
182
+ const pullMetadataPath = join(targetDir, '.orizu', 'pull.json');
183
+ mkdirSync(dirname(pullMetadataPath), { recursive: true });
184
+ writeFileSync(pullMetadataPath, `${JSON.stringify({
185
+ schema_version: `orizu.${kind}-pull.v1`,
186
+ name: artifact.name,
187
+ [`${kind}_id`]: artifact.id,
188
+ version_id: data.version.id,
189
+ content_sha256: data.version.contentSha256,
190
+ labels: versionLabels,
191
+ }, null, 2)}\n`);
192
+ if (io.json) {
193
+ io.print(JSON.stringify({
194
+ [`${kind}_id`]: artifact.id,
195
+ [`${kind}_version_id`]: data.version.id,
196
+ path: targetDir,
197
+ }));
198
+ return;
199
+ }
200
+ io.print(`Pulled ${kind} ${sanitizeTerminalText(artifact.name)} to ${sanitizeTerminalText(targetDir)}`);
201
+ }
package/dist/help.js CHANGED
@@ -344,6 +344,18 @@ 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
+ },
353
+ {
354
+ path: ['runners', 'pull'],
355
+ usage: 'orizu runners pull <runner-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <version-id>] [--json]',
356
+ summary: 'Pull a runner artifact version to a local directory.',
357
+ group: 'Scorers and runners',
358
+ },
347
359
  {
348
360
  path: ['runners', 'exec'],
349
361
  usage: 'orizu runners exec (--prompt <prompt-version-id> | --prompt-version <id> --runner-version <id> | --scorer-version <id>) --dataset-version <id> --split-set <id-or-name> --split <name> [--runner-dir <dir>] --out <results.jsonl|results.jsonl.gz>',
@@ -356,6 +368,18 @@ export const COMMAND_DOCS = [
356
368
  summary: 'Push a local optimizer artifact directory.',
357
369
  group: 'Optimizations',
358
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
+ },
377
+ {
378
+ path: ['optimizers', 'pull'],
379
+ usage: 'orizu optimizers pull <optimizer-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <version-id>] [--json]',
380
+ summary: 'Pull an optimizer artifact version to a local directory.',
381
+ group: 'Optimizations',
382
+ },
359
383
  {
360
384
  path: ['runs', 'submit'],
361
385
  usage: 'orizu runs submit <results.jsonl|results.jsonl.gz> --prompt-version <id> --runner-version <id> --dataset-version <id> --split-set <id> --split <name> [--project <team/project>]',
package/dist/index.js CHANGED
@@ -27,6 +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, runnerOptimizerCommand } from './artifact-pull.js';
30
31
  import { killSwitchCommand } from './kill-switch-cli.js';
31
32
  import { egressAllowlistCommand } from './egress-allowlist-cli.js';
32
33
  import { manifestsCommand } from './manifests-cli.js';
@@ -4595,7 +4596,6 @@ function readRunnerManifest(runnerDir) {
4595
4596
  }
4596
4597
  const RUNNER_TIMEOUT_MS = 120_000;
4597
4598
  const RUNNER_OUTPUT_MAX_BYTES = 2 * 1024 * 1024;
4598
- const RUNNER_ARTIFACT_MAX_BYTES = 25 * 1024 * 1024;
4599
4599
  const RUNNER_ENV_ALLOWLIST = new Set([
4600
4600
  'PATH',
4601
4601
  'SystemRoot',
@@ -4643,9 +4643,9 @@ async function materializeRunnerVersion(runnerVersionId) {
4643
4643
  const zipPath = join(tempDir, 'runner.zip');
4644
4644
  const runnerDir = join(tempDir, 'runner');
4645
4645
  const zipBytes = new Uint8Array(await response.arrayBuffer());
4646
- if (zipBytes.byteLength > RUNNER_ARTIFACT_MAX_BYTES) {
4646
+ if (zipBytes.byteLength > ARTIFACT_MAX_BYTES) {
4647
4647
  rmSync(tempDir, { recursive: true, force: true });
4648
- throw new Error(`Runner artifact exceeds ${RUNNER_ARTIFACT_MAX_BYTES} bytes`);
4648
+ throw new Error(`Runner artifact exceeds ${ARTIFACT_MAX_BYTES} bytes`);
4649
4649
  }
4650
4650
  writeFileSync(zipPath, zipBytes);
4651
4651
  const result = spawnSync('unzip', ['-q', zipPath, '-d', runnerDir], {
@@ -4997,16 +4997,16 @@ export async function main(rawArgs = process.argv.slice(2)) {
4997
4997
  await execScorer();
4998
4998
  return;
4999
4999
  }
5000
- if (command === 'runners' && subcommand === 'push') {
5001
- await pushRunnerArtifact('runner');
5000
+ if ((command === 'runners' || command === 'optimizers') && subcommand === 'push') {
5001
+ await pushRunnerArtifact(command === 'runners' ? 'runner' : 'optimizer');
5002
5002
  return;
5003
5003
  }
5004
- if (command === 'runners' && subcommand === 'exec') {
5005
- await runnersExec();
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
- if (command === 'optimizers' && subcommand === 'push') {
5009
- await pushRunnerArtifact('optimizer');
5008
+ if (command === 'runners' && subcommand === 'exec') {
5009
+ await runnersExec();
5010
5010
  return;
5011
5011
  }
5012
5012
  if (command === 'runs' && subcommand === 'submit') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orizu",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,264 @@
1
+ /**
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.
9
+ *
10
+ * Lives outside index.ts per the CLI index line ratchet (ALI-976): this module
11
+ * owns its own argument parsing and printing via injected io.
12
+ */
13
+
14
+ import { spawnSync } from 'child_process'
15
+ import { createHash } from 'crypto'
16
+ import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'fs'
17
+ import { tmpdir } from 'os'
18
+ import { dirname, join } from 'path'
19
+
20
+ import { authedFetch } from './http.js'
21
+
22
+ export type PullableArtifactKind = 'runner' | 'optimizer'
23
+
24
+ export interface PullArtifactIo {
25
+ json: boolean
26
+ print: (line: string) => void
27
+ resolveProjectSlug: (projectArg: string | null) => Promise<string>
28
+ fetcher?: (path: string, init?: RequestInit) => Promise<Response>
29
+ }
30
+
31
+ // Shared with the runners exec download path in index.ts.
32
+ export const ARTIFACT_MAX_BYTES = 25 * 1024 * 1024
33
+
34
+ function sanitizeTerminalText(value: unknown): string {
35
+ return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '')
36
+ }
37
+
38
+ function expandHomePath(path: string): string {
39
+ if (path.startsWith('~/')) {
40
+ const home = process.env.HOME || ''
41
+ return `${home}/${path.slice(2)}`
42
+ }
43
+
44
+ return path
45
+ }
46
+
47
+ function argValue(args: string[], flag: string): string | null {
48
+ const index = args.indexOf(flag)
49
+ if (index === -1 || index + 1 >= args.length || args[index + 1].startsWith('--')) {
50
+ return null
51
+ }
52
+ return args[index + 1]
53
+ }
54
+
55
+ async function parseJsonPayload<T>(response: Response, context: string): Promise<T> {
56
+ const contentType = response.headers.get('content-type') || ''
57
+ const rawBody = await response.text()
58
+
59
+ if (!contentType.includes('application/json')) {
60
+ throw new Error(
61
+ `${context} returned non-JSON response (status ${response.status}). ` +
62
+ `Body preview: ${sanitizeTerminalText(rawBody.slice(0, 180))}`
63
+ )
64
+ }
65
+
66
+ try {
67
+ return JSON.parse(rawBody) as T
68
+ } catch {
69
+ throw new Error(
70
+ `${context} returned invalid JSON (status ${response.status}). ` +
71
+ `Body preview: ${sanitizeTerminalText(rawBody.slice(0, 180))}`
72
+ )
73
+ }
74
+ }
75
+
76
+ interface PullArtifactResponse {
77
+ runner?: { id: string; name: string }
78
+ optimizer?: { id: string; name: string }
79
+ version: {
80
+ id: string
81
+ contentSha256?: string
82
+ manifest?: Record<string, unknown>
83
+ uploadedAt?: string
84
+ }
85
+ labels?: Array<{ label: string; runnerVersionId?: string; optimizerVersionId?: string }>
86
+ }
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) {
149
+ const kind: PullableArtifactKind = args[0] === 'runners' ? 'runner' : 'optimizer'
150
+ const fetcher = io.fetcher || authedFetch
151
+ const positionalRef = args[2]
152
+ const artifactRef = positionalRef && !positionalRef.startsWith('--') ? positionalRef : null
153
+ const outDir = argValue(args, '--out')
154
+ const label = argValue(args, '--label')
155
+ const version = argValue(args, '--version')
156
+
157
+ // Argument errors surface before project resolution, which may prompt
158
+ // interactively when --project is omitted.
159
+ const plural = kind === 'runner' ? 'runners' : 'optimizers'
160
+ if (!artifactRef || !outDir) {
161
+ throw new Error(`Usage: orizu ${plural} pull <${kind}-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <version-id>] [--json]`)
162
+ }
163
+ if (label && version) {
164
+ throw new Error('Use either --label or --version, not both')
165
+ }
166
+
167
+ const project = argValue(args, '--project') || await io.resolveProjectSlug(null)
168
+
169
+ const params = new URLSearchParams({ project })
170
+ if (label) params.set('label', label)
171
+ if (version) params.set('version', version)
172
+
173
+ const response = await fetcher(`/api/cli/${plural}/${encodeURIComponent(artifactRef)}?${params.toString()}`)
174
+ if (!response.ok) {
175
+ throw new Error(`Failed to pull ${kind}: ${await response.text()}`)
176
+ }
177
+
178
+ const data = await parseJsonPayload<PullArtifactResponse>(response, `${kind} pull`)
179
+ const artifact = kind === 'runner' ? data.runner : data.optimizer
180
+ if (!artifact) {
181
+ throw new Error(`Failed to pull ${kind}: malformed response`)
182
+ }
183
+ // Every stored version is content-addressed; a missing hash would silently
184
+ // void the integrity check and the pull.json anchor, so fail instead.
185
+ if (!data.version.contentSha256) {
186
+ throw new Error(`Failed to pull ${kind}: version ${data.version.id} has no content hash`)
187
+ }
188
+
189
+ const downloadResponse = await fetcher(`/api/cli/${kind}-versions/${encodeURIComponent(data.version.id)}/download`)
190
+ if (!downloadResponse.ok) {
191
+ throw new Error(`Failed to download ${kind} artifact: ${await downloadResponse.text()}`)
192
+ }
193
+
194
+ const zipBytes = new Uint8Array(await downloadResponse.arrayBuffer())
195
+ if (zipBytes.byteLength > ARTIFACT_MAX_BYTES) {
196
+ throw new Error(`${kind} artifact exceeds ${ARTIFACT_MAX_BYTES} bytes`)
197
+ }
198
+
199
+ // Versions are content-addressed by zip SHA256 (see the push routes), so a
200
+ // corrupted or mismatched download is detectable before anything is written.
201
+ const actualSha256 = createHash('sha256').update(zipBytes).digest('hex')
202
+ if (actualSha256 !== data.version.contentSha256.toLowerCase()) {
203
+ throw new Error(`${kind} artifact hash mismatch: expected ${data.version.contentSha256}, got ${actualSha256}`)
204
+ }
205
+
206
+ // Extraction is additive, so a dirty directory would mix files from other
207
+ // versions and break the pull -> identical re-push hash guarantee.
208
+ const targetDir = expandHomePath(outDir)
209
+ mkdirSync(targetDir, { recursive: true })
210
+ if (readdirSync(targetDir).length > 0) {
211
+ throw new Error(`Output directory is not empty: ${targetDir}`)
212
+ }
213
+
214
+ const tempDir = mkdtempSync(join(tmpdir(), `orizu-${kind}-pull-`))
215
+ try {
216
+ const zipPath = join(tempDir, 'artifact.zip')
217
+ writeFileSync(zipPath, zipBytes)
218
+ // Artifacts are self-authored, hash-verified above, and served only to
219
+ // authenticated project members; Info-ZIP additionally strips ../ path
220
+ // components on extraction (same trust model as materializeRunnerVersion).
221
+ const result = spawnSync('unzip', ['-q', zipPath, '-d', targetDir], {
222
+ encoding: 'utf8',
223
+ })
224
+ if (result.error || result.status !== 0) {
225
+ // targetDir was verified empty above, so everything in it came from
226
+ // this extraction — clear it so a re-pull isn't rejected as non-empty.
227
+ for (const entry of readdirSync(targetDir)) {
228
+ rmSync(join(targetDir, entry), { recursive: true, force: true })
229
+ }
230
+ if (result.error) {
231
+ throw result.error
232
+ }
233
+ throw new Error(`unzip failed: ${sanitizeTerminalText(result.stderr || result.stdout || '')}`)
234
+ }
235
+ } finally {
236
+ rmSync(tempDir, { recursive: true, force: true })
237
+ }
238
+
239
+ const versionLabels = (data.labels || [])
240
+ .filter(item => (kind === 'runner' ? item.runnerVersionId : item.optimizerVersionId) === data.version.id)
241
+ .map(item => item.label)
242
+
243
+ const pullMetadataPath = join(targetDir, '.orizu', 'pull.json')
244
+ mkdirSync(dirname(pullMetadataPath), { recursive: true })
245
+ writeFileSync(pullMetadataPath, `${JSON.stringify({
246
+ schema_version: `orizu.${kind}-pull.v1`,
247
+ name: artifact.name,
248
+ [`${kind}_id`]: artifact.id,
249
+ version_id: data.version.id,
250
+ content_sha256: data.version.contentSha256,
251
+ labels: versionLabels,
252
+ }, null, 2)}\n`)
253
+
254
+ if (io.json) {
255
+ io.print(JSON.stringify({
256
+ [`${kind}_id`]: artifact.id,
257
+ [`${kind}_version_id`]: data.version.id,
258
+ path: targetDir,
259
+ }))
260
+ return
261
+ }
262
+
263
+ io.print(`Pulled ${kind} ${sanitizeTerminalText(artifact.name)} to ${sanitizeTerminalText(targetDir)}`)
264
+ }
package/src/help.ts CHANGED
@@ -369,6 +369,18 @@ 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
+ },
378
+ {
379
+ path: ['runners', 'pull'],
380
+ usage: 'orizu runners pull <runner-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <version-id>] [--json]',
381
+ summary: 'Pull a runner artifact version to a local directory.',
382
+ group: 'Scorers and runners',
383
+ },
372
384
  {
373
385
  path: ['runners', 'exec'],
374
386
  usage: 'orizu runners exec (--prompt <prompt-version-id> | --prompt-version <id> --runner-version <id> | --scorer-version <id>) --dataset-version <id> --split-set <id-or-name> --split <name> [--runner-dir <dir>] --out <results.jsonl|results.jsonl.gz>',
@@ -381,6 +393,18 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
381
393
  summary: 'Push a local optimizer artifact directory.',
382
394
  group: 'Optimizations',
383
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
+ },
402
+ {
403
+ path: ['optimizers', 'pull'],
404
+ usage: 'orizu optimizers pull <optimizer-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <version-id>] [--json]',
405
+ summary: 'Pull an optimizer artifact version to a local directory.',
406
+ group: 'Optimizations',
407
+ },
384
408
  {
385
409
  path: ['runs', 'submit'],
386
410
  usage: 'orizu runs submit <results.jsonl|results.jsonl.gz> --prompt-version <id> --runner-version <id> --dataset-version <id> --split-set <id> --split <name> [--project <team/project>]',
package/src/index.ts CHANGED
@@ -59,6 +59,7 @@ import {
59
59
  workspaceExists,
60
60
  } from './workspace.js'
61
61
  import { connectorsCommand } from './connectors-cli.js'
62
+ import { ARTIFACT_MAX_BYTES, runnerOptimizerCommand } from './artifact-pull.js'
62
63
  import { killSwitchCommand } from './kill-switch-cli.js'
63
64
  import { egressAllowlistCommand } from './egress-allowlist-cli.js'
64
65
  import { manifestsCommand } from './manifests-cli.js'
@@ -5994,7 +5995,6 @@ function readRunnerManifest(runnerDir: string): { command: string[]; supports_bo
5994
5995
 
5995
5996
  const RUNNER_TIMEOUT_MS = 120_000
5996
5997
  const RUNNER_OUTPUT_MAX_BYTES = 2 * 1024 * 1024
5997
- const RUNNER_ARTIFACT_MAX_BYTES = 25 * 1024 * 1024
5998
5998
  const RUNNER_ENV_ALLOWLIST = new Set([
5999
5999
  'PATH',
6000
6000
  'SystemRoot',
@@ -6045,9 +6045,9 @@ async function materializeRunnerVersion(runnerVersionId: string): Promise<{ runn
6045
6045
  const zipPath = join(tempDir, 'runner.zip')
6046
6046
  const runnerDir = join(tempDir, 'runner')
6047
6047
  const zipBytes = new Uint8Array(await response.arrayBuffer())
6048
- if (zipBytes.byteLength > RUNNER_ARTIFACT_MAX_BYTES) {
6048
+ if (zipBytes.byteLength > ARTIFACT_MAX_BYTES) {
6049
6049
  rmSync(tempDir, { recursive: true, force: true })
6050
- throw new Error(`Runner artifact exceeds ${RUNNER_ARTIFACT_MAX_BYTES} bytes`)
6050
+ throw new Error(`Runner artifact exceeds ${ARTIFACT_MAX_BYTES} bytes`)
6051
6051
  }
6052
6052
  writeFileSync(zipPath, zipBytes)
6053
6053
 
@@ -6462,18 +6462,17 @@ export async function main(rawArgs = process.argv.slice(2)) {
6462
6462
  return
6463
6463
  }
6464
6464
 
6465
- if (command === 'runners' && subcommand === 'push') {
6466
- await pushRunnerArtifact('runner')
6465
+ if ((command === 'runners' || command === 'optimizers') && subcommand === 'push') {
6466
+ await pushRunnerArtifact(command === 'runners' ? 'runner' : 'optimizer')
6467
6467
  return
6468
6468
  }
6469
6469
 
6470
- if (command === 'runners' && subcommand === 'exec') {
6471
- await runnersExec()
6472
- return
6470
+ if ((command === 'runners' || command === 'optimizers') && (subcommand === 'pull' || subcommand === 'list')) {
6471
+ await runnerOptimizerCommand(cliArgs, { json: hasJsonFlag(), print: printLine, resolveProjectSlug }); return
6473
6472
  }
6474
6473
 
6475
- if (command === 'optimizers' && subcommand === 'push') {
6476
- await pushRunnerArtifact('optimizer')
6474
+ if (command === 'runners' && subcommand === 'exec') {
6475
+ await runnersExec()
6477
6476
  return
6478
6477
  }
6479
6478