@osolmaz/pi-workflows 0.13.2 → 0.13.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +5 -3
  2. package/dist/builtins/autodoc.workflow.d.ts +5 -5
  3. package/dist/builtins/autoimplement.workflow.d.ts +77 -73
  4. package/dist/builtins/autoimplement.workflow.js +47 -8
  5. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  6. package/dist/builtins/autoplan.workflow.d.ts +8 -8
  7. package/dist/builtins/autoplan.workflow.js +44 -35
  8. package/dist/builtins/autoplan.workflow.js.map +1 -1
  9. package/dist/builtins/catalog.js +2 -2
  10. package/dist/builtins/change-verification.workflow.d.ts +1 -1
  11. package/dist/builtins/plain-summary.workflow.d.ts +1 -1
  12. package/dist/builtins/plan-approval.workflow.d.ts +1 -1
  13. package/dist/builtins/plan-change.workflow.d.ts +29 -29
  14. package/dist/builtins/sanity-check.workflow.d.ts +3 -3
  15. package/dist/builtins/workspace-preparation.workflow.d.ts +1 -1
  16. package/dist/controllers/index.d.ts +2 -2
  17. package/dist/controllers/index.js.map +1 -1
  18. package/dist/controllers/sqlite.js +85 -23
  19. package/dist/controllers/sqlite.js.map +1 -1
  20. package/dist/controllers/types.d.ts +34 -0
  21. package/dist/controllers/workflow-engine-scheduler.d.ts +5 -1
  22. package/dist/controllers/workflow-engine-scheduler.js +83 -1
  23. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  24. package/dist/controllers/workflows.d.ts +8 -1
  25. package/dist/controllers/workflows.js +36 -0
  26. package/dist/controllers/workflows.js.map +1 -1
  27. package/dist/extension/controller-host.d.ts +2 -1
  28. package/dist/extension/controller-host.js +1 -1
  29. package/dist/extension/controller-host.js.map +1 -1
  30. package/dist/extension/follow-up-coordinator.d.ts +27 -0
  31. package/dist/extension/follow-up-coordinator.js +131 -0
  32. package/dist/extension/follow-up-coordinator.js.map +1 -0
  33. package/dist/extension/index.d.ts +9 -0
  34. package/dist/extension/index.js +460 -75
  35. package/dist/extension/index.js.map +1 -1
  36. package/dist/extension/session-events.d.ts +2 -3
  37. package/dist/extension/session-events.js +11 -10
  38. package/dist/extension/session-events.js.map +1 -1
  39. package/dist/extension/widget.js +9 -0
  40. package/dist/extension/widget.js.map +1 -1
  41. package/dist/state/database.d.ts +2 -1
  42. package/dist/state/database.js +11 -13
  43. package/dist/state/database.js.map +1 -1
  44. package/dist/state/json.js +6 -1
  45. package/dist/state/json.js.map +1 -1
  46. package/dist/state/mutation.d.ts +1 -1
  47. package/dist/state/mutation.js.map +1 -1
  48. package/dist/state/prune.d.ts +18 -0
  49. package/dist/state/prune.js +373 -0
  50. package/dist/state/prune.js.map +1 -0
  51. package/dist/state/schema.d.ts +1 -1
  52. package/dist/state/schema.js +116 -18
  53. package/dist/state/schema.js.map +1 -1
  54. package/dist/viewer/cli.d.ts +3 -1
  55. package/dist/viewer/cli.js +55 -4
  56. package/dist/viewer/cli.js.map +1 -1
  57. package/dist/viewer/render.js +19 -1
  58. package/dist/viewer/render.js.map +1 -1
  59. package/dist/workflows/composition.d.ts +2 -0
  60. package/dist/workflows/composition.js +15 -0
  61. package/dist/workflows/composition.js.map +1 -1
  62. package/dist/workflows/definition.d.ts +6 -3
  63. package/dist/workflows/definition.js +3 -0
  64. package/dist/workflows/definition.js.map +1 -1
  65. package/dist/workflows/engine.d.ts +6 -1
  66. package/dist/workflows/engine.js +301 -35
  67. package/dist/workflows/engine.js.map +1 -1
  68. package/dist/workflows/human-decision.d.ts +1 -0
  69. package/dist/workflows/human-decision.js +25 -9
  70. package/dist/workflows/human-decision.js.map +1 -1
  71. package/dist/workflows/index.d.ts +3 -1
  72. package/dist/workflows/index.js +2 -0
  73. package/dist/workflows/index.js.map +1 -1
  74. package/dist/workflows/json-patch.d.ts +47 -0
  75. package/dist/workflows/json-patch.js +298 -0
  76. package/dist/workflows/json-patch.js.map +1 -0
  77. package/dist/workflows/schema.js +29 -1
  78. package/dist/workflows/schema.js.map +1 -1
  79. package/dist/workflows/settings.d.ts +128 -0
  80. package/dist/workflows/settings.js +199 -0
  81. package/dist/workflows/settings.js.map +1 -0
  82. package/dist/workflows/store.d.ts +75 -2
  83. package/dist/workflows/store.js +1349 -89
  84. package/dist/workflows/store.js.map +1 -1
  85. package/dist/workflows/tool-input.d.ts +22 -0
  86. package/dist/workflows/tool-input.js +43 -0
  87. package/dist/workflows/tool-input.js.map +1 -1
  88. package/dist/workflows/types.d.ts +40 -5
  89. package/docs/2026-08-25-workflow-follow-ups.md +132 -0
  90. package/docs/2026-08-25-workflow-settings.md +169 -0
  91. package/docs/DEFERRED_TURNS.md +6 -0
  92. package/docs/DESIGN_PHILOSOPHY.md +2 -0
  93. package/docs/SQLITE_STATE.md +31 -18
  94. package/docs/WORKFLOW_COMPOSITION.md +16 -1
  95. package/docs/WORKFLOW_STEP_MESSAGES.md +4 -2
  96. package/docs/plans/2026-08-25-live-workflow-settings-plan.md +532 -0
  97. package/docs/plans/2026-08-25-workflow-run-storage-plan.md +67 -0
  98. package/docs/tui-viewer.md +7 -6
  99. package/docs/workflows.md +22 -1
  100. package/examples/workflows/live-settings.workflow.ts +93 -0
  101. package/herdr-plugin.toml +1 -1
  102. package/package.json +1 -1
  103. package/skills/autoplan/SKILL.md +6 -6
  104. package/src/builtins/autoimplement.workflow.ts +56 -8
  105. package/src/builtins/autoplan.workflow.ts +45 -37
  106. package/src/builtins/catalog.ts +2 -2
  107. package/src/controllers/index.ts +6 -0
  108. package/src/controllers/sqlite.ts +129 -35
  109. package/src/controllers/types.ts +40 -0
  110. package/src/controllers/workflow-engine-scheduler.ts +103 -1
  111. package/src/controllers/workflows.ts +68 -0
  112. package/src/extension/controller-host.ts +6 -1
  113. package/src/extension/follow-up-coordinator.ts +151 -0
  114. package/src/extension/index.ts +538 -88
  115. package/src/extension/session-events.ts +15 -13
  116. package/src/extension/widget.ts +8 -0
  117. package/src/state/database.ts +12 -12
  118. package/src/state/json.ts +6 -1
  119. package/src/state/mutation.ts +4 -1
  120. package/src/state/prune.ts +502 -0
  121. package/src/state/schema.ts +116 -18
  122. package/src/viewer/cli.ts +52 -5
  123. package/src/viewer/render.ts +43 -1
  124. package/src/workflows/composition.ts +19 -0
  125. package/src/workflows/definition.ts +19 -5
  126. package/src/workflows/engine.ts +363 -35
  127. package/src/workflows/human-decision.ts +41 -11
  128. package/src/workflows/index.ts +43 -0
  129. package/src/workflows/json-patch.ts +375 -0
  130. package/src/workflows/schema.ts +31 -1
  131. package/src/workflows/settings.ts +430 -0
  132. package/src/workflows/store.ts +1977 -184
  133. package/src/workflows/tool-input.ts +58 -0
  134. package/src/workflows/types.ts +43 -4
@@ -37,13 +37,13 @@ export type NormalizedAssistantEvent =
37
37
  | { type: "start" }
38
38
  | { type: "text_start"; contentIndex: number }
39
39
  | { type: "text_delta"; contentIndex: number; delta: string }
40
- | { type: "text_end"; contentIndex: number; content: string }
40
+ | { type: "text_end"; contentIndex: number }
41
41
  | { type: "thinking_start"; contentIndex: number }
42
42
  | { type: "thinking_delta"; contentIndex: number; delta: string }
43
- | { type: "thinking_end"; contentIndex: number; content: string }
43
+ | { type: "thinking_end"; contentIndex: number }
44
44
  | { type: "toolcall_start"; contentIndex: number }
45
45
  | { type: "toolcall_delta"; contentIndex: number; delta: string }
46
- | { type: "toolcall_end"; contentIndex: number; toolCall: unknown }
46
+ | { type: "toolcall_end"; contentIndex: number; toolCallId?: string; toolName?: string }
47
47
  | { type: "done"; reason: "stop" | "length" | "toolUse" | "deferred" }
48
48
  | { type: "error"; reason: "aborted" | "error" };
49
49
 
@@ -64,13 +64,19 @@ export function normalizeAssistantEvent(
64
64
  return { type: event.type, contentIndex: event.contentIndex, delta: event.delta };
65
65
  case "text_end":
66
66
  case "thinking_end":
67
- return { type: event.type, contentIndex: event.contentIndex, content: event.content };
68
- case "toolcall_end":
67
+ return { type: event.type, contentIndex: event.contentIndex };
68
+ case "toolcall_end": {
69
+ const toolCall =
70
+ typeof event.toolCall === "object" && event.toolCall !== null
71
+ ? (event.toolCall as { id?: unknown; name?: unknown })
72
+ : {};
69
73
  return {
70
74
  type: event.type,
71
75
  contentIndex: event.contentIndex,
72
- toolCall: event.toolCall,
76
+ ...(typeof toolCall.id === "string" ? { toolCallId: toolCall.id } : {}),
77
+ ...(typeof toolCall.name === "string" ? { toolName: toolCall.name } : {}),
73
78
  };
79
+ }
74
80
  case "done":
75
81
  return { type: event.type, reason: event.reason };
76
82
  case "error":
@@ -91,19 +97,15 @@ export function messageRole(message: unknown): string {
91
97
  }
92
98
 
93
99
  export function toolCallIdFromAssistantEvent(event: NormalizedAssistantEvent): string | null {
94
- if (event.type !== "toolcall_end" || typeof event.toolCall !== "object" || !event.toolCall) {
95
- return null;
96
- }
97
- const id = (event.toolCall as { id?: unknown }).id;
98
- return typeof id === "string" && id.length > 0 ? id : null;
100
+ return event.type === "toolcall_end" && event.toolCallId !== undefined ? event.toolCallId : null;
99
101
  }
100
102
 
101
103
  export function toolStartedPayload(event: ToolExecutionStartEventLike): Record<string, unknown> {
102
- return { toolName: event.toolName, args: event.args };
104
+ return { toolName: event.toolName };
103
105
  }
104
106
 
105
107
  export function toolFinishedPayload(event: ToolExecutionEndEventLike): Record<string, unknown> {
106
- return { toolName: event.toolName, isError: event.isError, result: event.result };
108
+ return { toolName: event.toolName, isError: event.isError };
107
109
  }
108
110
 
109
111
  export function turnFinishedPayload(
@@ -311,6 +311,14 @@ function nodeRuntimeSegments(
311
311
  if (attempts > 1) {
312
312
  segments.push(`↻${attempts}`);
313
313
  }
314
+ if (state.currentNode === nodeId && state.currentSettingsChangeNumber !== undefined) {
315
+ segments.push(`settings ${state.currentSettingsChangeNumber}`);
316
+ } else {
317
+ const latest = state.steps.findLast((step) => step.nodeId === nodeId);
318
+ if (latest?.settingsChangeNumber !== undefined) {
319
+ segments.push(`settings ${latest.settingsChangeNumber}`);
320
+ }
321
+ }
314
322
 
315
323
  const result = state.results[nodeId];
316
324
  if (state.currentNode === nodeId) {
@@ -89,14 +89,15 @@ export class StateDatabase {
89
89
  });
90
90
 
91
91
  try {
92
- this.configure(existed);
93
- if (this.mode === "read-write") {
94
- this.prepareSchema(!existed);
92
+ this.configureConnection();
93
+ if (this.mode === "read-write" && !existed) {
94
+ this.prepareSchema(true);
95
95
  }
96
96
  this.verifySchema();
97
97
  if (this.mode === "read-only") {
98
98
  this.connection.pragma("query_only = ON");
99
99
  } else {
100
+ this.configureWriter();
100
101
  fs.chmodSync(this.filePath, 0o600);
101
102
  }
102
103
  } catch (error) {
@@ -212,17 +213,16 @@ export class StateDatabase {
212
213
  }
213
214
  }
214
215
 
215
- private configure(existed: boolean): void {
216
+ private configureConnection(): void {
216
217
  this.connection.pragma("foreign_keys = ON");
217
218
  this.connection.pragma(`busy_timeout = ${BUSY_TIMEOUT_MS}`);
218
- if (this.mode === "read-write") {
219
- this.connection.pragma("journal_mode = WAL");
220
- this.connection.pragma("synchronous = FULL");
221
- this.connection.pragma("wal_autocheckpoint = 1000");
222
- this.connection.pragma(`journal_size_limit = ${JOURNAL_SIZE_LIMIT}`);
223
- } else if (existed) {
224
- this.connection.pragma("synchronous = FULL");
225
- }
219
+ this.connection.pragma("synchronous = FULL");
220
+ }
221
+
222
+ private configureWriter(): void {
223
+ this.connection.pragma("journal_mode = WAL");
224
+ this.connection.pragma("wal_autocheckpoint = 1000");
225
+ this.connection.pragma(`journal_size_limit = ${JOURNAL_SIZE_LIMIT}`);
226
226
  }
227
227
 
228
228
  private prepareSchema(allowInitialize: boolean): void {
package/src/state/json.ts CHANGED
@@ -34,7 +34,12 @@ function normalizeJson(value: unknown): JsonValue {
34
34
  if (item === undefined) {
35
35
  continue;
36
36
  }
37
- output[key] = normalizeJson(item);
37
+ Object.defineProperty(output, key, {
38
+ value: normalizeJson(item),
39
+ enumerable: true,
40
+ configurable: true,
41
+ writable: true,
42
+ });
38
43
  }
39
44
  return output;
40
45
  }
@@ -10,7 +10,10 @@ export type ResourceType =
10
10
  | "effect"
11
11
  | "channel"
12
12
  | "notification"
13
- | "turn_intent";
13
+ | "turn_intent"
14
+ | "settings"
15
+ | "follow_up_queue"
16
+ | "follow_up";
14
17
 
15
18
  export type ActorType =
16
19
  | "session"
@@ -0,0 +1,502 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type Database from "better-sqlite3";
4
+ import { StateDatabase } from "./database.js";
5
+
6
+ const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "timed_out", "cancelled"]);
7
+ const LIVE_QUEUE_STATUSES = ["queued", "starting", "running", "parked"];
8
+ const UNSETTLED_EFFECT_STATUSES = ["pending", "applying", "ambiguous"];
9
+
10
+ type RunAgeRow = {
11
+ runId: string;
12
+ parentRunId: string | null;
13
+ status: string;
14
+ finishedAt: number | null;
15
+ };
16
+
17
+ export type StatePruneOptions = {
18
+ before: string;
19
+ apply: boolean;
20
+ backupPath?: string;
21
+ };
22
+
23
+ export type StatePruneReport = {
24
+ cutoff: string;
25
+ candidateTrees: number;
26
+ blockedTrees: number;
27
+ selectedRuns: number;
28
+ deletedRows: number;
29
+ deletedBlobs: number;
30
+ deletedBlobBytes: number;
31
+ databaseBytesBefore: number;
32
+ databaseBytesAfter: number;
33
+ applied: boolean;
34
+ };
35
+
36
+ export async function pruneState(
37
+ databasePath: string,
38
+ options: StatePruneOptions,
39
+ ): Promise<StatePruneReport> {
40
+ const cutoff = parseCutoff(options.before);
41
+ if (options.apply && options.backupPath === undefined) {
42
+ throw new Error("state prune --apply requires --backup <absolute-path>");
43
+ }
44
+ if (options.backupPath !== undefined && !path.isAbsolute(options.backupPath)) {
45
+ throw new Error("state prune backup path must be absolute");
46
+ }
47
+ const lockPath = `${databasePath}.maintenance.lock`;
48
+ const lock = options.apply ? acquireMaintenanceLock(lockPath) : undefined;
49
+ let state: StateDatabase | undefined;
50
+ try {
51
+ state = new StateDatabase({
52
+ filePath: databasePath,
53
+ mode: options.apply ? "read-write" : "read-only",
54
+ });
55
+ const selection = selectRunTrees(state.connection, cutoff);
56
+ const sizeBefore = databaseBytes(databasePath);
57
+ const base = {
58
+ cutoff: new Date(cutoff).toISOString(),
59
+ candidateTrees: selection.candidateTrees,
60
+ blockedTrees: selection.blockedTrees,
61
+ selectedRuns: selection.runIds.length,
62
+ databaseBytesBefore: sizeBefore,
63
+ };
64
+ if (!options.apply) {
65
+ return {
66
+ ...base,
67
+ deletedRows: 0,
68
+ deletedBlobs: 0,
69
+ deletedBlobBytes: 0,
70
+ databaseBytesAfter: sizeBefore,
71
+ applied: false,
72
+ };
73
+ }
74
+
75
+ const backupPath = path.resolve(options.backupPath as string);
76
+ if (fs.existsSync(backupPath)) {
77
+ throw new Error(`Backup destination already exists: ${backupPath}`);
78
+ }
79
+ await state.backup(backupPath);
80
+
81
+ let deletedRows = 0;
82
+ let deletedBlobs = 0;
83
+ let deletedBlobBytes = 0;
84
+ state.connection.exec("BEGIN EXCLUSIVE");
85
+ try {
86
+ const checked = selectRunTrees(state.connection, cutoff);
87
+ if (
88
+ checked.runIds.join("\0") !== selection.runIds.join("\0") ||
89
+ checked.signature !== selection.signature
90
+ ) {
91
+ throw new Error("Prune selection changed after backup; run the command again");
92
+ }
93
+ const beforeChanges = totalChanges(state.connection);
94
+ if (checked.runIds.length !== 0) {
95
+ deleteRunAggregates(state.connection, checked.runIds);
96
+ }
97
+ state.connection
98
+ .prepare(
99
+ `DELETE FROM workflow_definitions
100
+ WHERE NOT EXISTS (
101
+ SELECT 1 FROM runs WHERE runs.definition_digest = workflow_definitions.definition_digest
102
+ )`,
103
+ )
104
+ .run();
105
+ state.connection
106
+ .prepare(
107
+ `DELETE FROM projects
108
+ WHERE NOT EXISTS (SELECT 1 FROM runs WHERE runs.project_id = projects.project_id)
109
+ AND NOT EXISTS (
110
+ SELECT 1 FROM controller_resources
111
+ WHERE controller_resources.project_id = projects.project_id
112
+ )`,
113
+ )
114
+ .run();
115
+ const blobStats = unreferencedBlobStats(state.connection);
116
+ deleteUnreferencedBlobs(state.connection);
117
+ deletedRows = totalChanges(state.connection) - beforeChanges;
118
+ deletedBlobs = blobStats.count;
119
+ deletedBlobBytes = blobStats.bytes;
120
+ state.connection.exec("COMMIT");
121
+ } catch (error) {
122
+ if (state.connection.inTransaction) state.connection.exec("ROLLBACK");
123
+ throw error;
124
+ }
125
+ state.connection.pragma("wal_checkpoint(TRUNCATE)");
126
+ state.connection.exec("VACUUM");
127
+ state.connection.pragma("wal_checkpoint(TRUNCATE)");
128
+ state.integrityCheck();
129
+ return {
130
+ ...base,
131
+ deletedRows,
132
+ deletedBlobs,
133
+ deletedBlobBytes,
134
+ databaseBytesAfter: databaseBytes(databasePath),
135
+ applied: true,
136
+ };
137
+ } finally {
138
+ state?.close();
139
+ if (lock !== undefined) releaseMaintenanceLock(lockPath, lock);
140
+ }
141
+ }
142
+
143
+ function selectRunTrees(
144
+ database: Database.Database,
145
+ cutoff: number,
146
+ ): { candidateTrees: number; blockedTrees: number; runIds: string[]; signature: string } {
147
+ const rows = database
148
+ .prepare(
149
+ `SELECT run_id AS runId, parent_run_id AS parentRunId, status, finished_at AS finishedAt
150
+ FROM runs ORDER BY created_at, run_id`,
151
+ )
152
+ .all()
153
+ .filter(isRunAgeRow);
154
+ const eligible = new Set(
155
+ rows
156
+ .filter(
157
+ (row) =>
158
+ TERMINAL_RUN_STATUSES.has(row.status) &&
159
+ row.finishedAt !== null &&
160
+ row.finishedAt < cutoff,
161
+ )
162
+ .map((row) => row.runId),
163
+ );
164
+ const children = new Map<string, string[]>();
165
+ for (const row of rows) {
166
+ if (row.parentRunId === null) continue;
167
+ const values = children.get(row.parentRunId) ?? [];
168
+ values.push(row.runId);
169
+ children.set(row.parentRunId, values);
170
+ }
171
+ const roots = rows.filter(
172
+ (row) =>
173
+ eligible.has(row.runId) && (row.parentRunId === null || !eligible.has(row.parentRunId)),
174
+ );
175
+ const selected = new Set<string>();
176
+ let blockedTrees = 0;
177
+ for (const root of roots) {
178
+ const tree = descendants(root.runId, children);
179
+ if (tree.some((runId) => !eligible.has(runId)) || treeHasBlocker(database, tree)) {
180
+ blockedTrees += 1;
181
+ continue;
182
+ }
183
+ for (const runId of tree) selected.add(runId);
184
+ }
185
+ const runIds = [...selected].sort();
186
+ return {
187
+ candidateTrees: roots.length,
188
+ blockedTrees,
189
+ runIds,
190
+ signature: selectionSignature(database, runIds),
191
+ };
192
+ }
193
+
194
+ function treeHasBlocker(database: Database.Database, runIds: string[]): boolean {
195
+ const values = placeholders(runIds);
196
+ if (
197
+ hasRow(
198
+ database,
199
+ `SELECT 1 FROM run_queue WHERE run_id IN (${values}) AND status IN (${placeholders(LIVE_QUEUE_STATUSES)})`,
200
+ [...runIds, ...LIVE_QUEUE_STATUSES],
201
+ ) ||
202
+ hasRow(
203
+ database,
204
+ `SELECT 1 FROM workflow_follow_ups
205
+ WHERE run_id IN (${values}) AND status IN ('queued', 'pending_presentation', 'ready')`,
206
+ runIds,
207
+ ) ||
208
+ hasRow(
209
+ database,
210
+ `SELECT 1 FROM workflow_follow_up_queues
211
+ WHERE run_id IN (${values}) AND presentation_state = 'pending'`,
212
+ runIds,
213
+ ) ||
214
+ hasRow(
215
+ database,
216
+ `SELECT 1 FROM controller_workflows
217
+ WHERE run_id IN (${values}) OR reserved_run_id IN (${values})`,
218
+ [...runIds, ...runIds],
219
+ ) ||
220
+ hasRow(
221
+ database,
222
+ `SELECT 1 FROM run_steps s JOIN node_attempts a ON a.attempt_id = s.attempt_id
223
+ WHERE a.run_id IN (${values}) AND s.run_id NOT IN (${values})`,
224
+ [...runIds, ...runIds],
225
+ ) ||
226
+ hasRow(
227
+ database,
228
+ `SELECT 1 FROM continuations
229
+ WHERE (parent_run_id IN (${values}) AND continuation_run_id NOT IN (${values}))
230
+ OR (continuation_run_id IN (${values}) AND parent_run_id NOT IN (${values}))`,
231
+ [...runIds, ...runIds, ...runIds, ...runIds],
232
+ ) ||
233
+ hasRow(
234
+ database,
235
+ `SELECT 1 FROM workflow_settings
236
+ WHERE (origin_run_id IN (${values}) AND active_run_id NOT IN (${values}))
237
+ OR (active_run_id IN (${values}) AND origin_run_id NOT IN (${values}))`,
238
+ [...runIds, ...runIds, ...runIds, ...runIds],
239
+ )
240
+ ) {
241
+ return true;
242
+ }
243
+ const resources = relatedResourceIds(database, runIds);
244
+ const resourceValues = placeholders(resources);
245
+ if (
246
+ hasRow(
247
+ database,
248
+ `SELECT 1 FROM leases
249
+ WHERE resource_id IN (${resourceValues}) AND owner_id IS NOT NULL AND expires_at > ?`,
250
+ [...resources, Date.now()],
251
+ ) ||
252
+ hasRow(
253
+ database,
254
+ `SELECT 1 FROM effects
255
+ WHERE source_resource_id IN (${resourceValues})
256
+ AND status IN (${placeholders(UNSETTLED_EFFECT_STATUSES)})`,
257
+ [...resources, ...UNSETTLED_EFFECT_STATUSES],
258
+ ) ||
259
+ hasRow(
260
+ database,
261
+ `SELECT 1 FROM channel_messages m JOIN human_decisions d ON d.decision_id = m.decision_id
262
+ WHERE d.run_id IN (${values})`,
263
+ runIds,
264
+ )
265
+ ) {
266
+ return true;
267
+ }
268
+ return false;
269
+ }
270
+
271
+ function deleteRunAggregates(database: Database.Database, runIds: string[]): void {
272
+ const values = placeholders(runIds);
273
+ const resources = relatedResourceIds(database, runIds);
274
+ const effectRows = database
275
+ .prepare(
276
+ `SELECT effect_id AS effectId, resource_id AS resourceId
277
+ FROM effects WHERE source_resource_id IN (${placeholders(resources)})`,
278
+ )
279
+ .all(...resources)
280
+ .filter(isEffectResourceRow);
281
+ const effectIds = effectRows.map((row) => row.effectId);
282
+ if (effectIds.length !== 0) {
283
+ const effectValues = placeholders(effectIds);
284
+ database
285
+ .prepare(`DELETE FROM notifications WHERE effect_id IN (${effectValues})`)
286
+ .run(...effectIds);
287
+ database
288
+ .prepare(`DELETE FROM turn_intents WHERE effect_id IN (${effectValues})`)
289
+ .run(...effectIds);
290
+ database.prepare(`DELETE FROM effects WHERE effect_id IN (${effectValues})`).run(...effectIds);
291
+ }
292
+ database.prepare(`DELETE FROM turn_intents WHERE run_id IN (${values})`).run(...runIds);
293
+ database
294
+ .prepare(
295
+ `DELETE FROM continuations
296
+ WHERE parent_run_id IN (${values}) OR continuation_run_id IN (${values})`,
297
+ )
298
+ .run(...runIds, ...runIds);
299
+ const allResources = [...new Set([...resources, ...effectRows.map((row) => row.resourceId)])];
300
+ if (allResources.length !== 0) {
301
+ database
302
+ .prepare(`DELETE FROM events WHERE resource_id IN (${placeholders(allResources)})`)
303
+ .run(...allResources);
304
+ }
305
+ database.prepare(`DELETE FROM runs WHERE run_id IN (${values})`).run(...runIds);
306
+ if (allResources.length !== 0) {
307
+ database
308
+ .prepare(`DELETE FROM resources WHERE resource_id IN (${placeholders(allResources)})`)
309
+ .run(...allResources);
310
+ }
311
+ }
312
+
313
+ function relatedResourceIds(database: Database.Database, runIds: string[]): string[] {
314
+ if (runIds.length === 0) return [];
315
+ const values = placeholders(runIds);
316
+ const rows = database
317
+ .prepare(
318
+ `SELECT resource_id AS resourceId FROM runs WHERE run_id IN (${values})
319
+ UNION SELECT resource_id FROM session_segments WHERE run_id IN (${values})
320
+ UNION SELECT resource_id FROM human_decisions WHERE run_id IN (${values})
321
+ UNION SELECT resource_id FROM turn_intents WHERE run_id IN (${values})
322
+ UNION SELECT resource_id FROM workflow_settings
323
+ WHERE origin_run_id IN (${values}) OR active_run_id IN (${values})
324
+ UNION SELECT resource_id FROM workflow_follow_up_queues WHERE run_id IN (${values})
325
+ UNION SELECT resource_id FROM workflow_follow_ups WHERE run_id IN (${values})`,
326
+ )
327
+ .all(...runIds, ...runIds, ...runIds, ...runIds, ...runIds, ...runIds, ...runIds, ...runIds)
328
+ .filter(isResourceRow);
329
+ const resources = rows.map((row) => row.resourceId);
330
+ if (resources.length === 0) return [];
331
+ const effectResources = database
332
+ .prepare(
333
+ `SELECT resource_id AS resourceId FROM effects
334
+ WHERE source_resource_id IN (${placeholders(resources)})`,
335
+ )
336
+ .all(...resources)
337
+ .filter(isResourceRow)
338
+ .map((row) => row.resourceId);
339
+ const notificationResources = database
340
+ .prepare(
341
+ `SELECT resource.resource_id AS resourceId
342
+ FROM notifications notification
343
+ JOIN effects effect ON effect.effect_id = notification.effect_id
344
+ JOIN resources resource
345
+ ON resource.resource_type = 'notification'
346
+ AND resource.aggregate_key = notification.notification_id
347
+ WHERE effect.source_resource_id IN (${placeholders(resources)})`,
348
+ )
349
+ .all(...resources)
350
+ .filter(isResourceRow)
351
+ .map((row) => row.resourceId);
352
+ return [...new Set([...resources, ...effectResources, ...notificationResources])];
353
+ }
354
+
355
+ function selectionSignature(database: Database.Database, runIds: string[]): string {
356
+ if (runIds.length === 0) return "";
357
+ const resources = relatedResourceIds(database, runIds);
358
+ const resourceRows = database
359
+ .prepare(
360
+ `SELECT resource_id AS resourceId, revision, updated_at AS updatedAt
361
+ FROM resources WHERE resource_id IN (${placeholders(resources)})
362
+ ORDER BY resource_id`,
363
+ )
364
+ .all(...resources);
365
+ const queueRows = database
366
+ .prepare(
367
+ `SELECT run_id AS runId, status, updated_at AS updatedAt
368
+ FROM run_queue WHERE run_id IN (${placeholders(runIds)}) ORDER BY run_id`,
369
+ )
370
+ .all(...runIds);
371
+ return JSON.stringify([resourceRows, queueRows]);
372
+ }
373
+
374
+ function unreferencedBlobStats(database: Database.Database): { count: number; bytes: number } {
375
+ const predicate = blobReferencePredicate(database);
376
+ const row = database
377
+ .prepare(
378
+ `SELECT count(*) AS count, COALESCE(sum(byte_length), 0) AS bytes FROM blobs WHERE ${predicate}`,
379
+ )
380
+ .get();
381
+ return isBlobStatsRow(row) ? row : { count: 0, bytes: 0 };
382
+ }
383
+
384
+ function deleteUnreferencedBlobs(database: Database.Database): void {
385
+ database.prepare(`DELETE FROM blobs WHERE ${blobReferencePredicate(database)}`).run();
386
+ }
387
+
388
+ function blobReferencePredicate(database: Database.Database): string {
389
+ const tables = database
390
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")
391
+ .all()
392
+ .filter(isTableRow);
393
+ const references: string[] = [];
394
+ for (const table of tables) {
395
+ const foreignKeys = database.pragma(`foreign_key_list(${quoteIdentifier(table.name)})`);
396
+ if (!Array.isArray(foreignKeys)) continue;
397
+ for (const value of foreignKeys) {
398
+ if (!isForeignKeyRow(value) || value.table !== "blobs" || value.to !== "blob_hash") continue;
399
+ references.push(
400
+ `SELECT ${quoteIdentifier(value.from)} FROM ${quoteIdentifier(table.name)} ` +
401
+ `WHERE ${quoteIdentifier(value.from)} IS NOT NULL`,
402
+ );
403
+ }
404
+ }
405
+ return references.length === 0 ? "1 = 1" : `blob_hash NOT IN (${references.join(" UNION ")})`;
406
+ }
407
+
408
+ function descendants(root: string, children: Map<string, string[]>): string[] {
409
+ const result: string[] = [];
410
+ const pending = [root];
411
+ while (pending.length !== 0) {
412
+ const runId = pending.pop() as string;
413
+ result.push(runId);
414
+ pending.push(...(children.get(runId) ?? []));
415
+ }
416
+ return result;
417
+ }
418
+
419
+ function parseCutoff(value: string): number {
420
+ const cutoff = Date.parse(value);
421
+ if (!Number.isFinite(cutoff)) throw new Error("state prune --before requires a valid timestamp");
422
+ return cutoff;
423
+ }
424
+
425
+ function databaseBytes(databasePath: string): number {
426
+ return [databasePath, `${databasePath}-wal`]
427
+ .filter((filePath) => fs.existsSync(filePath))
428
+ .reduce((total, filePath) => total + fs.statSync(filePath).size, 0);
429
+ }
430
+
431
+ function acquireMaintenanceLock(lockPath: string): number {
432
+ try {
433
+ return fs.openSync(lockPath, "wx", 0o600);
434
+ } catch (error) {
435
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
436
+ throw new Error(`State maintenance is already active: ${lockPath}`);
437
+ }
438
+ throw error;
439
+ }
440
+ }
441
+
442
+ function releaseMaintenanceLock(lockPath: string, descriptor: number): void {
443
+ fs.closeSync(descriptor);
444
+ fs.unlinkSync(lockPath);
445
+ }
446
+
447
+ function hasRow(database: Database.Database, sql: string, values: unknown[]): boolean {
448
+ return database.prepare(sql).get(...values) !== undefined;
449
+ }
450
+
451
+ function totalChanges(database: Database.Database): number {
452
+ const row = database.prepare("SELECT total_changes() AS count").get();
453
+ return isCountRow(row) ? row.count : 0;
454
+ }
455
+
456
+ function placeholders(values: readonly unknown[]): string {
457
+ if (values.length === 0) return "NULL";
458
+ return values.map(() => "?").join(", ");
459
+ }
460
+
461
+ function quoteIdentifier(value: string): string {
462
+ return `"${value.replaceAll('"', '""')}"`;
463
+ }
464
+
465
+ function isRecord(value: unknown): value is Record<string, unknown> {
466
+ return typeof value === "object" && value !== null && !Array.isArray(value);
467
+ }
468
+
469
+ function isRunAgeRow(value: unknown): value is RunAgeRow {
470
+ return isRecord(value);
471
+ }
472
+
473
+ function isResourceRow(value: unknown): value is { resourceId: string } {
474
+ return isRecord(value) && typeof value.resourceId === "string";
475
+ }
476
+
477
+ function isEffectResourceRow(value: unknown): value is { effectId: string; resourceId: string } {
478
+ return (
479
+ isRecord(value) && typeof value.effectId === "string" && typeof value.resourceId === "string"
480
+ );
481
+ }
482
+
483
+ function isBlobStatsRow(value: unknown): value is { count: number; bytes: number } {
484
+ return isRecord(value) && typeof value.count === "number" && typeof value.bytes === "number";
485
+ }
486
+
487
+ function isCountRow(value: unknown): value is { count: number } {
488
+ return isRecord(value) && typeof value.count === "number";
489
+ }
490
+
491
+ function isTableRow(value: unknown): value is { name: string } {
492
+ return isRecord(value) && typeof value.name === "string";
493
+ }
494
+
495
+ function isForeignKeyRow(value: unknown): value is { table: string; from: string; to: string } {
496
+ return (
497
+ isRecord(value) &&
498
+ typeof value.table === "string" &&
499
+ typeof value.from === "string" &&
500
+ typeof value.to === "string"
501
+ );
502
+ }