@toist/in 0.7.1 → 0.8.1

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/src/cli.ts ADDED
@@ -0,0 +1,1003 @@
1
+ #!/usr/bin/env bun
2
+ // 2121 toist
3
+
4
+ import { readFileSync } from "node:fs"
5
+ import { resolve } from "node:path"
6
+ import { createInterface } from "node:readline/promises"
7
+ import { parseYaml, validateSpec, type PipelineSpec } from "@toist/spec"
8
+ import { createRunnerClient, startRunner, type PipelineRunResponse, type RunnerClient } from "@toist/aja"
9
+ import { runSpec, type RunSpecResult, type TaskDescriptor, type ToistRuntime } from "@toist/core"
10
+ import {
11
+ deleteRunDescriptor,
12
+ getCliRuntime,
13
+ isResumableRuntime,
14
+ loadRunDescriptor,
15
+ resumeLocalStoredRun,
16
+ storeRunDescriptor,
17
+ type StoredRunDescriptor,
18
+ } from "./runtime.ts"
19
+ import { wizard } from "./wizard.ts"
20
+ import { upgrade } from "./upgrade.ts"
21
+
22
+ const args = process.argv.slice(2)
23
+ const cmd = args[0]
24
+ let stdinTextPromise: Promise<string> | null = null
25
+
26
+ interface RunCliOptions {
27
+ pathArg?: string
28
+ payloadArg?: string
29
+ specStdin: boolean
30
+ payloadStdin: boolean
31
+ suspendAsJson: boolean
32
+ ephemeral: boolean
33
+ remote?: string
34
+ }
35
+
36
+ interface ResumeCliOptions {
37
+ runId?: number
38
+ responseArg?: string
39
+ suspendAsJson: boolean
40
+ }
41
+
42
+ interface ServeCliOptions {
43
+ port: number
44
+ rootDir: string
45
+ memory: boolean
46
+ noUi: boolean
47
+ noWatch: boolean
48
+ }
49
+
50
+ interface TasksCliOptions {
51
+ remote?: string
52
+ format: "table" | "json"
53
+ }
54
+
55
+ interface CliTaskRef {
56
+ id: number
57
+ nodeId: string
58
+ kind: "human.input" | "error_review"
59
+ prompt: string
60
+ schema: unknown
61
+ assignee: string | null
62
+ responseToken: string
63
+ }
64
+
65
+ type NormalizedStepList = Array<Record<string, unknown>>
66
+
67
+ type NormalizedRunResult =
68
+ | {
69
+ status: "done"
70
+ runId: number
71
+ output: unknown
72
+ steps: NormalizedStepList
73
+ results: Record<string, unknown>
74
+ }
75
+ | {
76
+ status: "suspended"
77
+ runId: number
78
+ suspendedAt: {
79
+ taskId: number
80
+ nodeId: string
81
+ cause: {
82
+ kind: "human.input" | "error_review"
83
+ spec: {
84
+ prompt: string
85
+ schema?: unknown
86
+ assignee?: string | null
87
+ }
88
+ }
89
+ }
90
+ task: {
91
+ id: number
92
+ kind: string
93
+ prompt: string
94
+ schema: unknown
95
+ assignee: string | null
96
+ responseToken: string
97
+ } | null
98
+ steps: NormalizedStepList
99
+ results: Record<string, unknown>
100
+ }
101
+ | {
102
+ status: "error"
103
+ runId: number
104
+ error: string
105
+ steps?: NormalizedStepList
106
+ }
107
+
108
+ type PrintableRunResult = RunSpecResult | NormalizedRunResult
109
+ type PrintableSuspendedRunResult = Extract<RunSpecResult, { status: "suspended" }> | Extract<NormalizedRunResult, { status: "suspended" }>
110
+
111
+ function help() {
112
+ console.log("toist <command> [options]\n")
113
+ console.log(" init run setup wizard")
114
+ console.log(" run <file> run a pipeline")
115
+ console.log(" --remote <url> run against a remote runner")
116
+ console.log(" --spec-stdin read spec YAML from stdin")
117
+ console.log(" --payload '<json>' read payload from flag")
118
+ console.log(" --payload @file.json read payload JSON from file")
119
+ console.log(" --payload-stdin read payload JSON from stdin")
120
+ console.log(" --suspend-as-json print only { runId, suspendedAt } on suspension")
121
+ console.log(" --ephemeral use in-memory runtime only")
122
+ console.log(" resume <runId> resume a suspended local run")
123
+ console.log(" serve start the durable runner")
124
+ console.log(" tasks list list open tasks")
125
+ console.log(" tasks answer <id> answer a task and resume its run")
126
+ console.log(" validate <file...> validate pipeline YAML files")
127
+ console.log(" plan <file> render the pipeline DAG")
128
+ console.log(" upgrade upgrade @toist/* deps")
129
+ console.log("")
130
+ console.log("No command runs the setup wizard.")
131
+ }
132
+
133
+ function parsePayloadText(input: string, source = "payload"): Record<string, unknown> {
134
+ try {
135
+ return JSON.parse(input) as Record<string, unknown>
136
+ } catch (err) {
137
+ throw new Error(`invalid JSON ${source}: ${(err as Error).message}`)
138
+ }
139
+ }
140
+
141
+ function parsePayloadArg(input?: string): Record<string, unknown> {
142
+ if (!input) return {}
143
+ if (input.startsWith("@")) {
144
+ const path = resolve(process.cwd(), input.slice(1))
145
+ return parsePayloadText(readFileSync(path, "utf8"), path)
146
+ }
147
+ return parsePayloadText(input)
148
+ }
149
+
150
+ function readPipelineFile(pathArg: string): { path: string; parsed: unknown } {
151
+ const path = resolve(process.cwd(), pathArg)
152
+ const raw = readFileSync(path, "utf8")
153
+ return { path, parsed: parseYaml(raw) }
154
+ }
155
+
156
+ function requireParsedSpec(value: unknown, path: string): PipelineSpec {
157
+ const result = validateSpec(value)
158
+ if (!result.ok || !result.parsed) {
159
+ const details = result.errors.map((error) => `- ${error}`).join("\n")
160
+ throw new Error(`Invalid: ${path}\n${details}`)
161
+ }
162
+ return result.parsed
163
+ }
164
+
165
+ async function readStdinText(): Promise<string> {
166
+ if (!stdinTextPromise) {
167
+ stdinTextPromise = new Promise<string>((resolveText, reject) => {
168
+ const chunks: Buffer[] = []
169
+ process.stdin.on("data", (chunk) => chunks.push(Buffer.from(chunk)))
170
+ process.stdin.on("end", () => resolveText(Buffer.concat(chunks).toString("utf8")))
171
+ process.stdin.on("error", reject)
172
+ })
173
+ }
174
+ return stdinTextPromise
175
+ }
176
+
177
+ function shouldUseDefaultPayloadStdin(opts: RunCliOptions): boolean {
178
+ return !opts.specStdin && !opts.payloadStdin && !opts.payloadArg && !process.stdin.isTTY
179
+ }
180
+
181
+ function canPromptInline(opts: RunCliOptions): boolean {
182
+ return !opts.specStdin && !opts.payloadStdin && !shouldUseDefaultPayloadStdin(opts)
183
+ }
184
+
185
+ async function loadRunSpec(opts: RunCliOptions): Promise<unknown> {
186
+ if (opts.specStdin) return parseYaml(await readStdinText())
187
+ if (!opts.pathArg) throw new Error("Usage: toist run <file> [--payload '<json>']")
188
+ return readPipelineFile(opts.pathArg).parsed
189
+ }
190
+
191
+ async function loadRunPayload(opts: RunCliOptions): Promise<Record<string, unknown>> {
192
+ if (opts.payloadStdin || shouldUseDefaultPayloadStdin(opts)) {
193
+ return parsePayloadText(await readStdinText(), "payload from stdin")
194
+ }
195
+ return parsePayloadArg(opts.payloadArg)
196
+ }
197
+
198
+ function normalizeRemoteBaseUrl(input: string): string {
199
+ if (input.startsWith(":")) return `http://localhost${input}`
200
+ return input
201
+ }
202
+
203
+ function parseJsonValue(value: string | null | undefined): unknown {
204
+ if (!value) return null
205
+ try { return JSON.parse(value) } catch { return value }
206
+ }
207
+
208
+ function asCliTaskKind(kind: string): "human.input" | "error_review" {
209
+ if (kind !== "human.input" && kind !== "error_review") {
210
+ throw new Error(`unsupported task kind: ${kind}`)
211
+ }
212
+ return kind
213
+ }
214
+
215
+ async function fetchResultsMap(client: RunnerClient, runId: number): Promise<Record<string, unknown>> {
216
+ const nodes = await client.runs.nodes(runId)
217
+ const results: Record<string, unknown> = {}
218
+ for (const node of nodes) results[node.nodeId] = node.output
219
+ return results
220
+ }
221
+
222
+ async function normalizeRemoteRunResult(
223
+ client: RunnerClient,
224
+ response: PipelineRunResponse,
225
+ ): Promise<NormalizedRunResult> {
226
+ const base = {
227
+ runId: response.id,
228
+ status: response.status,
229
+ steps: ("steps" in response ? response.steps : []) as unknown as NormalizedStepList,
230
+ }
231
+
232
+ if (response.status === "done") {
233
+ return {
234
+ ...base,
235
+ status: "done",
236
+ output: response.result,
237
+ results: await fetchResultsMap(client, response.id),
238
+ }
239
+ }
240
+
241
+ if (response.status === "suspended") {
242
+ const task = response.task
243
+ if (!task) throw new Error(`remote run ${response.id} suspended without task payload`)
244
+
245
+ return {
246
+ ...base,
247
+ status: "suspended",
248
+ suspendedAt: {
249
+ taskId: task.id,
250
+ nodeId: response.suspendedAt,
251
+ cause: {
252
+ kind: asCliTaskKind(task.kind),
253
+ spec: {
254
+ prompt: task.prompt,
255
+ schema: task.schema,
256
+ assignee: task.assignee,
257
+ },
258
+ },
259
+ },
260
+ task,
261
+ results: await fetchResultsMap(client, response.id),
262
+ }
263
+ }
264
+
265
+ return {
266
+ ...base,
267
+ status: "error",
268
+ error: response.error,
269
+ }
270
+ }
271
+
272
+ async function waitForRemoteRun(client: RunnerClient, runId: number): Promise<PipelineRunResponse> {
273
+ for (;;) {
274
+ const run = await client.runs.get(runId)
275
+ if (run.status === "running") {
276
+ await Bun.sleep(50)
277
+ continue
278
+ }
279
+
280
+ if (run.status === "done") {
281
+ return {
282
+ id: run.id,
283
+ pipeline: run.pipeline,
284
+ status: "done",
285
+ result: parseJsonValue(run.result),
286
+ steps: parseStepsJson(run.steps) as never,
287
+ } as PipelineRunResponse
288
+ }
289
+
290
+ if (run.status === "suspended") {
291
+ const tasks = await client.tasks.list({ status: "open", runId, limit: 1 })
292
+ if (tasks.length === 0) {
293
+ throw new Error(`run ${runId} is suspended but has no open task`)
294
+ }
295
+ const task = await client.tasks.get(tasks[0].id)
296
+ return {
297
+ id: run.id,
298
+ pipeline: run.pipeline,
299
+ status: "suspended",
300
+ suspendedAt: run.current_node ?? task.nodeId,
301
+ task: {
302
+ id: task.id,
303
+ kind: task.kind,
304
+ prompt: task.prompt,
305
+ schema: task.schema,
306
+ assignee: task.assignee,
307
+ responseToken: task.responseToken,
308
+ },
309
+ steps: parseStepsJson(run.steps) as never,
310
+ } as PipelineRunResponse
311
+ }
312
+
313
+ return {
314
+ id: run.id,
315
+ pipeline: run.pipeline,
316
+ status: "error",
317
+ error: run.error ?? "unknown error",
318
+ }
319
+ }
320
+ }
321
+
322
+ async function printRemoteRunEvents(client: RunnerClient, runId: number) {
323
+ for await (const event of client.runs.events(runId)) {
324
+ console.error(`[event] ${event.type}`)
325
+ }
326
+ }
327
+
328
+ function parseStepsJson(value: string | null): Array<Record<string, unknown>> {
329
+ const parsed = parseJsonValue(value)
330
+ return Array.isArray(parsed) ? parsed as Array<Record<string, unknown>> : []
331
+ }
332
+
333
+ async function printRunEvents(result: RunSpecResult) {
334
+ if (!result.events) return
335
+ for await (const event of result.events) {
336
+ console.error(`[event] ${event.type}`)
337
+ }
338
+ }
339
+
340
+ async function findOpenTask(runtime: ToistRuntime, runId: number): Promise<CliTaskRef | null> {
341
+ const tasks = await runtime.tasks.list({ runId, status: "open", limit: 1 })
342
+ if (tasks.length === 0) return null
343
+ const task = await runtime.tasks.get(tasks[0].id)
344
+ if (!task) return null
345
+ return toCliTask(task)
346
+ }
347
+
348
+ function sniffPromptMode(schema: unknown):
349
+ | { kind: "boolean" }
350
+ | { kind: "string" }
351
+ | { kind: "boolean-object"; key: string }
352
+ | { kind: "string-object"; key: string }
353
+ | { kind: "json" }
354
+ | { kind: "value" } {
355
+ if (!schema || typeof schema !== "object") return { kind: "value" }
356
+
357
+ const typed = schema as { type?: unknown; properties?: Record<string, { type?: unknown }> }
358
+ if (typed.type === "boolean") return { kind: "boolean" }
359
+ if (typed.type === "string") return { kind: "string" }
360
+
361
+ if (typed.type === "object" && typed.properties && typeof typed.properties === "object") {
362
+ const entries = Object.entries(typed.properties)
363
+ if (entries.length === 1) {
364
+ const [key, property] = entries[0]
365
+ if (property?.type === "boolean") return { kind: "boolean-object", key }
366
+ if (property?.type === "string") return { kind: "string-object", key }
367
+ }
368
+ return { kind: "json" }
369
+ }
370
+
371
+ return { kind: "value" }
372
+ }
373
+
374
+ function toCliTask(task: { id: number; nodeId: string; kind: string; prompt: string; schema: unknown; assignee: string | null; responseToken: string }): CliTaskRef {
375
+ if (task.kind !== "human.input" && task.kind !== "error_review") {
376
+ throw new Error(`unsupported task kind: ${task.kind}`)
377
+ }
378
+ return {
379
+ id: task.id,
380
+ nodeId: task.nodeId,
381
+ kind: task.kind,
382
+ prompt: task.prompt,
383
+ schema: task.schema,
384
+ assignee: task.assignee,
385
+ responseToken: task.responseToken,
386
+ }
387
+ }
388
+
389
+ function toCliTaskFromResult(
390
+ task: NonNullable<Extract<RunSpecResult, { status: "suspended" }>["task"]>,
391
+ nodeId: string,
392
+ ): CliTaskRef {
393
+ if (task.kind !== "human.input" && task.kind !== "error_review") {
394
+ throw new Error(`unsupported task kind: ${task.kind}`)
395
+ }
396
+ return {
397
+ id: task.id,
398
+ nodeId,
399
+ kind: task.kind,
400
+ prompt: task.prompt,
401
+ schema: task.schema,
402
+ assignee: task.assignee,
403
+ responseToken: task.responseToken,
404
+ }
405
+ }
406
+
407
+ async function promptTaskResponse(task: CliTaskRef): Promise<unknown> {
408
+ const mode = sniffPromptMode(task.schema)
409
+ const rl = createInterface({ input: process.stdin, output: process.stderr })
410
+ try {
411
+ if (mode.kind === "boolean") {
412
+ const answer = (await rl.question(`⏸ ${task.nodeId} — ${task.prompt} [y/N]: `)).trim().toLowerCase()
413
+ return answer === "y" || answer === "yes" || answer === "true"
414
+ }
415
+
416
+ if (mode.kind === "boolean-object") {
417
+ const answer = (await rl.question(`⏸ ${task.nodeId} — ${task.prompt} [y/N]: `)).trim().toLowerCase()
418
+ return { [mode.key]: answer === "y" || answer === "yes" || answer === "true" }
419
+ }
420
+
421
+ const answer = await rl.question(`⏸ ${task.nodeId} — ${task.prompt}: `)
422
+ if (mode.kind === "string") return answer
423
+ if (mode.kind === "string-object") return { [mode.key]: answer }
424
+ if (mode.kind === "json") {
425
+ try { return JSON.parse(answer) } catch { return { response: answer } }
426
+ }
427
+ return { value: answer }
428
+ } finally {
429
+ rl.close()
430
+ }
431
+ }
432
+
433
+ function printFinalResult(result: PrintableRunResult) {
434
+ if ("events" in result) {
435
+ const { events: _events, ...printable } = result
436
+ console.log(JSON.stringify(printable, null, 2))
437
+ return
438
+ }
439
+ console.log(JSON.stringify(result, null, 2))
440
+ }
441
+
442
+ function printSuspendAsJson(result: PrintableSuspendedRunResult) {
443
+ console.log(JSON.stringify({ runId: result.runId, suspendedAt: result.suspendedAt }, null, 2))
444
+ }
445
+
446
+ function updateStoredRunState(result: RunSpecResult, descriptor: StoredRunDescriptor, ephemeral: boolean) {
447
+ if (ephemeral || result.runId === -1) return
448
+ if (result.status === "suspended") storeRunDescriptor(result.runId, descriptor)
449
+ else deleteRunDescriptor(result.runId)
450
+ }
451
+
452
+ async function resumeWithRuntime(
453
+ runtime: ToistRuntime,
454
+ runId: number,
455
+ task: CliTaskRef,
456
+ descriptor: StoredRunDescriptor,
457
+ response: unknown,
458
+ ): Promise<RunSpecResult> {
459
+ if (isResumableRuntime(runtime)) {
460
+ return await runtime.resumeRun(runId, {
461
+ token: task.responseToken,
462
+ response,
463
+ respondedBy: "cli",
464
+ })
465
+ }
466
+
467
+ return await resumeLocalStoredRun(runtime, runId, descriptor, {
468
+ taskId: task.id,
469
+ token: task.responseToken,
470
+ response,
471
+ respondedBy: "cli",
472
+ })
473
+ }
474
+
475
+ async function runLocalCommand(opts: RunCliOptions) {
476
+ if (opts.specStdin && opts.payloadStdin) {
477
+ console.error("--spec-stdin and --payload-stdin cannot both read from stdin")
478
+ process.exit(1)
479
+ }
480
+
481
+ const runtime = await getCliRuntime({ ephemeral: opts.ephemeral })
482
+ try {
483
+ const [spec, payload] = await Promise.all([loadRunSpec(opts), loadRunPayload(opts)])
484
+ const descriptor: StoredRunDescriptor = { spec, payload }
485
+ let result = await runSpec(spec, payload, {
486
+ runtime,
487
+ events: opts.suspendAsJson ? false : true,
488
+ })
489
+
490
+ while (true) {
491
+ updateStoredRunState(result, descriptor, opts.ephemeral)
492
+ if (!opts.suspendAsJson) await printRunEvents(result)
493
+
494
+ if (result.status === "suspended") {
495
+ if (opts.suspendAsJson) {
496
+ printSuspendAsJson(result)
497
+ return
498
+ }
499
+
500
+ const task = result.task
501
+ ? toCliTaskFromResult(result.task, result.suspendedAt.nodeId)
502
+ : await findOpenTask(runtime, result.runId)
503
+ if (task?.kind === "human.input" && canPromptInline(opts)) {
504
+ const response = await promptTaskResponse(task)
505
+ result = await resumeWithRuntime(runtime, result.runId, task, descriptor, response)
506
+ continue
507
+ }
508
+ }
509
+
510
+ printFinalResult(result)
511
+ if (result.status === "error") process.exit(1)
512
+ return
513
+ }
514
+ } catch (error) {
515
+ console.error((error as Error).message)
516
+ process.exit(1)
517
+ } finally {
518
+ await runtime.close?.()
519
+ }
520
+ }
521
+
522
+ async function runRemoteCommand(opts: RunCliOptions) {
523
+ if (!opts.remote) throw new Error("--remote requires a base URL")
524
+
525
+ const [spec, payload] = await Promise.all([loadRunSpec(opts), loadRunPayload(opts)])
526
+ const client = createRunnerClient({ baseUrl: normalizeRemoteBaseUrl(opts.remote) })
527
+ const started = await client.pipelines.runSpec(spec, payload)
528
+
529
+ if (opts.suspendAsJson) {
530
+ const final = await waitForRemoteRun(client, started.id)
531
+ const normalized = await normalizeRemoteRunResult(client, final)
532
+ if (normalized.status === "suspended") {
533
+ printSuspendAsJson(normalized)
534
+ return
535
+ }
536
+ printFinalResult(normalized)
537
+ if (normalized.status === "error") process.exit(1)
538
+ return
539
+ }
540
+
541
+ const eventsPromise = printRemoteRunEvents(client, started.id)
542
+ const final = await waitForRemoteRun(client, started.id)
543
+ const normalized = await normalizeRemoteRunResult(client, final)
544
+ await eventsPromise
545
+ printFinalResult(normalized)
546
+ if (normalized.status === "error") process.exit(1)
547
+ }
548
+
549
+ async function runCommand(opts: RunCliOptions) {
550
+ if (opts.remote && opts.ephemeral) {
551
+ console.error("--remote and --ephemeral cannot be used together")
552
+ process.exit(1)
553
+ }
554
+
555
+ if (opts.remote) {
556
+ try {
557
+ await runRemoteCommand(opts)
558
+ } catch (error) {
559
+ console.error((error as Error).message)
560
+ process.exit(1)
561
+ }
562
+ return
563
+ }
564
+
565
+ await runLocalCommand(opts)
566
+ }
567
+
568
+ function parseResponseArg(input?: string): unknown {
569
+ if (!input) return undefined
570
+ try {
571
+ return JSON.parse(input)
572
+ } catch (err) {
573
+ throw new Error(`invalid JSON response: ${(err as Error).message}`)
574
+ }
575
+ }
576
+
577
+ async function resumeCommand(opts: ResumeCliOptions) {
578
+ if (opts.runId === undefined || Number.isNaN(opts.runId)) {
579
+ console.error("Usage: toist resume <runId> [--response '<json>'] [--suspend-as-json]")
580
+ process.exit(1)
581
+ }
582
+
583
+ const descriptor = loadRunDescriptor(opts.runId)
584
+ if (!descriptor) {
585
+ console.error(`No stored run descriptor for run ${opts.runId}. Was it started with --ephemeral?`)
586
+ process.exit(1)
587
+ }
588
+
589
+ const runtime = await getCliRuntime()
590
+ try {
591
+ let task = await findOpenTask(runtime, opts.runId)
592
+ if (!task) {
593
+ console.error(`run ${opts.runId} has no open task`)
594
+ process.exit(1)
595
+ }
596
+
597
+ let response = parseResponseArg(opts.responseArg)
598
+ if (response === undefined) {
599
+ if (task.kind !== "human.input") {
600
+ console.error("--response is required for non-human.input tasks")
601
+ process.exit(1)
602
+ }
603
+ response = await promptTaskResponse(task)
604
+ }
605
+
606
+ let result = await resumeWithRuntime(runtime, opts.runId, task, descriptor, response)
607
+ while (true) {
608
+ updateStoredRunState(result, descriptor, false)
609
+ if (!opts.suspendAsJson) await printRunEvents(result)
610
+
611
+ if (result.status === "suspended") {
612
+ if (opts.suspendAsJson) {
613
+ printSuspendAsJson(result)
614
+ return
615
+ }
616
+
617
+ task = result.task
618
+ ? toCliTaskFromResult(result.task, result.suspendedAt.nodeId)
619
+ : await findOpenTask(runtime, result.runId)
620
+ if (task?.kind === "human.input") {
621
+ const nextResponse = await promptTaskResponse(task)
622
+ result = await resumeWithRuntime(runtime, result.runId, task, descriptor, nextResponse)
623
+ continue
624
+ }
625
+ }
626
+
627
+ printFinalResult(result)
628
+ if (result.status === "error") process.exit(1)
629
+ return
630
+ }
631
+ } catch (error) {
632
+ console.error((error as Error).message)
633
+ process.exit(1)
634
+ } finally {
635
+ await runtime.close?.()
636
+ }
637
+ }
638
+
639
+ function formatAge(iso: string): string {
640
+ const delta = Math.max(0, Date.now() - new Date(iso).getTime())
641
+ const seconds = Math.floor(delta / 1000)
642
+ if (seconds < 60) return `${seconds}s`
643
+ const minutes = Math.floor(seconds / 60)
644
+ if (minutes < 60) return `${minutes}m`
645
+ const hours = Math.floor(minutes / 60)
646
+ if (hours < 24) return `${hours}h`
647
+ return `${Math.floor(hours / 24)}d`
648
+ }
649
+
650
+ function printTaskTable(rows: Array<{ id: number; run_id: number; node_id: string; kind: string; created_at: string }>) {
651
+ const headers = ["ID", "RUN", "NODE", "KIND", "AGE"]
652
+ const body = rows.map((row) => [String(row.id), String(row.run_id), row.node_id, row.kind, formatAge(row.created_at)])
653
+ const widths = headers.map((header, index) => Math.max(header.length, ...body.map((cols) => cols[index].length)))
654
+ console.log(headers.map((header, index) => header.padEnd(widths[index])).join(" "))
655
+ for (const cols of body) {
656
+ console.log(cols.map((value, index) => value.padEnd(widths[index])).join(" "))
657
+ }
658
+ }
659
+
660
+ async function listTasksCommand(opts: TasksCliOptions) {
661
+ if (opts.remote) {
662
+ const client = createRunnerClient({ baseUrl: normalizeRemoteBaseUrl(opts.remote) })
663
+ const tasks = await client.tasks.list({ status: "open" })
664
+ if (opts.format === "json") console.log(JSON.stringify(tasks, null, 2))
665
+ else printTaskTable(tasks)
666
+ return
667
+ }
668
+
669
+ const runtime = await getCliRuntime()
670
+ try {
671
+ const tasks = await runtime.tasks.list({ status: "open" })
672
+ if (opts.format === "json") console.log(JSON.stringify(tasks, null, 2))
673
+ else printTaskTable(tasks)
674
+ } finally {
675
+ await runtime.close?.()
676
+ }
677
+ }
678
+
679
+ async function answerTaskCommand(taskId: number, responseArg?: string, remote?: string) {
680
+ if (Number.isNaN(taskId)) {
681
+ console.error("Usage: toist tasks answer <id> [--response '<json>'] [--remote <url>]")
682
+ process.exit(1)
683
+ }
684
+
685
+ if (remote) {
686
+ const client = createRunnerClient({ baseUrl: normalizeRemoteBaseUrl(remote) })
687
+ const task = await client.tasks.get(taskId)
688
+ const response = parseResponseArg(responseArg) ?? (task.kind === "human.input"
689
+ ? await promptTaskResponse(toCliTask(task))
690
+ : (() => { throw new Error("--response is required for non-human.input tasks") })())
691
+ const result = await client.tasks.respond(task.runId, task.id, {
692
+ token: task.responseToken,
693
+ response,
694
+ respondedBy: "cli",
695
+ })
696
+ const normalized = await normalizeRemoteRunResult(client, result)
697
+ printFinalResult(normalized)
698
+ if (normalized.status === "error") process.exit(1)
699
+ return
700
+ }
701
+
702
+ const runtime = await getCliRuntime()
703
+ try {
704
+ let task = await runtime.tasks.get(taskId)
705
+ if (!task) {
706
+ console.error(`task ${taskId} not found`)
707
+ process.exit(1)
708
+ }
709
+
710
+ const descriptor = loadRunDescriptor(task.runId)
711
+ if (!descriptor) {
712
+ console.error(`No stored run descriptor for run ${task.runId}`)
713
+ process.exit(1)
714
+ }
715
+
716
+ let response = parseResponseArg(responseArg)
717
+ if (response === undefined) {
718
+ if (task.kind !== "human.input") {
719
+ console.error("--response is required for non-human.input tasks")
720
+ process.exit(1)
721
+ }
722
+ response = await promptTaskResponse(toCliTask(task))
723
+ }
724
+
725
+ let result = await resumeLocalStoredRun(runtime, task.runId, descriptor, {
726
+ taskId: task.id,
727
+ token: task.responseToken,
728
+ response,
729
+ respondedBy: "cli",
730
+ })
731
+
732
+ while (true) {
733
+ updateStoredRunState(result, descriptor, false)
734
+ await printRunEvents(result)
735
+ if (result.status === "suspended") {
736
+ task = result.task
737
+ ? { ...task, ...toCliTaskFromResult(result.task, result.suspendedAt.nodeId) }
738
+ : await runtime.tasks.get(taskId)
739
+ if (task?.kind === "human.input" && responseArg === undefined) {
740
+ const nextResponse = await promptTaskResponse(toCliTask(task))
741
+ result = await resumeLocalStoredRun(runtime, task.runId, descriptor, {
742
+ taskId: task.id,
743
+ token: task.responseToken,
744
+ response: nextResponse,
745
+ respondedBy: "cli",
746
+ })
747
+ continue
748
+ }
749
+ }
750
+ printFinalResult(result)
751
+ if (result.status === "error") process.exit(1)
752
+ return
753
+ }
754
+ } catch (error) {
755
+ console.error((error as Error).message)
756
+ process.exit(1)
757
+ } finally {
758
+ await runtime.close?.()
759
+ }
760
+ }
761
+
762
+ async function serveCommand(opts: ServeCliOptions) {
763
+ const runner = await startRunner({
764
+ rootDir: opts.rootDir,
765
+ port: opts.port,
766
+ storage: opts.memory ? "memory" : "sqlite",
767
+ disableUi: opts.noUi,
768
+ disableWatch: opts.noWatch,
769
+ })
770
+
771
+ const stop = async () => {
772
+ process.off("SIGINT", stop)
773
+ process.off("SIGTERM", stop)
774
+ await runner.stop()
775
+ process.exit(0)
776
+ }
777
+
778
+ process.on("SIGINT", stop)
779
+ process.on("SIGTERM", stop)
780
+ await new Promise(() => {})
781
+ }
782
+
783
+ async function validateFiles(pathArgs: string[]) {
784
+ if (pathArgs.length === 0) {
785
+ console.error("Usage: toist validate <file...>")
786
+ process.exit(1)
787
+ }
788
+
789
+ let ok = true
790
+ for (const pathArg of pathArgs) {
791
+ try {
792
+ const { path, parsed } = readPipelineFile(pathArg)
793
+ const result = validateSpec(parsed)
794
+
795
+ if (!result.ok) {
796
+ ok = false
797
+ console.error(`✗ ${path}`)
798
+ for (const error of result.errors) console.error(` - ${error}`)
799
+ continue
800
+ }
801
+
802
+ console.log(`✓ ${path}`)
803
+ for (const warning of result.warnings ?? []) console.log(` warning: ${warning}`)
804
+ } catch (error) {
805
+ ok = false
806
+ console.error(`✗ ${pathArg}`)
807
+ console.error(` - ${(error as Error).message}`)
808
+ }
809
+ }
810
+
811
+ if (!ok) process.exit(1)
812
+ }
813
+
814
+ async function planFile(pathArg?: string, format = "ascii") {
815
+ if (!pathArg) {
816
+ console.error("Usage: toist plan <file> [--format ascii|dot]")
817
+ process.exit(1)
818
+ }
819
+
820
+ if (format !== "ascii" && format !== "dot") {
821
+ console.error(`Unknown format: ${format}`)
822
+ process.exit(1)
823
+ }
824
+
825
+ try {
826
+ const { path, parsed } = readPipelineFile(pathArg)
827
+ const spec = requireParsedSpec(parsed, path)
828
+ console.log(format === "dot" ? renderDot(spec) : renderAscii(spec))
829
+ } catch (error) {
830
+ console.error((error as Error).message)
831
+ process.exit(1)
832
+ }
833
+ }
834
+
835
+ function renderDot(spec: PipelineSpec): string {
836
+ const lines = [`digraph \"${spec.id}\" {`, " rankdir=LR;"]
837
+ const seenEdges = new Set<string>()
838
+ for (const node of spec.parsedNodes) lines.push(` \"${node.id}\";`)
839
+ for (const edge of spec.edges) {
840
+ const key = `${edge.from}->${edge.to}`
841
+ if (seenEdges.has(key)) continue
842
+ seenEdges.add(key)
843
+ lines.push(` \"${edge.from}\" -> \"${edge.to}\";`)
844
+ }
845
+ lines.push("}")
846
+ return lines.join("\n")
847
+ }
848
+
849
+ function renderAscii(spec: PipelineSpec): string {
850
+ const children = new Map<string, string[]>()
851
+ const incoming = new Map<string, number>()
852
+
853
+ for (const node of spec.parsedNodes) {
854
+ children.set(node.id, [])
855
+ incoming.set(node.id, 0)
856
+ }
857
+
858
+ const seenEdges = new Set<string>()
859
+ for (const edge of spec.edges) {
860
+ const key = `${edge.from}->${edge.to}`
861
+ if (seenEdges.has(key)) continue
862
+ seenEdges.add(key)
863
+ children.get(edge.from)?.push(edge.to)
864
+ incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1)
865
+ }
866
+
867
+ const roots = spec.parsedNodes
868
+ .map((node) => node.id)
869
+ .filter((id) => (incoming.get(id) ?? 0) === 0)
870
+
871
+ const lines: string[] = []
872
+
873
+ const walk = (id: string, prefix: string, isLast: boolean, trail: Set<string>, depth: number) => {
874
+ const branch = depth === 0 ? "" : `${prefix}${isLast ? "└─ " : "├─ "}`
875
+ lines.push(`${branch}${id}`)
876
+
877
+ const nextPrefix = depth === 0 ? "" : `${prefix}${isLast ? " " : "│ "}`
878
+ const nextChildren = children.get(id) ?? []
879
+ for (let i = 0; i < nextChildren.length; i++) {
880
+ const child = nextChildren[i]
881
+ if (trail.has(child)) {
882
+ lines.push(`${nextPrefix}${i === nextChildren.length - 1 ? "└─ " : "├─ "}${child} ↺`)
883
+ continue
884
+ }
885
+ walk(child, nextPrefix, i === nextChildren.length - 1, new Set([...trail, child]), depth + 1)
886
+ }
887
+ }
888
+
889
+ for (let i = 0; i < roots.length; i++) {
890
+ if (i > 0) lines.push("")
891
+ walk(roots[i], "", true, new Set([roots[i]]), 0)
892
+ }
893
+
894
+ return lines.join("\n")
895
+ }
896
+
897
+ if (!cmd) {
898
+ await wizard()
899
+ } else if (cmd === "help" || cmd === "--help" || cmd === "-h") {
900
+ help()
901
+ } else if (cmd === "init") {
902
+ await wizard(process.cwd(), args[1])
903
+ } else if (cmd === "upgrade") {
904
+ await upgrade()
905
+ } else if (cmd === "run") {
906
+ const opts: RunCliOptions = {
907
+ specStdin: false,
908
+ payloadStdin: false,
909
+ suspendAsJson: false,
910
+ ephemeral: false,
911
+ }
912
+
913
+ const positional: string[] = []
914
+ for (let i = 1; i < args.length; i++) {
915
+ const arg = args[i]
916
+ if (arg === "--payload" || arg === "-p") opts.payloadArg = args[++i]
917
+ else if (arg.startsWith("--payload=")) opts.payloadArg = arg.slice("--payload=".length)
918
+ else if (arg === "--remote") opts.remote = args[++i]
919
+ else if (arg.startsWith("--remote=")) opts.remote = arg.slice("--remote=".length)
920
+ else if (arg === "--spec-stdin") opts.specStdin = true
921
+ else if (arg === "--payload-stdin") opts.payloadStdin = true
922
+ else if (arg === "--suspend-as-json") opts.suspendAsJson = true
923
+ else if (arg === "--ephemeral") opts.ephemeral = true
924
+ else positional.push(arg)
925
+ }
926
+
927
+ opts.pathArg = positional[0]
928
+ await runCommand(opts)
929
+ } else if (cmd === "resume") {
930
+ const opts: ResumeCliOptions = { suspendAsJson: false }
931
+ const positional: string[] = []
932
+ for (let i = 1; i < args.length; i++) {
933
+ const arg = args[i]
934
+ if (arg === "--response") opts.responseArg = args[++i]
935
+ else if (arg.startsWith("--response=")) opts.responseArg = arg.slice("--response=".length)
936
+ else if (arg === "--suspend-as-json") opts.suspendAsJson = true
937
+ else positional.push(arg)
938
+ }
939
+ opts.runId = positional[0] ? Number(positional[0]) : undefined
940
+ await resumeCommand(opts)
941
+ } else if (cmd === "serve") {
942
+ const opts: ServeCliOptions = {
943
+ port: 5172,
944
+ rootDir: process.cwd(),
945
+ memory: false,
946
+ noUi: false,
947
+ noWatch: false,
948
+ }
949
+ for (let i = 1; i < args.length; i++) {
950
+ const arg = args[i]
951
+ if (arg === "--port") opts.port = Number(args[++i] ?? opts.port)
952
+ else if (arg.startsWith("--port=")) opts.port = Number(arg.slice("--port=".length))
953
+ else if (arg === "--rootDir") opts.rootDir = resolve(process.cwd(), args[++i] ?? ".")
954
+ else if (arg.startsWith("--rootDir=")) opts.rootDir = resolve(process.cwd(), arg.slice("--rootDir=".length))
955
+ else if (arg === "--memory") opts.memory = true
956
+ else if (arg === "--no-ui") opts.noUi = true
957
+ else if (arg === "--no-watch") opts.noWatch = true
958
+ }
959
+ await serveCommand(opts)
960
+ } else if (cmd === "tasks") {
961
+ const sub = args[1]
962
+ if (sub === "list") {
963
+ const opts: TasksCliOptions = { format: "table" }
964
+ for (let i = 2; i < args.length; i++) {
965
+ const arg = args[i]
966
+ if (arg === "--remote") opts.remote = args[++i]
967
+ else if (arg.startsWith("--remote=")) opts.remote = arg.slice("--remote=".length)
968
+ else if (arg === "--format") opts.format = (args[++i] as "table" | "json") ?? "table"
969
+ else if (arg.startsWith("--format=")) opts.format = arg.slice("--format=".length) as "table" | "json"
970
+ }
971
+ await listTasksCommand(opts)
972
+ } else if (sub === "answer") {
973
+ let responseArg: string | undefined
974
+ let remote: string | undefined
975
+ for (let i = 3; i < args.length; i++) {
976
+ const arg = args[i]
977
+ if (arg === "--response") responseArg = args[++i]
978
+ else if (arg.startsWith("--response=")) responseArg = arg.slice("--response=".length)
979
+ else if (arg === "--remote") remote = args[++i]
980
+ else if (arg.startsWith("--remote=")) remote = arg.slice("--remote=".length)
981
+ }
982
+ await answerTaskCommand(Number(args[2]), responseArg, remote)
983
+ } else {
984
+ console.error("Usage: toist tasks <list|answer>")
985
+ process.exit(1)
986
+ }
987
+ } else if (cmd === "validate") {
988
+ await validateFiles(args.slice(1))
989
+ } else if (cmd === "plan") {
990
+ let format = "ascii"
991
+ for (let i = 2; i < args.length; i++) {
992
+ if (args[i] === "--format") format = args[++i] ?? format
993
+ else if (args[i].startsWith("--format=")) format = args[i].slice("--format=".length)
994
+ }
995
+ await planFile(args[1], format)
996
+ } else if (cmd === ".toist" || cmd.startsWith("./") || cmd.startsWith("../") || cmd.startsWith("/")) {
997
+ await wizard(process.cwd(), cmd)
998
+ } else {
999
+ console.error(`Unknown command: ${cmd}`)
1000
+ console.error("")
1001
+ help()
1002
+ process.exit(1)
1003
+ }