@osolmaz/pi-workflows 0.15.2 → 0.16.0

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 (124) hide show
  1. package/README.md +6 -6
  2. package/dist/client/activity.d.ts +2 -0
  3. package/dist/client/activity.js +6 -0
  4. package/dist/client/activity.js.map +1 -0
  5. package/dist/client/client.d.ts +101 -0
  6. package/dist/client/client.js +733 -0
  7. package/dist/client/client.js.map +1 -0
  8. package/dist/client/index.d.ts +3 -0
  9. package/dist/client/index.js +3 -0
  10. package/dist/client/index.js.map +1 -0
  11. package/dist/client/materialize.d.ts +7 -0
  12. package/dist/client/materialize.js +177 -0
  13. package/dist/client/materialize.js.map +1 -0
  14. package/dist/client/protocol.d.ts +60 -0
  15. package/dist/client/protocol.js +269 -0
  16. package/dist/client/protocol.js.map +1 -0
  17. package/dist/client/resolver.d.ts +23 -0
  18. package/dist/client/resolver.js +2 -0
  19. package/dist/client/resolver.js.map +1 -0
  20. package/dist/client/view.d.ts +118 -0
  21. package/dist/client/view.js +3 -0
  22. package/dist/client/view.js.map +1 -0
  23. package/dist/controllers/sqlite.d.ts +64 -0
  24. package/dist/controllers/sqlite.js +219 -3
  25. package/dist/controllers/sqlite.js.map +1 -1
  26. package/dist/extension/index.d.ts +1 -0
  27. package/dist/extension/index.js +384 -156
  28. package/dist/extension/index.js.map +1 -1
  29. package/dist/extension/session-delivery.d.ts +6 -0
  30. package/dist/extension/session-delivery.js +80 -25
  31. package/dist/extension/session-delivery.js.map +1 -1
  32. package/dist/extension/session-view.d.ts +21 -0
  33. package/dist/extension/session-view.js +127 -0
  34. package/dist/extension/session-view.js.map +1 -0
  35. package/dist/extension/widget.d.ts +2 -1
  36. package/dist/extension/widget.js +15 -7
  37. package/dist/extension/widget.js.map +1 -1
  38. package/dist/host/child-worker-supervisor.js +1 -1
  39. package/dist/host/child-worker-supervisor.js.map +1 -1
  40. package/dist/host/resolver-entry.d.ts +2 -23
  41. package/dist/host/resolver-entry.js +1 -1
  42. package/dist/host/resolver-entry.js.map +1 -1
  43. package/dist/host/runner.d.ts +12 -0
  44. package/dist/host/runner.js +565 -43
  45. package/dist/host/runner.js.map +1 -1
  46. package/dist/host/state.d.ts +8 -3
  47. package/dist/host/state.js +59 -27
  48. package/dist/host/state.js.map +1 -1
  49. package/dist/host/view.d.ts +73 -0
  50. package/dist/host/view.js +871 -0
  51. package/dist/host/view.js.map +1 -0
  52. package/dist/host/worker-protocol.js +1 -1
  53. package/dist/host/worker-protocol.js.map +1 -1
  54. package/dist/state/database.d.ts +1 -0
  55. package/dist/state/database.js +15 -0
  56. package/dist/state/database.js.map +1 -1
  57. package/dist/state/prune.d.ts +3 -1
  58. package/dist/state/prune.js +6 -8
  59. package/dist/state/prune.js.map +1 -1
  60. package/dist/state/schema.js +12 -1
  61. package/dist/state/schema.js.map +1 -1
  62. package/dist/viewer/backup.d.ts +2 -0
  63. package/dist/viewer/backup.js +28 -0
  64. package/dist/viewer/backup.js.map +1 -0
  65. package/dist/viewer/cli.d.ts +4 -0
  66. package/dist/viewer/cli.js +150 -170
  67. package/dist/viewer/cli.js.map +1 -1
  68. package/dist/viewer/tui.d.ts +5 -7
  69. package/dist/viewer/tui.js +188 -108
  70. package/dist/viewer/tui.js.map +1 -1
  71. package/dist/workflows/store.d.ts +62 -1
  72. package/dist/workflows/store.js +350 -44
  73. package/dist/workflows/store.js.map +1 -1
  74. package/docs/2026-09-01-restore-session-delivery-controls-plan.md +139 -0
  75. package/docs/2026-09-01-unified-workflow-client-plan.md +381 -0
  76. package/docs/2026-09-02-installed-live-e2e-plan.md +225 -0
  77. package/docs/SQLITE_STATE.md +13 -11
  78. package/docs/WORKFLOW_HOST.md +81 -64
  79. package/docs/WORKFLOW_STEP_MESSAGES.md +4 -4
  80. package/docs/development.md +2 -1
  81. package/docs/live-replay-protocol.md +70 -132
  82. package/docs/tui-viewer.md +10 -14
  83. package/docs/workflows.md +56 -3
  84. package/herdr-plugin.toml +1 -1
  85. package/package.json +9 -3
  86. package/protocol/client.v1.schema.json +137 -0
  87. package/protocol/fixtures/client-v1.json +23 -0
  88. package/src/client/activity.ts +6 -0
  89. package/src/client/client.ts +935 -0
  90. package/src/client/index.ts +24 -0
  91. package/src/client/materialize.ts +228 -0
  92. package/src/client/protocol.ts +327 -0
  93. package/src/client/resolver.ts +26 -0
  94. package/src/client/view.ts +138 -0
  95. package/src/controllers/sqlite.ts +342 -3
  96. package/src/extension/index.ts +482 -171
  97. package/src/extension/session-delivery.ts +88 -25
  98. package/src/extension/session-view.ts +154 -0
  99. package/src/extension/widget.ts +18 -9
  100. package/src/host/child-worker-supervisor.ts +1 -1
  101. package/src/host/resolver-entry.ts +11 -26
  102. package/src/host/runner.ts +749 -75
  103. package/src/host/state.ts +82 -44
  104. package/src/host/view.ts +1084 -0
  105. package/src/host/worker-protocol.ts +1 -1
  106. package/src/state/database.ts +13 -0
  107. package/src/state/prune.ts +11 -11
  108. package/src/state/schema.ts +12 -1
  109. package/src/viewer/backup.ts +29 -0
  110. package/src/viewer/cli.ts +171 -185
  111. package/src/viewer/tui.ts +196 -124
  112. package/src/workflows/store.ts +500 -45
  113. package/dist/host/client.d.ts +0 -48
  114. package/dist/host/client.js +0 -216
  115. package/dist/host/client.js.map +0 -1
  116. package/dist/host/protocol.d.ts +0 -38
  117. package/dist/host/protocol.js +0 -156
  118. package/dist/host/protocol.js.map +0 -1
  119. package/dist/viewer/watch.d.ts +0 -6
  120. package/dist/viewer/watch.js +0 -46
  121. package/dist/viewer/watch.js.map +0 -1
  122. package/src/host/client.ts +0 -293
  123. package/src/host/protocol.ts +0 -196
  124. package/src/viewer/watch.ts +0 -51
@@ -1,5 +1,5 @@
1
+ import { MAX_PROTOCOL_MESSAGE_BYTES } from "../client/protocol.js";
1
2
  import { canonicalJson, parseJson, type JsonValue } from "../state/json.js";
2
- import { MAX_PROTOCOL_MESSAGE_BYTES } from "./protocol.js";
3
3
 
4
4
  export const WORKER_MESSAGE_SCHEMA = "pi-workflows.worker-message.v1" as const;
5
5
  export const WORKER_RESPONSE_SCHEMA = "pi-workflows.worker-response.v1" as const;
@@ -110,6 +110,19 @@ export class StateDatabase {
110
110
  this.connection.close();
111
111
  }
112
112
 
113
+ readTransaction<T>(operation: () => T): T {
114
+ if (this.connection.inTransaction) return operation();
115
+ this.connection.exec("BEGIN");
116
+ try {
117
+ const result = operation();
118
+ this.connection.exec("COMMIT");
119
+ return result;
120
+ } catch (error) {
121
+ if (this.connection.inTransaction) this.connection.exec("ROLLBACK");
122
+ throw error;
123
+ }
124
+ }
125
+
113
126
  transaction<T>(operation: () => T): T {
114
127
  if (this.mode === "read-only") {
115
128
  throw new Error("Cannot mutate a read-only Pi Workflows database");
@@ -34,25 +34,26 @@ export type StatePruneReport = {
34
34
  applied: boolean;
35
35
  };
36
36
 
37
- export async function pruneState(
38
- databasePath: string,
39
- options: StatePruneOptions,
40
- ): Promise<StatePruneReport> {
41
- const cutoff = parseCutoff(options.before);
37
+ export function validateStatePruneOptions(options: StatePruneOptions): void {
38
+ parseCutoff(options.before);
42
39
  if (options.apply && options.backupPath === undefined) {
43
40
  throw new Error("state prune --apply requires --backup <absolute-path>");
44
41
  }
45
42
  if (options.backupPath !== undefined && !path.isAbsolute(options.backupPath)) {
46
43
  throw new Error("state prune backup path must be absolute");
47
44
  }
45
+ }
46
+
47
+ export async function pruneState(
48
+ state: StateDatabase,
49
+ databasePath: string,
50
+ options: StatePruneOptions,
51
+ ): Promise<StatePruneReport> {
52
+ validateStatePruneOptions(options);
53
+ const cutoff = parseCutoff(options.before);
48
54
  const lockPath = `${databasePath}.maintenance.lock`;
49
55
  const lock = options.apply ? acquireMaintenanceLock(lockPath) : undefined;
50
- let state: StateDatabase | undefined;
51
56
  try {
52
- state = new StateDatabase({
53
- filePath: databasePath,
54
- mode: options.apply ? "read-write" : "read-only",
55
- });
56
57
  const selection = selectRunTrees(state, cutoff);
57
58
  const sizeBefore = databaseBytes(databasePath);
58
59
  const base = {
@@ -136,7 +137,6 @@ export async function pruneState(
136
137
  applied: true,
137
138
  };
138
139
  } finally {
139
- state?.close();
140
140
  if (lock !== undefined) releaseMaintenanceLock(lockPath, lock);
141
141
  }
142
142
  }
@@ -137,6 +137,17 @@ CREATE INDEX runs_project_idx ON runs(project_id, created_at DESC);
137
137
  CREATE INDEX runs_status_idx ON runs(status, updated_at DESC);
138
138
  CREATE INDEX runs_parent_idx ON runs(parent_run_id);
139
139
 
140
+ CREATE TABLE run_view_content (
141
+ run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
142
+ content_hash BLOB NOT NULL,
143
+ media_type TEXT NOT NULL CHECK (media_type IN ('application/json', 'text/plain')),
144
+ byte_length INTEGER NOT NULL CHECK (byte_length >= 0),
145
+ content BLOB NOT NULL,
146
+ created_at INTEGER NOT NULL,
147
+ PRIMARY KEY (run_id, content_hash, media_type),
148
+ CHECK (byte_length = length(content))
149
+ ) STRICT;
150
+
140
151
  CREATE TABLE viewer_runs (
141
152
  run_id TEXT PRIMARY KEY REFERENCES runs(run_id) ON DELETE CASCADE,
142
153
  presentation_revision INTEGER NOT NULL CHECK (presentation_revision >= 1),
@@ -303,7 +314,7 @@ CREATE TABLE host_commands (
303
314
  'checkpoint.answer', 'decision.answer', 'interaction.submit', 'interaction.update',
304
315
  'notification.claim', 'notification.deliver', 'turn.claim', 'turn.resolve',
305
316
  'controller.list', 'controller.get', 'controller.apply', 'controller.reconcile', 'controller.delete',
306
- 'host.status', 'host.stop'
317
+ 'host.status', 'host.stop', 'state.backup', 'state.prune'
307
318
  )),
308
319
  idempotency_key TEXT NOT NULL,
309
320
  request_fingerprint BLOB NOT NULL CHECK (length(request_fingerprint) = 32),
@@ -0,0 +1,29 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { StateDatabase, workflowStatePath } from "../state/database.js";
4
+
5
+ /** Verify only an explicit inactive backup. Active state is verified through WorkflowClient. */
6
+ export function verifyInactiveBackup(
7
+ databasePath: string,
8
+ activeDatabasePath: string = workflowStatePath(),
9
+ ): void {
10
+ const resolved = path.resolve(databasePath);
11
+ const active = path.resolve(activeDatabasePath);
12
+ if (sameFile(resolved, active)) {
13
+ throw new Error("Active workflow state must be verified through the workflow host");
14
+ }
15
+ const state = new StateDatabase({ filePath: resolved, mode: "read-only" });
16
+ try {
17
+ state.integrityCheck();
18
+ } finally {
19
+ state.close();
20
+ }
21
+ }
22
+
23
+ function sameFile(left: string, right: string): boolean {
24
+ if (left === right) return true;
25
+ if (!fs.existsSync(left) || !fs.existsSync(right)) return false;
26
+ const leftStat = fs.statSync(left);
27
+ const rightStat = fs.statSync(right);
28
+ return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
29
+ }
package/src/viewer/cli.ts CHANGED
@@ -1,22 +1,16 @@
1
1
  #!/usr/bin/env node
2
- import fs, { realpathSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { realpathSync } from "node:fs";
3
4
  import path from "node:path";
4
5
  import { fileURLToPath, pathToFileURL } from "node:url";
5
- import { SqliteControllerStore } from "../controllers/sqlite.js";
6
+ import { WorkflowClient } from "../client/client.js";
7
+ import { materializeRunView } from "../client/materialize.js";
6
8
  import { syncHerdrPlugin } from "../herdr/setup.js";
7
- import { WorkflowHostClient } from "../host/client.js";
8
- import { sanitizeText } from "../render/ansi.js";
9
- import { StateDatabase } from "../state/database.js";
10
- import { pruneState } from "../state/prune.js";
11
- import { WorkflowRunStore, workflowStateDatabasePath } from "../workflows/store.js";
12
- import {
13
- formatDuration,
14
- renderRunDetailLines,
15
- renderRunListLines,
16
- runElapsedMs,
17
- statusLabel,
18
- } from "./render.js";
19
- import { runViewer } from "./tui.js";
9
+ import type { JsonValue } from "../state/json.js";
10
+ import { verifyInactiveBackup } from "./backup.js";
11
+ import { renderClientView, runViewer } from "./tui.js";
12
+
13
+ const CLI_CLIENT_ID = "pi-workflows-cli";
20
14
 
21
15
  const USAGE = `pi-workflows — workflow runs and controller resources
22
16
 
@@ -25,7 +19,8 @@ Usage:
25
19
  pi-workflows runs
26
20
  pi-workflows controllers
27
21
  pi-workflows controller <controller> <key>
28
- pi-workflows state status|verify
22
+ pi-workflows state status
23
+ pi-workflows state verify [inactive-backup]
29
24
  pi-workflows state backup <destination>
30
25
  pi-workflows state prune --before <timestamp> --dry-run
31
26
  pi-workflows state prune --before <timestamp> --backup <absolute-path> --apply
@@ -36,7 +31,7 @@ Usage:
36
31
  pi-workflows herdr sync [--json]
37
32
  pi-workflows herdr setup [--json]
38
33
 
39
- All commands use ~/.pi/agent/workflows/state.sqlite.
34
+ Active state is available only through the package-owned workflow host.
40
35
  `;
41
36
 
42
37
  export type CliArgs = {
@@ -48,6 +43,7 @@ export type CliArgs = {
48
43
  hostAction?: "start" | "status" | "stop" | "run";
49
44
  stateAction?: "status" | "verify" | "backup" | "prune";
50
45
  backupDestination?: string;
46
+ verifyBackup?: string;
51
47
  pruneBefore?: string;
52
48
  pruneApply?: boolean;
53
49
  once: boolean;
@@ -58,6 +54,13 @@ export type CliArgs = {
58
54
  export function parseCliArgs(argv: string[]): CliArgs {
59
55
  const args = [...argv];
60
56
  const command = args[0] && !args[0].startsWith("-") ? (args.shift() as string) : "view";
57
+ if (
58
+ !["view", "runs", "controllers", "controller", "state", "host", "herdr", "help"].includes(
59
+ command,
60
+ )
61
+ ) {
62
+ throw new Error(`Unknown command: ${command}`);
63
+ }
61
64
  let once = false;
62
65
  let json = false;
63
66
  let project: string | undefined;
@@ -143,12 +146,22 @@ export function parseCliArgs(argv: string[]): CliArgs {
143
146
  json,
144
147
  };
145
148
  }
149
+ if (action === "verify") {
150
+ if (positionals.length > 2) throw new Error("state verify accepts one inactive backup path");
151
+ return {
152
+ command,
153
+ stateAction: action,
154
+ ...(positionals[1] === undefined ? {} : { verifyBackup: positionals[1] }),
155
+ once,
156
+ json,
157
+ };
158
+ }
146
159
  if (positionals.length !== 1) throw new Error(`state ${action} accepts no other arguments`);
147
160
  return { command, stateAction: action, once, json };
148
161
  }
149
162
  if (command === "herdr") {
150
163
  if (positionals.length !== 1 || !["sync", "setup"].includes(positionals[0] as string)) {
151
- throw new Error("herdr requires the sync action");
164
+ throw new Error("herdr requires the sync or setup action");
152
165
  }
153
166
  return { command, herdrAction: positionals[0] as string, once, json };
154
167
  }
@@ -161,101 +174,12 @@ export function parseCliArgs(argv: string[]): CliArgs {
161
174
  };
162
175
  }
163
176
 
164
- function printRuns(databasePath: string): void {
165
- const store = new WorkflowRunStore(databasePath, { readOnly: true });
166
- try {
167
- const runs = store.listRuns();
168
- if (runs.length === 0) {
169
- process.stdout.write("No workflow runs found.\n");
170
- return;
171
- }
172
- for (const run of runs) {
173
- const state = run.state;
174
- const title = state.runTitle ? ` — ${sanitizeText(state.runTitle)}` : "";
175
- process.stdout.write(
176
- `${statusLabel(state.status)} ${sanitizeText(state.workflowName)}${title} ${state.runId} ${formatDuration(runElapsedMs(state))}\n`,
177
- );
178
- }
179
- } finally {
180
- store.close();
181
- }
182
- }
183
-
184
- function printOnce(databasePath: string, runId: string | undefined): void {
185
- const store = new WorkflowRunStore(databasePath, { readOnly: true });
186
- try {
187
- const size = { width: process.stdout.columns ?? 100, height: 1_000 };
188
- if (runId === undefined) {
189
- process.stdout.write(`${renderRunListLines(store.listRuns(), 0, size).join("\n")}\n`);
190
- return;
191
- }
192
- const run = store.readRun(runId, { includeTrace: true });
193
- if (run === null) throw new Error(`Run not found: ${runId}`);
194
- process.stdout.write(`${renderRunDetailLines(run, size).join("\n")}\n`);
195
- } finally {
196
- store.close();
197
- }
198
- }
199
-
200
- function printControllers(databasePath: string): void {
201
- const store = openControllerStore(databasePath);
202
- if (store === undefined) {
203
- process.stdout.write("No controller resources found.\n");
204
- return;
205
- }
206
- try {
207
- const resources = store.listResources();
208
- if (resources.length === 0) {
209
- process.stdout.write("No controller resources found.\n");
210
- return;
211
- }
212
- for (const resource of resources) {
213
- const condition =
214
- resource.status.conditions.find((item) => item.type === "Ready") ??
215
- resource.status.conditions[0];
216
- const conditionText =
217
- condition === undefined
218
- ? "unknown"
219
- : `${String(condition.status)}:${sanitizeText(condition.reason)}`;
220
- process.stdout.write(
221
- `${sanitizeText(resource.metadata.controller)} ${sanitizeText(resource.metadata.key)} generation=${resource.metadata.generation} ready=${conditionText}\n`,
222
- );
223
- }
224
- } finally {
225
- store.close();
226
- }
227
- }
228
-
229
- function printController(databasePath: string, controller: string, key: string): void {
230
- const store = openControllerStore(databasePath);
231
- if (store === undefined) throw new Error("Controller state database not found");
232
- try {
233
- const resource = store.getResource({ controller, key });
234
- if (resource === undefined)
235
- throw new Error(`Controller resource not found: ${controller}/${key}`);
236
- process.stdout.write(
237
- `${JSON.stringify(
238
- {
239
- resource,
240
- effects: store.listEffects(resource.metadata.uid),
241
- workflows: store.listWorkflows(resource.metadata.uid),
242
- events: store.listEvents({ controller, key, limit: 50 }),
243
- },
244
- null,
245
- 2,
246
- )}\n`,
247
- );
248
- } finally {
249
- store.close();
250
- }
251
- }
252
-
253
177
  export async function main(argv = process.argv.slice(2)): Promise<number> {
254
178
  let args: CliArgs;
255
179
  try {
256
180
  args = parseCliArgs(argv);
257
181
  } catch (error) {
258
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n\n${USAGE}`);
182
+ process.stderr.write(`${errorMessage(error)}\n\n${USAGE}`);
259
183
  return 2;
260
184
  }
261
185
  if (args.command === "help") {
@@ -263,24 +187,7 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
263
187
  return 0;
264
188
  }
265
189
 
266
- const databasePath = workflowStateDatabasePath();
267
190
  try {
268
- if (args.command === "runs") {
269
- printRuns(databasePath);
270
- return 0;
271
- }
272
- if (args.command === "controllers") {
273
- printControllers(databasePath);
274
- return 0;
275
- }
276
- if (args.command === "controller") {
277
- printController(databasePath, args.controllerName as string, args.resourceKey as string);
278
- return 0;
279
- }
280
- if (args.command === "state") {
281
- await runStateCommand(databasePath, args);
282
- return 0;
283
- }
284
191
  if (args.command === "host") {
285
192
  return await runHost(args.hostAction as "start" | "status" | "stop" | "run", args.piArgs);
286
193
  }
@@ -289,65 +196,106 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
289
196
  process.stdout.write(args.json ? `${JSON.stringify(result)}\n` : `${result.message}\n`);
290
197
  return 0;
291
198
  }
292
- if (args.command === "view") {
293
- if (args.once || !process.stdout.isTTY) printOnce(databasePath, args.runId);
294
- else await runViewer({ databasePath, runId: args.runId });
199
+ if (
200
+ args.command === "state" &&
201
+ args.stateAction === "verify" &&
202
+ args.verifyBackup !== undefined
203
+ ) {
204
+ verifyInactiveBackup(args.verifyBackup);
205
+ process.stdout.write(`Inactive SQLite backup is valid: ${path.resolve(args.verifyBackup)}\n`);
295
206
  return 0;
296
207
  }
208
+
209
+ if (
210
+ args.command === "state" &&
211
+ args.stateAction === "prune" &&
212
+ args.backupDestination !== undefined &&
213
+ !path.isAbsolute(args.backupDestination)
214
+ ) {
215
+ throw new Error("state prune backup path must be absolute");
216
+ }
217
+
218
+ const client = new WorkflowClient({ clientId: CLI_CLIENT_ID });
219
+ try {
220
+ await client.ensureRunning();
221
+ if (args.command === "runs") {
222
+ const runs = await firstRunsView(client);
223
+ printJsonLines(runs);
224
+ return 0;
225
+ }
226
+ if (args.command === "controllers" || args.command === "controller") {
227
+ const response = await client.request({
228
+ operation: args.command === "controllers" ? "controller.list" : "controller.get",
229
+ payload: {
230
+ projectPath: process.cwd(),
231
+ ...(args.command === "controller"
232
+ ? { controller: args.controllerName as string, key: args.resourceKey as string }
233
+ : {}),
234
+ },
235
+ });
236
+ requireAccepted(response);
237
+ process.stdout.write(`${JSON.stringify(response.receipt ?? null, null, 2)}\n`);
238
+ return 0;
239
+ }
240
+ if (args.command === "state") {
241
+ await runStateCommand(client, args);
242
+ return 0;
243
+ }
244
+ if (args.command === "view") {
245
+ if (args.once || !process.stdout.isTTY) {
246
+ const view =
247
+ args.runId === undefined
248
+ ? await firstRunsView(client)
249
+ : await firstRunView(client, args.runId);
250
+ process.stdout.write(
251
+ `${renderClientView(view, process.stdout.columns ?? 100, Number.MAX_SAFE_INTEGER, 0, 0).join("\n")}\n`,
252
+ );
253
+ } else {
254
+ await runViewer({ client, runId: args.runId });
255
+ }
256
+ return 0;
257
+ }
258
+ } finally {
259
+ await client.close();
260
+ }
297
261
  process.stderr.write(`Unknown command: ${args.command}\n\n${USAGE}`);
298
262
  return 2;
299
263
  } catch (error) {
300
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
264
+ process.stderr.write(`${errorMessage(error)}\n`);
301
265
  return 1;
302
266
  }
303
267
  }
304
268
 
305
- async function runStateCommand(databasePath: string, args: CliArgs): Promise<void> {
306
- if (args.stateAction === "prune") {
307
- const report = await pruneState(databasePath, {
308
- before: args.pruneBefore as string,
309
- apply: args.pruneApply === true,
310
- ...(args.backupDestination === undefined ? {} : { backupPath: args.backupDestination }),
311
- });
312
- process.stdout.write(`${JSON.stringify(report)}\n`);
313
- return;
314
- }
315
- if (args.stateAction === "backup") {
316
- const state = new StateDatabase({ filePath: databasePath });
317
- try {
318
- await state.backup(path.resolve(args.backupDestination as string));
319
- process.stdout.write(`Backup verified: ${path.resolve(args.backupDestination as string)}\n`);
320
- } finally {
321
- state.close();
322
- }
323
- return;
324
- }
325
- const state = new StateDatabase({ filePath: databasePath, mode: "read-only" });
326
- try {
327
- state.integrityCheck();
328
- if (args.stateAction === "verify") {
329
- process.stdout.write("SQLite state is valid.\n");
330
- return;
331
- }
332
- const counts = state.connection
333
- .prepare(
334
- `SELECT
335
- (SELECT count(*) FROM runs) AS runs,
336
- (SELECT count(*) FROM controller_resources) AS controllers,
337
- (SELECT count(*) FROM human_decisions) AS decisions,
338
- (SELECT count(*) FROM workflow_settings) AS settingsScopes,
339
- (SELECT count(*) FROM workflow_follow_ups WHERE status IN ('queued', 'pending_presentation', 'ready')) AS pendingFollowUps,
340
- (SELECT count(*) FROM effects WHERE status IN ('pending', 'applying', 'ambiguous')) AS unsettledEffects,
341
- (SELECT count(*) FROM leases
342
- WHERE owner_id IS NOT NULL AND expires_at > ?) AS activeLeases`,
343
- )
344
- .get(Date.now());
345
- process.stdout.write(
346
- `Database: ${databasePath}\nSize: ${fs.statSync(databasePath).size} bytes\n${JSON.stringify(counts)}\n`,
347
- );
348
- } finally {
349
- state.close();
350
- }
269
+ async function runStateCommand(client: WorkflowClient, args: CliArgs): Promise<void> {
270
+ const operation =
271
+ args.stateAction === "status"
272
+ ? "state.status"
273
+ : args.stateAction === "verify"
274
+ ? "state.verify"
275
+ : args.stateAction === "backup"
276
+ ? "state.backup"
277
+ : "state.prune";
278
+ const payload: JsonValue =
279
+ args.stateAction === "backup"
280
+ ? { destination: path.resolve(args.backupDestination as string) }
281
+ : args.stateAction === "prune"
282
+ ? {
283
+ before: args.pruneBefore as string,
284
+ apply: args.pruneApply === true,
285
+ ...(args.backupDestination === undefined
286
+ ? {}
287
+ : { backupPath: path.resolve(args.backupDestination) }),
288
+ }
289
+ : {};
290
+ const durable =
291
+ args.stateAction === "backup" || (args.stateAction === "prune" && args.pruneApply === true);
292
+ const idempotencyKey = durable ? `state-${randomUUID()}` : undefined;
293
+ const response =
294
+ idempotencyKey === undefined
295
+ ? await client.request({ operation, payload })
296
+ : await client.requestDurable({ operation, payload, idempotencyKey });
297
+ requireAccepted(response);
298
+ process.stdout.write(`${JSON.stringify(response.receipt ?? {})}\n`);
351
299
  }
352
300
 
353
301
  async function runHost(
@@ -368,22 +316,56 @@ async function runHost(
368
316
  });
369
317
  return 0;
370
318
  }
371
- const client = new WorkflowHostClient();
372
- if (action === "start") {
373
- const response = await client.ensureRunning();
319
+ const client = new WorkflowClient({ clientId: CLI_CLIENT_ID });
320
+ try {
321
+ const response =
322
+ action === "start"
323
+ ? await client.ensureRunning()
324
+ : await client.request({ operation: action === "status" ? "host.status" : "host.stop" });
374
325
  process.stdout.write(`${JSON.stringify(response.receipt ?? {})}\n`);
375
326
  return response.outcome === "accepted" || response.outcome === "adopted" ? 0 : 1;
327
+ } finally {
328
+ await client.close();
376
329
  }
377
- const response = await client.request({
378
- operation: action === "status" ? "host.status" : "host.stop",
330
+ }
331
+
332
+ async function firstRunsView(client: WorkflowClient): Promise<JsonValue> {
333
+ return await firstSubscriptionEvent((listener) => client.watchRuns(listener));
334
+ }
335
+
336
+ export async function firstRunView(client: WorkflowClient, runId: string): Promise<JsonValue> {
337
+ const view = await client.getRun(runId);
338
+ if (view === null) throw new Error(`Workflow run not found: ${runId}`);
339
+ return await materializeRunView(client, view);
340
+ }
341
+
342
+ async function firstSubscriptionEvent(
343
+ subscribe: (listener: (event: { payload: JsonValue }) => void) => Promise<() => Promise<void>>,
344
+ ): Promise<JsonValue> {
345
+ let resolveEvent!: (value: JsonValue) => void;
346
+ const event = new Promise<JsonValue>((resolve) => {
347
+ resolveEvent = resolve;
379
348
  });
380
- process.stdout.write(`${JSON.stringify(response.receipt ?? {})}\n`);
381
- return response.outcome === "accepted" || response.outcome === "adopted" ? 0 : 1;
349
+ const unsubscribe = await subscribe((received) => resolveEvent(received.payload));
350
+ try {
351
+ return await event;
352
+ } finally {
353
+ await unsubscribe();
354
+ }
355
+ }
356
+
357
+ function printJsonLines(value: JsonValue): void {
358
+ if (!Array.isArray(value) || value.length === 0) {
359
+ process.stdout.write("No workflow runs found.\n");
360
+ return;
361
+ }
362
+ for (const item of value) process.stdout.write(`${JSON.stringify(item)}\n`);
382
363
  }
383
364
 
384
- function openControllerStore(databasePath: string): SqliteControllerStore | undefined {
385
- if (!fs.existsSync(databasePath)) return undefined;
386
- return new SqliteControllerStore(databasePath, { readOnly: true });
365
+ function requireAccepted(response: { outcome: string; error?: string }): void {
366
+ if (response.outcome !== "accepted" && response.outcome !== "adopted") {
367
+ throw new Error(response.error ?? `Workflow host returned ${response.outcome}`);
368
+ }
387
369
  }
388
370
 
389
371
  function packageRoot(): string {
@@ -396,6 +378,10 @@ function requiredValue(args: string[], option: string): string {
396
378
  return value;
397
379
  }
398
380
 
381
+ function errorMessage(error: unknown): string {
382
+ return error instanceof Error ? error.message : String(error);
383
+ }
384
+
399
385
  const entryPath = process.argv[1];
400
386
  const resolvedEntry = entryPath === undefined ? undefined : realpathSyncSafe(entryPath);
401
387
  if (resolvedEntry !== undefined && import.meta.url === pathToFileURL(resolvedEntry).href) {