@tuned-tensor/local 0.2.6 → 0.2.8

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +149 -0
  3. package/dist/artifacts.d.ts +92 -0
  4. package/dist/artifacts.js +591 -3
  5. package/dist/artifacts.js.map +1 -1
  6. package/dist/contracts.d.ts +5 -2
  7. package/dist/contracts.js +15 -10
  8. package/dist/contracts.js.map +1 -1
  9. package/dist/dataset.d.ts +3 -0
  10. package/dist/dataset.js +87 -5
  11. package/dist/dataset.js.map +1 -1
  12. package/dist/doctor.d.ts +13 -2
  13. package/dist/doctor.js +372 -49
  14. package/dist/doctor.js.map +1 -1
  15. package/dist/evaluation.d.ts +15 -0
  16. package/dist/evaluation.js +120 -16
  17. package/dist/evaluation.js.map +1 -1
  18. package/dist/huggingface-cache.d.ts +26 -0
  19. package/dist/huggingface-cache.js +68 -0
  20. package/dist/huggingface-cache.js.map +1 -0
  21. package/dist/index.d.ts +4 -0
  22. package/dist/index.js +766 -73
  23. package/dist/index.js.map +1 -1
  24. package/dist/local-project.d.ts +11 -0
  25. package/dist/local-project.js +96 -3
  26. package/dist/local-project.js.map +1 -1
  27. package/dist/model-registry.d.ts +21 -0
  28. package/dist/model-registry.js +198 -0
  29. package/dist/model-registry.js.map +1 -1
  30. package/dist/model-server.d.ts +32 -0
  31. package/dist/model-server.js +158 -0
  32. package/dist/model-server.js.map +1 -0
  33. package/dist/orchestrator.d.ts +3 -0
  34. package/dist/orchestrator.js +1001 -142
  35. package/dist/orchestrator.js.map +1 -1
  36. package/dist/prefetch.d.ts +43 -0
  37. package/dist/prefetch.js +192 -0
  38. package/dist/prefetch.js.map +1 -0
  39. package/dist/process-runner.d.ts +7 -0
  40. package/dist/process-runner.js +171 -16
  41. package/dist/process-runner.js.map +1 -1
  42. package/dist/process-training.d.ts +5 -1
  43. package/dist/process-training.js +32 -16
  44. package/dist/process-training.js.map +1 -1
  45. package/dist/run-reporter.d.ts +2 -0
  46. package/dist/run-reporter.js +10 -0
  47. package/dist/run-reporter.js.map +1 -1
  48. package/dist/store.d.ts +16 -2
  49. package/dist/store.js +189 -24
  50. package/dist/store.js.map +1 -1
  51. package/docs/architecture.md +32 -0
  52. package/docs/local-workflow-remediation-2026-07-13.md +130 -0
  53. package/docs/local-workflow-ux-review-2026-07-13.md +458 -0
  54. package/docs/spark.md +10 -0
  55. package/package.json +3 -1
  56. package/training/local-runner/pyproject.toml +1 -0
  57. package/training/local-runner/src/evaluate.py +80 -20
  58. package/training/local-runner/src/prefetch.py +178 -0
  59. package/training/local-runner/src/serve.py +329 -0
  60. package/training/local-runner/src/train.py +47 -22
  61. package/training/local-runner/src/train_dpo.py +41 -25
  62. package/training/local-runner/uv.lock +2401 -0
package/dist/index.js CHANGED
@@ -1,36 +1,55 @@
1
1
  #!/usr/bin/env node
2
- import { realpathSync } from "node:fs";
3
- import { readFile } from "node:fs/promises";
4
- import { resolve } from "node:path";
2
+ import { readFileSync, realpathSync } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { mkdir, open, readFile, stat } from "node:fs/promises";
5
+ import { spawn } from "node:child_process";
6
+ import { basename, dirname, join, resolve } from "node:path";
5
7
  import { cwd } from "node:process";
6
8
  import { fileURLToPath } from "node:url";
7
9
  import { compareRuns } from "./compare.js";
10
+ import { assertArtifactManifest, claimRunArtifactDirectory, defaultArtifactPrefix, resolveRunArtifacts } from "./artifacts.js";
8
11
  import { fineTuneRunRequestSchema, localBehaviorSpecFileSchema, localRunnerConfigSchema, specSnapshotSchema } from "./contracts.js";
9
12
  import { buildSystemMessage } from "./dataset.js";
13
+ import { assertEvaluationScoringReady } from "./evaluation.js";
10
14
  import { runLocalLabelingJob } from "./labeling.js";
11
- import { loadLocalRunnerConfig, runLocalFineTuneStage, } from "./orchestrator.js";
15
+ import { loadLocalRunnerConfig, fingerprintLocalBaseModel, runLocalFineTuneStage, } from "./orchestrator.js";
12
16
  import { runDoctor } from "./doctor.js";
13
- import { resolveTrainingModel } from "./model-registry.js";
14
- import { createLocalStore } from "./store.js";
17
+ import { assertUsableModelArtifact, resolveTrainingModel } from "./model-registry.js";
18
+ import { buildLocalModelServerLaunch, serveLocalModel } from "./model-server.js";
19
+ import { prefetchBaseModel } from "./prefetch.js";
20
+ import { createLocalStore, isTerminalRunState } from "./store.js";
15
21
  import { serveLocalDashboard } from "./server.js";
16
- import { DEFAULT_LOCAL_SPEC_PATH, initLocalSpecFile, loadLocalRunInput, runRequestFromLocalSpec, } from "./local-project.js";
22
+ import { DEFAULT_LOCAL_SPEC_PATH, assertLocalRunInputReady, initLocalRunnerConfigFile, initLocalSpecFile, loadLocalRunInput, runRequestFromLocalSpec, } from "./local-project.js";
17
23
  import { sanitizeLogLine } from "./run-reporter.js";
18
24
  export * from "./compare.js";
19
25
  export * from "./contracts.js";
20
26
  export * from "./dataset.js";
21
27
  export * from "./labeling.js";
22
28
  export * from "./labeling-sanitize.js";
29
+ export * from "./model-server.js";
23
30
  export * from "./orchestrator.js";
24
31
  export * from "./local-project.js";
25
32
  export * from "./openrouter.js";
33
+ export * from "./prefetch.js";
26
34
  export * from "./run-reporter.js";
27
35
  export * from "./server.js";
28
36
  export * from "./store.js";
37
+ function packageVersion() {
38
+ try {
39
+ const packageJson = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
40
+ return typeof packageJson.version === "string" ? packageJson.version : "unknown";
41
+ }
42
+ catch {
43
+ return "unknown";
44
+ }
45
+ }
46
+ export const TT_LOCAL_VERSION = packageVersion();
29
47
  export function getLocalRunnerInfo() {
30
48
  return {
31
49
  name: "tuned-tensor-local",
32
50
  status: "local-runner-preview",
33
51
  description: "Local fine-tuning runner for single-GPU uv/Python hosts.",
52
+ version: TT_LOCAL_VERSION,
34
53
  };
35
54
  }
36
55
  /**
@@ -79,72 +98,343 @@ async function loadDotEnvFromCwd() {
79
98
  }
80
99
  function readOption(argv, name) {
81
100
  const index = argv.indexOf(name);
82
- if (index === -1)
83
- return undefined;
84
- return argv[index + 1];
101
+ if (index !== -1)
102
+ return argv[index + 1];
103
+ const inline = argv.find((value) => value.startsWith(`${name}=`));
104
+ return inline?.slice(name.length + 1);
85
105
  }
86
106
  function hasFlag(argv, name) {
87
107
  return argv.includes(name);
88
108
  }
89
109
  function printHelp() {
90
- console.log(`tt-local
110
+ console.log(`Usage: tt-local <command> [options]
91
111
 
92
112
  Commands:
93
- info
94
- init [--name "My Local Model"] [--model Qwen/Qwen3.5-2B] [--output tunedtensor.json] [--force]
95
- doctor [--config local-runner.json]
113
+ info Show package and runner information
114
+ init [--name "My Local Model"] [--model Qwen/Qwen3.5-2B] [--output tunedtensor.json] [--profile spark] [--force]
115
+ doctor [tunedtensor.json|request.json] [--config local-runner.json]
96
116
  validate [tunedtensor.json|request.json] [--config local-runner.json]
97
117
  run [tunedtensor.json|request.json] [--config local-runner.json] [--stage all|prepare|baseline|train|candidate|score|report]
98
118
  [--run-id uuid] [--parent-model local-model-id] [--parent-model-artifact path-or-file-uri]
99
- [--model-artifact path-or-file-uri] [--force] [--dry-run] [--verbose] [--quiet]
119
+ [--model-artifact path-or-file-uri] [--force] [--dry-run] [--detach] [--verbose] [--quiet]
100
120
  label <input.jsonl|input.csv> [--input-column col] [--spec tunedtensor.json] [--system-prompt "..."]
101
121
  [--model teacher-model] [--output labeled.jsonl] [--config local-runner.json] [--dry-run]
102
122
  serve [--config local-runner.json] [--host 127.0.0.1] [--port 8787]
103
123
  runs list|get|events|watch|report|compare|cancel|reconcile [args] [--config local-runner.json]
104
- models list|get [args] [--config local-runner.json]
124
+ models list|get|verify|prefetch|verify-base|serve [args] [--config local-runner.json]
105
125
  specs list|get|import [args] [--config local-runner.json]
106
126
  store rebuild-index [--config local-runner.json]
107
127
 
128
+ Global options:
129
+ -h, --help Show help
130
+ -V, --version Show the installed version
131
+
108
132
  The run command writes local artifacts under config.artifactRoot, defaulting to
109
133
  .tt-local/artifacts. The file-backed local store defaults to
110
134
  ~/.tuned-tensor-local unless config.storeRoot or TT_LOCAL_HOME is set.`);
111
135
  }
112
- const optionNamesWithValues = new Set([
113
- "--config",
114
- "--host",
115
- "--port",
116
- "--name",
117
- "--model",
118
- "--output",
119
- "--id",
120
- "--user-id",
121
- "--run-number",
122
- "--run-id",
123
- "--stage",
124
- "--model-artifact",
125
- "--parent-model",
126
- "--parent-model-artifact",
127
- "--input-column",
128
- "--system-prompt",
129
- "--spec",
130
- "--format",
131
- ]);
132
- function readPositionals(argv, startIndex = 3) {
136
+ const CONFIG_OPTION = { name: "--config", value: "path", description: "Local runner config JSON path" };
137
+ const USER_ID_OPTION = { name: "--user-id", value: "id", description: "Override the local user ID" };
138
+ const RUN_NUMBER_OPTION = { name: "--run-number", value: "number", description: "Override the local run number" };
139
+ const VERBOSE_OPTION = { name: "--verbose", description: "Stream subprocess output" };
140
+ const QUIET_OPTION = { name: "--quiet", description: "Suppress progress output on stderr" };
141
+ const COMMAND_DEFINITIONS = {
142
+ info: {
143
+ usage: "tt-local info",
144
+ description: "Show the installed TT Local version and runner status.",
145
+ options: [],
146
+ maxPositionals: 0,
147
+ },
148
+ init: {
149
+ usage: "tt-local init [options]",
150
+ description: "Create a local tunedtensor.json behavior spec.",
151
+ options: [
152
+ { name: "--name", value: "name", description: "Behavior spec name" },
153
+ { name: "--model", value: "model", description: "Base model ID" },
154
+ { name: "--output", value: "path", description: "Output spec path" },
155
+ { name: "--profile", value: "profile", description: "Write a durable runner config (spark)" },
156
+ { name: "--config-output", value: "path", description: "Profile config output path" },
157
+ { name: "--force", description: "Overwrite an existing output file" },
158
+ ],
159
+ maxPositionals: 0,
160
+ },
161
+ doctor: {
162
+ usage: "tt-local doctor [tunedtensor.json|request.json] [--config path]",
163
+ description: "Check the host and optional run input before starting work.",
164
+ options: [CONFIG_OPTION],
165
+ maxPositionals: 1,
166
+ },
167
+ validate: {
168
+ usage: "tt-local validate [tunedtensor.json|request.json] [options]",
169
+ description: "Validate a behavior spec or run request without executing it.",
170
+ options: [CONFIG_OPTION, USER_ID_OPTION, RUN_NUMBER_OPTION],
171
+ maxPositionals: 1,
172
+ },
173
+ run: {
174
+ usage: "tt-local run [tunedtensor.json|request.json] [options]",
175
+ description: "Run the local fine-tuning workflow.",
176
+ options: [
177
+ CONFIG_OPTION,
178
+ USER_ID_OPTION,
179
+ RUN_NUMBER_OPTION,
180
+ { name: "--run-id", value: "uuid", description: "Resume or override a run ID" },
181
+ { name: "--stage", value: "stage", description: "Run through a specific workflow stage" },
182
+ { name: "--model-artifact", value: "uri", description: "Use an existing model artifact" },
183
+ { name: "--parent-model", value: "model-id", description: "Continue training a stored local model" },
184
+ { name: "--parent-model-artifact", value: "uri", description: "Continue training a model artifact" },
185
+ { name: "--force", description: "Recompute the selected stage" },
186
+ { name: "--dry-run", description: "Write representative artifacts without GPU work" },
187
+ { name: "--detach", description: "Run in the background and return the run ID immediately" },
188
+ VERBOSE_OPTION,
189
+ QUIET_OPTION,
190
+ ],
191
+ maxPositionals: 1,
192
+ },
193
+ label: {
194
+ usage: "tt-local label <input.jsonl|input.csv> [options]",
195
+ description: "Label local rows with a teacher model.",
196
+ options: [
197
+ CONFIG_OPTION,
198
+ { name: "--input-column", value: "column", description: "CSV input column" },
199
+ { name: "--spec", value: "path", description: "Behavior spec path" },
200
+ { name: "--system-prompt", value: "text", description: "Explicit teacher system prompt" },
201
+ { name: "--model", value: "model", description: "Teacher model ID" },
202
+ { name: "--output", value: "path", description: "Output JSONL path" },
203
+ { name: "--format", value: "jsonl|csv", description: "Force the source format" },
204
+ { name: "--dry-run", description: "Parse and sanitize without teacher calls" },
205
+ VERBOSE_OPTION,
206
+ QUIET_OPTION,
207
+ ],
208
+ minPositionals: 1,
209
+ maxPositionals: 1,
210
+ missingPositionalsMessage: "label requires <input.jsonl|input.csv>",
211
+ },
212
+ serve: {
213
+ usage: "tt-local serve [--config path] [--host host] [--port port]",
214
+ description: "Serve the local runs dashboard.",
215
+ options: [
216
+ CONFIG_OPTION,
217
+ { name: "--host", value: "host", description: "Dashboard bind host" },
218
+ { name: "--port", value: "port", description: "Dashboard bind port" },
219
+ ],
220
+ maxPositionals: 0,
221
+ },
222
+ };
223
+ const COMMAND_GROUPS = {
224
+ runs: {
225
+ description: "Inspect and manage locally stored runs.",
226
+ defaultSubcommand: "list",
227
+ subcommands: {
228
+ list: { usage: "tt-local runs list [--config path]", description: "List local runs.", options: [CONFIG_OPTION], maxPositionals: 0 },
229
+ get: { usage: "tt-local runs get <run-id> [--config path]", description: "Get a local run.", options: [CONFIG_OPTION], minPositionals: 1, maxPositionals: 1, missingPositionalsMessage: "runs get requires <run-id>" },
230
+ events: { usage: "tt-local runs events <run-id> [--config path]", description: "List run events.", options: [CONFIG_OPTION], minPositionals: 1, maxPositionals: 1, missingPositionalsMessage: "runs events requires <run-id>" },
231
+ watch: { usage: "tt-local runs watch <run-id> [--config path]", description: "Watch a run until it finishes.", options: [CONFIG_OPTION], minPositionals: 1, maxPositionals: 1, missingPositionalsMessage: "runs watch requires <run-id>" },
232
+ report: { usage: "tt-local runs report <run-id> [--config path]", description: "Get a run comparison report.", options: [CONFIG_OPTION], minPositionals: 1, maxPositionals: 1, missingPositionalsMessage: "runs report requires <run-id>" },
233
+ compare: { usage: "tt-local runs compare <run-id-a> <run-id-b> [--config path]", description: "Compare two run reports.", options: [CONFIG_OPTION], minPositionals: 2, maxPositionals: 2, missingPositionalsMessage: "runs compare requires <run-id-a> <run-id-b>" },
234
+ cancel: { usage: "tt-local runs cancel <run-id> [--config path]", description: "Request cancellation of a run.", options: [CONFIG_OPTION], minPositionals: 1, maxPositionals: 1, missingPositionalsMessage: "runs cancel requires <run-id>" },
235
+ reconcile: { usage: "tt-local runs reconcile [--config path]", description: "Rebuild local store indexes.", options: [CONFIG_OPTION], maxPositionals: 0 },
236
+ },
237
+ },
238
+ models: {
239
+ description: "Inspect, verify, prefetch, or serve local models.",
240
+ defaultSubcommand: "list",
241
+ subcommands: {
242
+ list: { usage: "tt-local models list [--config path]", description: "List local models.", options: [CONFIG_OPTION], maxPositionals: 0 },
243
+ get: { usage: "tt-local models get <model-id> [--config path]", description: "Get a local model.", options: [CONFIG_OPTION], minPositionals: 1, maxPositionals: 1, missingPositionalsMessage: "models get requires <model-id>" },
244
+ verify: {
245
+ usage: "tt-local models verify <model-id-or-artifact-path> [--config path]",
246
+ description: "Verify a stored model or manifested artifact path.",
247
+ options: [CONFIG_OPTION],
248
+ minPositionals: 1,
249
+ maxPositionals: 1,
250
+ missingPositionalsMessage: "models verify requires <model-id-or-artifact-path>",
251
+ },
252
+ prefetch: {
253
+ usage: "tt-local models prefetch [tunedtensor.json|request.json] [options]",
254
+ description: "Download the configured base model before a run.",
255
+ options: [CONFIG_OPTION, USER_ID_OPTION, RUN_NUMBER_OPTION, VERBOSE_OPTION, QUIET_OPTION],
256
+ maxPositionals: 1,
257
+ },
258
+ "verify-base": {
259
+ usage: "tt-local models verify-base [tunedtensor.json|request.json] [options]",
260
+ description: "Verify that the configured base-model snapshot is complete and locally available.",
261
+ options: [CONFIG_OPTION, USER_ID_OPTION, RUN_NUMBER_OPTION, VERBOSE_OPTION, QUIET_OPTION],
262
+ maxPositionals: 1,
263
+ },
264
+ serve: {
265
+ usage: "tt-local models serve <model-id> [options]",
266
+ description: "Serve a verified adapter through an OpenAI-compatible local API.",
267
+ options: [
268
+ CONFIG_OPTION,
269
+ { name: "--host", value: "host", description: "Bind host (localhost by default)" },
270
+ { name: "--port", value: "port", description: "Bind port" },
271
+ { name: "--device", value: "device", description: "auto, cpu, cuda, or mps" },
272
+ { name: "--max-tokens", value: "count", description: "Default response token limit" },
273
+ { name: "--temperature", value: "number", description: "Default sampling temperature" },
274
+ { name: "--top-p", value: "number", description: "Default nucleus sampling threshold" },
275
+ { name: "--max-concurrent-requests", value: "count", description: "Concurrent generation limit" },
276
+ { name: "--spec", value: "path", description: "Behavior spec whose instructions are enforced" },
277
+ { name: "--no-spec-prompt", description: "Do not enforce the stored behavior-spec prompt" },
278
+ { name: "--allow-remote", description: "Allow a non-loopback bind" },
279
+ { name: "--api-key-env", value: "name", description: "Environment variable containing a bearer token" },
280
+ { name: "--print-command", description: "Validate and print the launch plan without starting" },
281
+ ],
282
+ minPositionals: 1,
283
+ maxPositionals: 1,
284
+ missingPositionalsMessage: "models serve requires <model-id>",
285
+ },
286
+ },
287
+ },
288
+ specs: {
289
+ description: "Inspect and import local behavior specs.",
290
+ defaultSubcommand: "list",
291
+ subcommands: {
292
+ list: { usage: "tt-local specs list [--config path]", description: "List local behavior specs.", options: [CONFIG_OPTION], maxPositionals: 0 },
293
+ get: { usage: "tt-local specs get <spec-id> [--config path]", description: "Get a local behavior spec.", options: [CONFIG_OPTION], minPositionals: 1, maxPositionals: 1, missingPositionalsMessage: "specs get requires <spec-id>" },
294
+ import: {
295
+ usage: "tt-local specs import <spec-or-request.json> [options]",
296
+ description: "Import a behavior spec into the local store.",
297
+ options: [CONFIG_OPTION, USER_ID_OPTION, RUN_NUMBER_OPTION, { name: "--id", value: "id", description: "ID for a raw spec snapshot" }],
298
+ minPositionals: 1,
299
+ maxPositionals: 1,
300
+ missingPositionalsMessage: "specs import requires <spec-or-request.json>",
301
+ },
302
+ },
303
+ },
304
+ store: {
305
+ description: "Maintain the local file-backed store.",
306
+ subcommands: {
307
+ "rebuild-index": { usage: "tt-local store rebuild-index [--config path]", description: "Rebuild store indexes from durable records.", options: [CONFIG_OPTION], maxPositionals: 0 },
308
+ },
309
+ },
310
+ };
311
+ function hasHelpFlag(argv) {
312
+ return argv.includes("--help") || argv.includes("-h");
313
+ }
314
+ function printCommandHelp(definition) {
315
+ console.log(`Usage: ${definition.usage}\n\n${definition.description}`);
316
+ if (definition.options.length > 0) {
317
+ console.log("\nOptions:");
318
+ for (const option of definition.options) {
319
+ const label = option.value ? `${option.name} <${option.value}>` : option.name;
320
+ console.log(` ${label.padEnd(34)} ${option.description}`);
321
+ }
322
+ }
323
+ console.log(" -h, --help Show help");
324
+ }
325
+ function printGroupHelp(command, group) {
326
+ console.log(`Usage: tt-local ${command} <command> [options]\n\n${group.description}\n\nCommands:`);
327
+ for (const [name, definition] of Object.entries(group.subcommands)) {
328
+ console.log(` ${name.padEnd(16)} ${definition.description}`);
329
+ }
330
+ console.log("\nRun `tt-local " + command + " <command> --help` for command-specific help.");
331
+ }
332
+ function parseCommandArguments(tokens, definition) {
333
+ const options = new Map(definition.options.map((option) => [option.name, option]));
334
+ const seen = new Set();
133
335
  const positionals = [];
134
- for (let index = startIndex; index < argv.length; index += 1) {
135
- const arg = argv[index];
136
- if (!arg)
137
- continue;
138
- if (optionNamesWithValues.has(arg)) {
139
- index += 1;
336
+ let optionsEnded = false;
337
+ for (let index = 0; index < tokens.length; index += 1) {
338
+ const token = tokens[index];
339
+ if (!optionsEnded && token === "--") {
340
+ optionsEnded = true;
140
341
  continue;
141
342
  }
142
- if (arg.startsWith("--"))
343
+ if (!optionsEnded && token.startsWith("-")) {
344
+ const equalsIndex = token.indexOf("=");
345
+ const name = equalsIndex === -1 ? token : token.slice(0, equalsIndex);
346
+ const inlineValue = equalsIndex === -1 ? undefined : token.slice(equalsIndex + 1);
347
+ const option = options.get(name);
348
+ if (!option)
349
+ throw new Error(`Unknown option: ${name}`);
350
+ if (seen.has(name))
351
+ throw new Error(`Option ${name} may only be specified once.`);
352
+ seen.add(name);
353
+ if (option.value) {
354
+ if (inlineValue !== undefined) {
355
+ if (!inlineValue)
356
+ throw new Error(`Option ${name} requires a value.`);
357
+ continue;
358
+ }
359
+ const value = tokens[index + 1];
360
+ if (value === undefined || value.startsWith("-")) {
361
+ throw new Error(`Option ${name} requires a value.`);
362
+ }
363
+ index += 1;
364
+ }
365
+ else if (inlineValue !== undefined) {
366
+ throw new Error(`Option ${name} does not accept a value.`);
367
+ }
143
368
  continue;
144
- positionals.push(arg);
369
+ }
370
+ positionals.push(token);
371
+ }
372
+ if (positionals.length < (definition.minPositionals ?? 0)) {
373
+ throw new Error(definition.missingPositionalsMessage ?? `Missing required argument. Usage: ${definition.usage}`);
374
+ }
375
+ if (definition.maxPositionals !== undefined && positionals.length > definition.maxPositionals) {
376
+ throw new Error(`Too many arguments. Usage: ${definition.usage}`);
145
377
  }
146
378
  return positionals;
147
379
  }
380
+ function parseCli(argv) {
381
+ const command = argv[2] ?? "info";
382
+ if (command === "--help" || command === "-h") {
383
+ return { command: "info", positionals: [], help: "top" };
384
+ }
385
+ if (command === "--version" || command === "-V") {
386
+ if (argv.length > 3)
387
+ throw new Error(`${command} does not accept arguments.`);
388
+ return { command, positionals: [], help: "command" };
389
+ }
390
+ if (command.startsWith("-"))
391
+ throw new Error(`Unknown option: ${command}`);
392
+ const definition = COMMAND_DEFINITIONS[command];
393
+ if (definition) {
394
+ if (hasHelpFlag(argv.slice(3))) {
395
+ return { command, positionals: [], help: "command", definition };
396
+ }
397
+ return {
398
+ command,
399
+ positionals: parseCommandArguments(argv.slice(3), definition),
400
+ help: "top",
401
+ definition,
402
+ };
403
+ }
404
+ const group = COMMAND_GROUPS[command];
405
+ if (!group)
406
+ throw new Error(`Unknown command: ${command}`);
407
+ if (argv[3] === "--help" || argv[3] === "-h") {
408
+ return { command, positionals: [], help: "group" };
409
+ }
410
+ const candidate = argv[3];
411
+ let subcommand;
412
+ let tokenStart;
413
+ if (candidate && !candidate.startsWith("-")) {
414
+ subcommand = candidate;
415
+ tokenStart = 4;
416
+ }
417
+ else if (group.defaultSubcommand) {
418
+ subcommand = group.defaultSubcommand;
419
+ tokenStart = 3;
420
+ }
421
+ else {
422
+ throw new Error(`${command} requires a subcommand. Run 'tt-local ${command} --help'.`);
423
+ }
424
+ const subcommandDefinition = group.subcommands[subcommand];
425
+ if (!subcommandDefinition)
426
+ throw new Error(`Unknown ${command} command: ${subcommand}`);
427
+ if (hasHelpFlag(argv.slice(tokenStart))) {
428
+ return { command, subcommand, positionals: [], help: "command", definition: subcommandDefinition };
429
+ }
430
+ return {
431
+ command,
432
+ subcommand,
433
+ positionals: parseCommandArguments(argv.slice(tokenStart), subcommandDefinition),
434
+ help: "top",
435
+ definition: subcommandDefinition,
436
+ };
437
+ }
148
438
  function readNumberOption(argv, name) {
149
439
  const value = readOption(argv, name);
150
440
  return value ? Number(value) : undefined;
@@ -233,10 +523,45 @@ async function parentModelSelectionFromArgv(argv, config) {
233
523
  return undefined;
234
524
  const store = createLocalStore(config.storeRoot);
235
525
  const record = await store.getModel(parentModel);
526
+ const verified = await verifyStoredModel(record, { requireServable: true });
527
+ const contract = verified.contract;
528
+ const baseModelRevision = typeof contract.base_model_revision === "string"
529
+ ? contract.base_model_revision
530
+ : undefined;
531
+ const baseModelArtifactUri = typeof contract.base_model_artifact_uri === "string"
532
+ ? contract.base_model_artifact_uri
533
+ : undefined;
534
+ const baseModelFingerprint = typeof contract.base_model_fingerprint === "string"
535
+ ? contract.base_model_fingerprint
536
+ : undefined;
537
+ if (baseModelArtifactUri && baseModelFingerprint) {
538
+ if (!config.paths.baseModel) {
539
+ throw new Error(`Parent model ${record.id} was trained from a local base snapshot. Configure paths.baseModel to the same `
540
+ + "snapshot before continuing it.");
541
+ }
542
+ const configuredFingerprint = await fingerprintLocalBaseModel(config.paths.baseModel);
543
+ if (configuredFingerprint !== baseModelFingerprint) {
544
+ throw new Error(`Configured paths.baseModel content does not match parent model ${record.id}.`);
545
+ }
546
+ }
547
+ else if (!baseModelRevision) {
548
+ throw new Error(`Parent model ${record.id} does not record an immutable base-model revision or local snapshot fingerprint. `
549
+ + "Use --parent-model-artifact only if you intentionally accept responsibility for that unpinned artifact.");
550
+ }
551
+ if (baseModelRevision && config.paths.baseModel) {
552
+ const snapshotRevision = resolve(config.paths.baseModel).match(/[\\/]snapshots[\\/]([^\\/]+)(?:[\\/]|$)/)?.[1];
553
+ if (!baseModelFingerprint && snapshotRevision !== baseModelRevision) {
554
+ throw new Error(`Parent model ${record.id} requires base revision ${baseModelRevision}; configured paths.baseModel does not `
555
+ + "identify that snapshot revision.");
556
+ }
557
+ }
236
558
  return {
237
559
  artifactUri: record.artifact_uri,
238
560
  modelId: record.id,
239
561
  baseModel: record.base_model,
562
+ baseModelRevision,
563
+ baseModelArtifactUri,
564
+ baseModelFingerprint,
240
565
  };
241
566
  }
242
567
  function withParentModelArtifact(request, parentModel) {
@@ -246,10 +571,16 @@ function withParentModelArtifact(request, parentModel) {
246
571
  throw new Error(`Parent model ${parentModel.modelId ?? ""} uses base model ${parentModel.baseModel}, `
247
572
  + `but this run uses ${request.spec_snapshot.base_model}. Continue from a model with the same base model.`);
248
573
  }
574
+ const requestedRevision = request.hyperparameters.base_model_revision;
575
+ if (parentModel.baseModelRevision && requestedRevision && parentModel.baseModelRevision !== requestedRevision) {
576
+ throw new Error(`Parent model ${parentModel.modelId ?? ""} uses base revision ${parentModel.baseModelRevision}, `
577
+ + `but this run requests ${requestedRevision}.`);
578
+ }
249
579
  return fineTuneRunRequestSchema.parse({
250
580
  ...request,
251
581
  hyperparameters: {
252
582
  ...request.hyperparameters,
583
+ ...(parentModel.baseModelRevision ? { base_model_revision: parentModel.baseModelRevision } : {}),
253
584
  parent_model_artifact: parentModel.artifactUri,
254
585
  },
255
586
  });
@@ -257,26 +588,265 @@ function withParentModelArtifact(request, parentModel) {
257
588
  async function sleep(ms) {
258
589
  await new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
259
590
  }
260
- function isTerminalStatus(status) {
261
- return status === "completed" || status === "failed" || status === "cancelled";
591
+ function detachedRunArgv(argv, runId) {
592
+ const tokens = argv.slice(3);
593
+ const output = [];
594
+ for (let index = 0; index < tokens.length; index += 1) {
595
+ const token = tokens[index];
596
+ if (token === "--detach")
597
+ continue;
598
+ if (token === "--run-id") {
599
+ index += 1;
600
+ continue;
601
+ }
602
+ if (token.startsWith("--run-id="))
603
+ continue;
604
+ output.push(token);
605
+ }
606
+ return ["run", ...output, "--run-id", runId];
607
+ }
608
+ async function launchDetachedRun(args) {
609
+ if (!process.argv[1])
610
+ throw new Error("Cannot determine the tt-local CLI entrypoint for --detach.");
611
+ const prefix = args.request.artifacts?.prefix ?? defaultArtifactPrefix({
612
+ userId: args.request.user_id,
613
+ behaviorSpecId: args.request.behavior_spec_id,
614
+ runId: args.request.run_id,
615
+ });
616
+ const artifacts = resolveRunArtifacts({ artifactRoot: args.config.artifactRoot, prefix });
617
+ await claimRunArtifactDirectory({
618
+ artifacts,
619
+ runId: args.request.run_id,
620
+ userId: args.request.user_id,
621
+ behaviorSpecId: args.request.behavior_spec_id,
622
+ });
623
+ const store = createLocalStore(args.config.storeRoot);
624
+ const existing = await store.getRun(args.request.run_id).catch(() => null);
625
+ if (existing) {
626
+ await store.syncRunRequest(args.request);
627
+ }
628
+ else {
629
+ await store.startRun({ request: args.request, artifactDir: artifacts.runDir });
630
+ }
631
+ const logPath = resolve(artifacts.runDir, "detached.log");
632
+ await mkdir(dirname(logPath), { recursive: true });
633
+ const log = await open(logPath, "a");
634
+ try {
635
+ const child = spawn(process.execPath, [
636
+ ...process.execArgv,
637
+ process.argv[1],
638
+ ...detachedRunArgv(args.argv, args.request.run_id),
639
+ ], {
640
+ detached: true,
641
+ stdio: ["ignore", log.fd, log.fd],
642
+ env: { ...process.env, TT_LOCAL_DETACHED: "1" },
643
+ });
644
+ await new Promise((resolveSpawn, reject) => {
645
+ child.once("spawn", resolveSpawn);
646
+ child.once("error", reject);
647
+ });
648
+ if (!child.pid)
649
+ throw new Error("Detached tt-local process did not report a PID.");
650
+ child.unref();
651
+ return { pid: child.pid, logPath, artifactDir: artifacts.runDir };
652
+ }
653
+ catch (error) {
654
+ await store.failRun(args.request.run_id, `Failed to launch detached workflow: ${error instanceof Error ? error.message : String(error)}`).catch(() => undefined);
655
+ throw error;
656
+ }
657
+ finally {
658
+ await log.close();
659
+ }
660
+ }
661
+ async function verifyStoredModel(model, options = {}) {
662
+ const manifestPath = join(model.artifact_dir, "artifact-manifest.json");
663
+ const integrity = await assertArtifactManifest(manifestPath, {
664
+ requiredPaths: ["stage-metadata.json", "training-report.json"],
665
+ scopeToRequired: true,
666
+ verifyModel: true,
667
+ });
668
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
669
+ if (!manifest.model) {
670
+ throw new Error(`Artifact manifest does not contain a model contract: ${manifestPath}`);
671
+ }
672
+ const customNonServable = manifest.model.framework !== "transformers-peft"
673
+ && manifest.model.servable === false;
674
+ const artifact = await assertUsableModelArtifact(model.artifact_uri, {
675
+ allowUnrecognizedPayload: customNonServable && !options.requireServable,
676
+ });
677
+ if (typeof manifest.model.base_model_artifact_uri === "string"
678
+ && typeof manifest.model.base_model_fingerprint === "string") {
679
+ const actualBaseFingerprint = await fingerprintLocalBaseModel(manifest.model.base_model_artifact_uri);
680
+ if (actualBaseFingerprint !== manifest.model.base_model_fingerprint) {
681
+ throw new Error("Recorded local base-model content no longer matches the model artifact contract.");
682
+ }
683
+ }
684
+ if (typeof manifest.model.artifact_root !== "string"
685
+ || resolve(manifest.model.artifact_root) !== resolve(artifact.path)) {
686
+ throw new Error("Stored model record does not match the artifact covered by its manifest.");
687
+ }
688
+ if (manifest.model.base_model !== model.base_model) {
689
+ throw new Error("Stored model base model does not match its artifact manifest.");
690
+ }
691
+ if (options.requireServable && manifest.model.servable !== true) {
692
+ throw new Error(`Model ${model.id} is valid but its artifact contract does not mark it as locally servable.`);
693
+ }
694
+ return {
695
+ manifest_path: manifestPath,
696
+ integrity,
697
+ artifact,
698
+ contract: manifest.model,
699
+ };
700
+ }
701
+ async function verifyModelArtifactPath(input) {
702
+ const inputPath = resolve(input);
703
+ let manifestPath;
704
+ let artifactUri = inputPath;
705
+ if (basename(inputPath) === "artifact-manifest.json") {
706
+ manifestPath = inputPath;
707
+ const raw = JSON.parse(await readFile(inputPath, "utf8"));
708
+ if (typeof raw.model?.artifact_root !== "string") {
709
+ throw new Error(`Artifact manifest does not contain a model contract: ${inputPath}`);
710
+ }
711
+ artifactUri = raw.model.artifact_root;
712
+ }
713
+ else {
714
+ const metadata = await stat(inputPath).catch(() => null);
715
+ if (!metadata)
716
+ throw new Error(`Model not found and artifact path does not exist: ${input}`);
717
+ let current = metadata.isDirectory() ? inputPath : dirname(inputPath);
718
+ for (let depth = 0; depth < 8; depth += 1) {
719
+ const candidate = join(current, "artifact-manifest.json");
720
+ const raw = await readFile(candidate, "utf8").catch(() => null);
721
+ if (raw) {
722
+ const parsed = JSON.parse(raw);
723
+ if (typeof parsed.model?.artifact_root === "string"
724
+ && resolve(parsed.model.artifact_root) === resolve(inputPath)) {
725
+ manifestPath = candidate;
726
+ break;
727
+ }
728
+ }
729
+ const parent = dirname(current);
730
+ if (parent === current)
731
+ break;
732
+ current = parent;
733
+ }
734
+ }
735
+ if (!manifestPath) {
736
+ throw new Error(`No artifact manifest covering model path ${inputPath} was found in its parent run directory.`);
737
+ }
738
+ const integrity = await assertArtifactManifest(manifestPath, {
739
+ requiredPaths: ["stage-metadata.json", "training-report.json"],
740
+ scopeToRequired: true,
741
+ verifyModel: true,
742
+ });
743
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
744
+ if (!manifest.model)
745
+ throw new Error(`Artifact manifest does not contain a model contract: ${manifestPath}`);
746
+ const customNonServable = manifest.model.framework !== "transformers-peft"
747
+ && manifest.model.servable === false;
748
+ const artifact = await assertUsableModelArtifact(artifactUri, {
749
+ allowUnrecognizedPayload: customNonServable,
750
+ });
751
+ if (typeof manifest.model.base_model_artifact_uri === "string"
752
+ && typeof manifest.model.base_model_fingerprint === "string") {
753
+ const actualBaseFingerprint = await fingerprintLocalBaseModel(manifest.model.base_model_artifact_uri);
754
+ if (actualBaseFingerprint !== manifest.model.base_model_fingerprint) {
755
+ throw new Error("Recorded local base-model content no longer matches the model artifact contract.");
756
+ }
757
+ }
758
+ if (typeof manifest.model.artifact_root !== "string"
759
+ || resolve(manifest.model.artifact_root) !== resolve(artifact.path)) {
760
+ throw new Error("Model path does not match the artifact covered by its manifest.");
761
+ }
762
+ return { manifest_path: manifestPath, integrity, artifact, contract: manifest.model };
763
+ }
764
+ async function modelSystemPrompt(args) {
765
+ const specPath = readOption(args.argv, "--spec");
766
+ if (specPath && hasFlag(args.argv, "--no-spec-prompt")) {
767
+ throw new Error("Use only one of --spec or --no-spec-prompt.");
768
+ }
769
+ if (hasFlag(args.argv, "--no-spec-prompt"))
770
+ return undefined;
771
+ let spec;
772
+ if (specPath) {
773
+ const input = JSON.parse(await readFile(resolve(specPath), "utf8"));
774
+ const local = localBehaviorSpecFileSchema.safeParse(input);
775
+ const request = fineTuneRunRequestSchema.safeParse(input);
776
+ const snapshot = specSnapshotSchema.safeParse(input);
777
+ const parsedSpec = local.success ? local.data : request.success ? request.data.spec_snapshot : snapshot.success ? snapshot.data : null;
778
+ if (!parsedSpec)
779
+ throw new Error(`--spec must contain a TT Local behavior spec or run request: ${resolve(specPath)}`);
780
+ spec = parsedSpec;
781
+ }
782
+ else {
783
+ const runRequestPath = join(args.store.paths.runsDir, args.model.run_id, "request.json");
784
+ let persistedRequest = null;
785
+ try {
786
+ persistedRequest = fineTuneRunRequestSchema.parse(JSON.parse(await readFile(runRequestPath, "utf8")));
787
+ }
788
+ catch (error) {
789
+ if (error.code !== "ENOENT")
790
+ throw error;
791
+ }
792
+ spec = persistedRequest?.spec_snapshot
793
+ ?? (await args.store.getSpec(args.model.behavior_spec_id)).spec;
794
+ }
795
+ if (spec.base_model !== args.model.base_model) {
796
+ throw new Error(`Behavior spec base model ${spec.base_model} does not match stored model base ${args.model.base_model}.`);
797
+ }
798
+ const prompt = buildSystemMessage(spec);
799
+ const metadata = JSON.parse(await readFile(join(args.model.artifact_dir, "stage-metadata.json"), "utf8"));
800
+ const promptHash = createHash("sha256").update(prompt).digest("hex");
801
+ if (metadata.system_prompt_sha256 !== promptHash) {
802
+ throw new Error("Behavior spec instructions do not match the prompt fingerprint used for this trained model. "
803
+ + "Pass the original --spec, or use --no-spec-prompt only when this change is intentional.");
804
+ }
805
+ return prompt;
806
+ }
807
+ function modelServeDevice(argv) {
808
+ const value = readOption(argv, "--device");
809
+ if (value === undefined || value === "auto" || value === "cpu" || value === "cuda" || value === "mps") {
810
+ return value;
811
+ }
812
+ throw new Error(`--device must be one of auto, cpu, cuda, mps; got: ${value}`);
262
813
  }
263
814
  async function main(argv) {
264
- const command = argv[2] ?? "info";
815
+ const cli = parseCli(argv);
816
+ const command = cli.command;
817
+ if (command === "--version" || command === "-V") {
818
+ console.log(TT_LOCAL_VERSION);
819
+ return;
820
+ }
821
+ if (cli.help === "top" && (argv[2] === "--help" || argv[2] === "-h")) {
822
+ printHelp();
823
+ return;
824
+ }
825
+ if (cli.help === "group") {
826
+ printGroupHelp(command, COMMAND_GROUPS[command]);
827
+ return;
828
+ }
829
+ if (cli.help === "command" && cli.definition) {
830
+ printCommandHelp(cli.definition);
831
+ return;
832
+ }
265
833
  const loadedEnv = await loadDotEnvFromCwd();
266
834
  if (loadedEnv.length > 0 && !hasFlag(argv, "--quiet")) {
267
835
  process.stderr.write(`[tt-local] loaded ${loadedEnv.join(", ")} from .env\n`);
268
836
  }
269
- if (command === "info" || command === "--help" || command === "-h") {
837
+ if (command === "info") {
270
838
  const info = getLocalRunnerInfo();
271
839
  console.log(`${info.name}: ${info.description}`);
840
+ console.log(`Version: ${info.version}`);
272
841
  console.log(`Status: ${info.status}`);
273
- if (command !== "info")
274
- printHelp();
275
842
  return;
276
843
  }
277
844
  if (command === "doctor") {
278
845
  const config = await configFromArgv(argv);
279
- const checks = await runDoctor(config);
846
+ const request = cli.positionals[0]
847
+ ? (await loadLocalRunInput(resolve(cli.positionals[0]))).request
848
+ : undefined;
849
+ const checks = await runDoctor(config, request);
280
850
  const ok = checks.every((check) => check.ok);
281
851
  printJson({ ok, checks });
282
852
  if (!ok)
@@ -285,35 +855,52 @@ async function main(argv) {
285
855
  }
286
856
  if (command === "init") {
287
857
  const outputPath = resolve(readOption(argv, "--output") ?? DEFAULT_LOCAL_SPEC_PATH);
858
+ const profile = readOption(argv, "--profile");
859
+ if (profile !== undefined && profile !== "spark") {
860
+ throw new Error(`--profile must be spark, got: ${profile}`);
861
+ }
288
862
  const spec = await initLocalSpecFile({
289
863
  outputPath,
290
864
  name: readOption(argv, "--name") ?? "Local Tuned Tensor Spec",
291
865
  baseModel: readOption(argv, "--model") ?? "Qwen/Qwen3.5-2B",
292
866
  force: hasFlag(argv, "--force"),
293
867
  });
868
+ const configPath = profile
869
+ ? resolve(readOption(argv, "--config-output") ?? resolve(dirname(outputPath), "local-runner.json"))
870
+ : undefined;
871
+ if (profile && configPath) {
872
+ await initLocalRunnerConfigFile({
873
+ outputPath: configPath,
874
+ profile,
875
+ force: hasFlag(argv, "--force"),
876
+ });
877
+ }
294
878
  printJson({
295
879
  ok: true,
296
880
  path: outputPath,
297
881
  id: spec.id,
298
882
  name: spec.name,
299
883
  base_model: spec.base_model,
884
+ config_path: configPath ?? null,
300
885
  });
301
886
  return;
302
887
  }
303
888
  if (command === "validate") {
304
- const inputPath = resolve(readPositionals(argv)[0] ?? DEFAULT_LOCAL_SPEC_PATH);
889
+ const inputPath = resolve(cli.positionals[0] ?? DEFAULT_LOCAL_SPEC_PATH);
305
890
  const input = await loadLocalRunInput(inputPath, {
306
891
  userId: readOption(argv, "--user-id"),
307
892
  runNumber: readNumberOption(argv, "--run-number"),
308
893
  });
309
894
  const request = input.request;
310
895
  const config = await configFromArgv(argv);
896
+ assertLocalRunInputReady(request);
311
897
  assertSupportedValidateShape(request, config);
898
+ assertEvaluationScoringReady(config);
312
899
  printJson({
313
900
  ok: true,
314
901
  input_kind: input.kind,
315
902
  input_path: input.path,
316
- run_id: request.run_id,
903
+ run_id: input.kind === "request" ? request.run_id : null,
317
904
  behavior_spec_id: request.behavior_spec_id,
318
905
  training_method: request.training_method,
319
906
  base_model: request.spec_snapshot.base_model,
@@ -326,7 +913,7 @@ async function main(argv) {
326
913
  return;
327
914
  }
328
915
  if (command === "run") {
329
- const inputPath = resolve(readPositionals(argv)[0] ?? DEFAULT_LOCAL_SPEC_PATH);
916
+ const inputPath = resolve(cli.positionals[0] ?? DEFAULT_LOCAL_SPEC_PATH);
330
917
  const configInput = await configFromArgv(argv);
331
918
  const config = localRunnerConfigSchema.parse({
332
919
  ...configInput,
@@ -347,6 +934,28 @@ async function main(argv) {
347
934
  ? fineTuneRunRequestSchema.parse({ ...input.request, run_id: runId })
348
935
  : input.request;
349
936
  const request = withParentModelArtifact(baseRequest, await parentModelSelectionFromArgv(argv, config));
937
+ assertLocalRunInputReady(request);
938
+ assertSupportedValidateShape(request, config);
939
+ assertEvaluationScoringReady(config);
940
+ if (hasFlag(argv, "--detach")) {
941
+ const detached = await launchDetachedRun({ argv, request, config });
942
+ const configPath = readOption(argv, "--config");
943
+ const configSuffix = configPath ? ` --config ${JSON.stringify(resolve(configPath))}` : "";
944
+ printJson({
945
+ status: "queued",
946
+ detached: true,
947
+ run_id: request.run_id,
948
+ behavior_spec_id: request.behavior_spec_id,
949
+ pid: detached.pid,
950
+ log_path: detached.logPath,
951
+ artifact_dir: detached.artifactDir,
952
+ next: {
953
+ watch: `tt-local runs watch ${request.run_id}${configSuffix}`,
954
+ cancel: `tt-local runs cancel ${request.run_id}${configSuffix}`,
955
+ },
956
+ });
957
+ return;
958
+ }
350
959
  const stage = readRunStage(argv);
351
960
  const result = await runLocalFineTuneStage({
352
961
  request,
@@ -379,7 +988,7 @@ async function main(argv) {
379
988
  }
380
989
  else {
381
990
  printJson({
382
- status: "completed",
991
+ status: "stage_completed",
383
992
  stage: result.stage,
384
993
  input_kind: input.kind,
385
994
  run_id: result.request.run_id,
@@ -392,7 +1001,7 @@ async function main(argv) {
392
1001
  return;
393
1002
  }
394
1003
  if (command === "label") {
395
- const sourcePath = readPositionals(argv)[0];
1004
+ const sourcePath = cli.positionals[0];
396
1005
  if (!sourcePath)
397
1006
  throw new Error("label requires <input.jsonl|input.csv>");
398
1007
  const configInput = await configFromArgv(argv);
@@ -450,32 +1059,32 @@ async function main(argv) {
450
1059
  return;
451
1060
  }
452
1061
  if (command === "runs") {
453
- const subcommand = argv[3] ?? "list";
1062
+ const subcommand = cli.subcommand;
454
1063
  const config = await configFromArgv(argv);
455
1064
  const store = createLocalStore(config.storeRoot);
456
1065
  if (subcommand === "list")
457
1066
  return printJson(await store.listRuns());
458
1067
  if (subcommand === "get") {
459
- const id = argv[4];
1068
+ const id = cli.positionals[0];
460
1069
  if (!id)
461
1070
  throw new Error("runs get requires <run-id>");
462
1071
  return printJson(await store.getRun(id));
463
1072
  }
464
1073
  if (subcommand === "events") {
465
- const id = argv[4];
1074
+ const id = cli.positionals[0];
466
1075
  if (!id)
467
1076
  throw new Error("runs events requires <run-id>");
468
1077
  return printJson(await store.getRunEvents(id));
469
1078
  }
470
1079
  if (subcommand === "report") {
471
- const id = argv[4];
1080
+ const id = cli.positionals[0];
472
1081
  if (!id)
473
1082
  throw new Error("runs report requires <run-id>");
474
1083
  return printJson(await store.getRunReport(id));
475
1084
  }
476
1085
  if (subcommand === "compare") {
477
- const idA = argv[4];
478
- const idB = argv[5];
1086
+ const idA = cli.positionals[0];
1087
+ const idB = cli.positionals[1];
479
1088
  if (!idA || !idB)
480
1089
  throw new Error("runs compare requires <run-id-a> <run-id-b>");
481
1090
  const [reportA, reportB] = await Promise.all([
@@ -485,14 +1094,15 @@ async function main(argv) {
485
1094
  return printJson(compareRuns(reportA, reportB));
486
1095
  }
487
1096
  if (subcommand === "cancel") {
488
- const id = argv[4];
1097
+ const id = cli.positionals[0];
489
1098
  if (!id)
490
1099
  throw new Error("runs cancel requires <run-id>");
491
1100
  await store.cancelRun(id);
492
- return printJson({ ok: true, run_id: id });
1101
+ const run = await store.getRun(id);
1102
+ return printJson({ ok: true, run_id: run.id, status: run.status, current_stage: run.current_stage });
493
1103
  }
494
1104
  if (subcommand === "watch") {
495
- const id = argv[4];
1105
+ const id = cli.positionals[0];
496
1106
  if (!id)
497
1107
  throw new Error("runs watch requires <run-id>");
498
1108
  const printed = new Set();
@@ -505,7 +1115,7 @@ async function main(argv) {
505
1115
  console.log(`${event.occurred_at} ${event.stage} ${event.status}: ${event.message}`);
506
1116
  }
507
1117
  const run = await store.getRun(id);
508
- if (isTerminalStatus(run.status))
1118
+ if (isTerminalRunState(run))
509
1119
  return;
510
1120
  await sleep(1000);
511
1121
  }
@@ -517,33 +1127,116 @@ async function main(argv) {
517
1127
  throw new Error(`Unknown runs command: ${subcommand}`);
518
1128
  }
519
1129
  if (command === "models") {
520
- const subcommand = argv[3] ?? "list";
1130
+ const subcommand = cli.subcommand;
521
1131
  const config = await configFromArgv(argv);
522
1132
  const store = createLocalStore(config.storeRoot);
523
1133
  if (subcommand === "list")
524
1134
  return printJson(await store.listModels());
525
1135
  if (subcommand === "get") {
526
- const id = argv[4];
1136
+ const id = cli.positionals[0];
527
1137
  if (!id)
528
1138
  throw new Error("models get requires <model-id>");
529
1139
  return printJson(await store.getModel(id));
530
1140
  }
1141
+ if (subcommand === "verify") {
1142
+ const id = cli.positionals[0];
1143
+ if (!id)
1144
+ throw new Error("models verify requires <model-id-or-artifact-path>");
1145
+ if (await stat(resolve(id)).then(() => true, () => false)) {
1146
+ return printJson({ ok: true, model: null, ...await verifyModelArtifactPath(id) });
1147
+ }
1148
+ const model = await store.getModel(id);
1149
+ const verified = await verifyStoredModel(model);
1150
+ return printJson({ ok: true, model, ...verified });
1151
+ }
1152
+ if (subcommand === "prefetch" || subcommand === "verify-base") {
1153
+ const inputPath = resolve(cli.positionals[0] ?? DEFAULT_LOCAL_SPEC_PATH);
1154
+ const input = await loadLocalRunInput(inputPath, {
1155
+ userId: readOption(argv, "--user-id"),
1156
+ runNumber: readNumberOption(argv, "--run-number"),
1157
+ });
1158
+ if (!hasFlag(argv, "--quiet")) {
1159
+ for (const warning of input.warnings) {
1160
+ process.stderr.write(`[tt-local] warning: ${warning}\n`);
1161
+ }
1162
+ }
1163
+ const report = await prefetchBaseModel({
1164
+ request: input.request,
1165
+ config,
1166
+ localOnly: subcommand === "verify-base",
1167
+ reporter: createConsoleReporter({
1168
+ verbose: hasFlag(argv, "--verbose"),
1169
+ quiet: hasFlag(argv, "--quiet"),
1170
+ }),
1171
+ });
1172
+ return printJson({
1173
+ ...report,
1174
+ input_kind: input.kind,
1175
+ input_path: input.path,
1176
+ });
1177
+ }
1178
+ if (subcommand === "serve") {
1179
+ const id = cli.positionals[0];
1180
+ if (!id)
1181
+ throw new Error("models serve requires <model-id>");
1182
+ const model = await store.getModel(id);
1183
+ const verified = await verifyStoredModel(model, { requireServable: true });
1184
+ const launch = buildLocalModelServerLaunch({
1185
+ model,
1186
+ config,
1187
+ options: {
1188
+ host: readOption(argv, "--host"),
1189
+ port: readNumberOption(argv, "--port"),
1190
+ device: modelServeDevice(argv),
1191
+ maxTokens: readNumberOption(argv, "--max-tokens"),
1192
+ temperature: readNumberOption(argv, "--temperature"),
1193
+ topP: readNumberOption(argv, "--top-p"),
1194
+ maxConcurrentRequests: readNumberOption(argv, "--max-concurrent-requests"),
1195
+ systemPrompt: await modelSystemPrompt({ argv, model, store }),
1196
+ allowRemote: hasFlag(argv, "--allow-remote"),
1197
+ apiKeyEnv: readOption(argv, "--api-key-env"),
1198
+ baseModelRevision: (() => {
1199
+ const contract = verified.contract;
1200
+ return typeof contract.base_model_revision === "string" ? contract.base_model_revision : undefined;
1201
+ })(),
1202
+ baseModelArtifactUri: (() => {
1203
+ const contract = verified.contract;
1204
+ return typeof contract.base_model_artifact_uri === "string" ? contract.base_model_artifact_uri : undefined;
1205
+ })(),
1206
+ },
1207
+ });
1208
+ if (hasFlag(argv, "--print-command")) {
1209
+ return printJson({
1210
+ ok: true,
1211
+ model_id: model.id,
1212
+ url: launch.url,
1213
+ command: launch.displayCommand,
1214
+ artifact_path: launch.artifactPath,
1215
+ manifest_path: verified.manifest_path,
1216
+ integrity: verified.integrity,
1217
+ });
1218
+ }
1219
+ process.stderr.write(`[tt-local] verified ${verified.integrity.checked} artifact file(s)\n`);
1220
+ process.stderr.write(`[tt-local] model API: ${launch.url}\n`);
1221
+ await serveLocalModel(launch);
1222
+ return;
1223
+ }
531
1224
  throw new Error(`Unknown models command: ${subcommand}`);
532
1225
  }
533
1226
  if (command === "specs") {
534
- const subcommand = argv[3] ?? "list";
1227
+ const subcommand = cli.subcommand;
535
1228
  const config = await configFromArgv(argv);
536
1229
  const store = createLocalStore(config.storeRoot);
537
1230
  if (subcommand === "list")
538
1231
  return printJson(await store.listSpecs());
539
1232
  if (subcommand === "get") {
540
- const id = argv[4];
1233
+ const id = cli.positionals[0];
541
1234
  if (!id)
542
1235
  throw new Error("specs get requires <spec-id>");
543
1236
  return printJson(await store.getSpec(id));
544
1237
  }
545
1238
  if (subcommand === "import") {
546
- const path = argv[4];
1239
+ const path = cli.positionals[0];
547
1240
  if (!path)
548
1241
  throw new Error("specs import requires <spec-or-request.json>");
549
1242
  const input = JSON.parse(await readFile(resolve(path), "utf8"));
@@ -566,7 +1259,7 @@ async function main(argv) {
566
1259
  throw new Error(`Unknown specs command: ${subcommand}`);
567
1260
  }
568
1261
  if (command === "store") {
569
- const subcommand = argv[3];
1262
+ const subcommand = cli.subcommand;
570
1263
  const config = await configFromArgv(argv);
571
1264
  const store = createLocalStore(config.storeRoot);
572
1265
  if (subcommand === "rebuild-index") {