@osolmaz/pi-workflows 0.16.4 → 0.16.6

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 (173) hide show
  1. package/README.md +5 -5
  2. package/dist/builtins/autoimplement.workflow.d.ts +5 -1
  3. package/dist/builtins/autoimplement.workflow.js +29 -10
  4. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  5. package/dist/client/client.js +3 -1
  6. package/dist/client/client.js.map +1 -1
  7. package/dist/client/protocol.d.ts +1 -1
  8. package/dist/client/protocol.js +18 -1
  9. package/dist/client/protocol.js.map +1 -1
  10. package/dist/client/view.d.ts +7 -5
  11. package/dist/extension/index.d.ts +2 -1
  12. package/dist/extension/index.js +115 -165
  13. package/dist/extension/index.js.map +1 -1
  14. package/dist/extension/recorder.d.ts +1 -1
  15. package/dist/extension/recorder.js +2 -2
  16. package/dist/extension/recorder.js.map +1 -1
  17. package/dist/extension/step-message.d.ts +1 -1
  18. package/dist/extension/step-message.js +4 -4
  19. package/dist/extension/step-message.js.map +1 -1
  20. package/dist/extension/workflow-message-coordinator.d.ts +12 -1
  21. package/dist/extension/workflow-message-coordinator.js +65 -54
  22. package/dist/extension/workflow-message-coordinator.js.map +1 -1
  23. package/dist/resource-managers/index.d.ts +1 -2
  24. package/dist/resource-managers/index.js +1 -2
  25. package/dist/resource-managers/index.js.map +1 -1
  26. package/dist/resource-managers/sqlite.d.ts +2 -295
  27. package/dist/resource-managers/sqlite.js +71 -1528
  28. package/dist/resource-managers/sqlite.js.map +1 -1
  29. package/dist/resource-managers/types.d.ts +2 -7
  30. package/dist/server/resource-runner-entry.js +2 -6
  31. package/dist/server/resource-runner-entry.js.map +1 -1
  32. package/dist/server/rpc-bridge.js +1 -1
  33. package/dist/server/rpc-bridge.js.map +1 -1
  34. package/dist/server/rpc-executor.js +2 -4
  35. package/dist/server/rpc-executor.js.map +1 -1
  36. package/dist/server/server-entry.js +3 -0
  37. package/dist/server/server-entry.js.map +1 -1
  38. package/dist/server/server.d.ts +22 -8
  39. package/dist/server/server.js +498 -543
  40. package/dist/server/server.js.map +1 -1
  41. package/dist/server/state.d.ts +16 -29
  42. package/dist/server/state.js +105 -319
  43. package/dist/server/state.js.map +1 -1
  44. package/dist/server/view.d.ts +4 -5
  45. package/dist/server/view.js +51 -51
  46. package/dist/server/view.js.map +1 -1
  47. package/dist/server/workflow-runner-entry.d.ts +1 -1
  48. package/dist/server/workflow-runner-entry.js +1 -49
  49. package/dist/server/workflow-runner-entry.js.map +1 -1
  50. package/dist/server/workflow-runner-protocol.d.ts +2 -7
  51. package/dist/server/workflow-runner-protocol.js +5 -10
  52. package/dist/server/workflow-runner-protocol.js.map +1 -1
  53. package/dist/server/workflow-runner-store.d.ts +5 -6
  54. package/dist/server/workflow-runner-store.js +7 -14
  55. package/dist/server/workflow-runner-store.js.map +1 -1
  56. package/dist/state/attempt-time.d.ts +21 -0
  57. package/dist/state/attempt-time.js +95 -0
  58. package/dist/state/attempt-time.js.map +1 -0
  59. package/dist/state/json.d.ts +2 -1
  60. package/dist/state/json.js.map +1 -1
  61. package/dist/state/project-store.d.ts +39 -0
  62. package/dist/state/project-store.js +154 -0
  63. package/dist/state/project-store.js.map +1 -0
  64. package/dist/state/prune.d.ts +28 -0
  65. package/dist/state/prune.js +278 -83
  66. package/dist/state/prune.js.map +1 -1
  67. package/dist/state/schema.js +23 -23
  68. package/dist/state/workflow-messages.d.ts +1 -1
  69. package/dist/state/workflow-messages.js +3 -1
  70. package/dist/state/workflow-messages.js.map +1 -1
  71. package/dist/viewer/backup.js +5 -0
  72. package/dist/viewer/backup.js.map +1 -1
  73. package/dist/workflows/diagnostics.d.ts +8 -0
  74. package/dist/workflows/diagnostics.js +61 -0
  75. package/dist/workflows/diagnostics.js.map +1 -0
  76. package/dist/workflows/engine.d.ts +2 -15
  77. package/dist/workflows/engine.js +125 -245
  78. package/dist/workflows/engine.js.map +1 -1
  79. package/dist/workflows/human-decision.d.ts +3 -7
  80. package/dist/workflows/human-decision.js +2 -56
  81. package/dist/workflows/human-decision.js.map +1 -1
  82. package/dist/workflows/index.d.ts +2 -1
  83. package/dist/workflows/index.js +1 -0
  84. package/dist/workflows/index.js.map +1 -1
  85. package/dist/workflows/queue.d.ts +267 -0
  86. package/dist/workflows/queue.js +1248 -0
  87. package/dist/workflows/queue.js.map +1 -0
  88. package/dist/workflows/requests.d.ts +47 -0
  89. package/dist/workflows/requests.js +158 -0
  90. package/dist/workflows/requests.js.map +1 -0
  91. package/dist/workflows/schema.js +2 -4
  92. package/dist/workflows/schema.js.map +1 -1
  93. package/dist/workflows/store.d.ts +11 -16
  94. package/dist/workflows/store.js +174 -169
  95. package/dist/workflows/store.js.map +1 -1
  96. package/dist/workflows/tool-input.d.ts +6 -9
  97. package/dist/workflows/tool-input.js +15 -12
  98. package/dist/workflows/tool-input.js.map +1 -1
  99. package/dist/workflows/transitions.d.ts +38 -0
  100. package/dist/workflows/transitions.js +276 -0
  101. package/dist/workflows/transitions.js.map +1 -0
  102. package/dist/workflows/types.d.ts +9 -42
  103. package/dist/workflows/workflow-message-content.d.ts +7 -1
  104. package/dist/workflows/workflow-message-content.js +22 -4
  105. package/dist/workflows/workflow-message-content.js.map +1 -1
  106. package/docs/2026-08-25-workflow-follow-ups.md +5 -5
  107. package/docs/2026-09-02-unify-workflow-messages-plan.md +2 -0
  108. package/docs/2026-09-04-workflow-run-state-plan.md +2 -0
  109. package/docs/2026-09-06-durable-execution-plan.md +176 -0
  110. package/docs/DEFERRED_TURNS.md +84 -105
  111. package/docs/HUMAN_DECISIONS.md +17 -19
  112. package/docs/HUMAN_DECISION_PRESENTATIONS.md +1 -1
  113. package/docs/SQLITE_STATE.md +87 -30
  114. package/docs/WORKFLOW_SERVER.md +50 -11
  115. package/docs/WORKFLOW_STEP_MESSAGES.md +72 -54
  116. package/docs/plans/2026-08-04-controller-runtime-plan.md +2 -0
  117. package/docs/plans/2026-08-21-deferred-turn-intents-plan.md +2 -0
  118. package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +2 -0
  119. package/docs/plans/2026-09-04-automatic-state-retention-plan.md +283 -0
  120. package/docs/workflows.md +109 -44
  121. package/examples/workflows/autoresearch.workflow.ts +0 -2
  122. package/examples/workflows/branch.workflow.ts +0 -4
  123. package/examples/workflows/echo.workflow.ts +0 -2
  124. package/examples/workflows/live-settings.workflow.ts +0 -2
  125. package/examples/workflows/two-turn.workflow.ts +0 -2
  126. package/herdr-plugin.toml +1 -1
  127. package/package.json +1 -1
  128. package/protocol/fixtures/client-v1.json +1 -1
  129. package/skills/pi-workflows/SKILL.md +8 -7
  130. package/src/builtins/autoimplement.workflow.ts +31 -10
  131. package/src/client/client.ts +3 -1
  132. package/src/client/protocol.ts +21 -1
  133. package/src/client/view.ts +9 -5
  134. package/src/extension/index.ts +117 -189
  135. package/src/extension/recorder.ts +2 -2
  136. package/src/extension/step-message.ts +4 -4
  137. package/src/extension/workflow-message-coordinator.ts +78 -50
  138. package/src/resource-managers/index.ts +1 -9
  139. package/src/resource-managers/sqlite.ts +330 -2655
  140. package/src/resource-managers/types.ts +2 -3
  141. package/src/server/resource-runner-entry.ts +2 -7
  142. package/src/server/rpc-bridge.ts +1 -1
  143. package/src/server/rpc-executor.ts +4 -9
  144. package/src/server/server-entry.ts +3 -0
  145. package/src/server/server.ts +568 -644
  146. package/src/server/state.ts +122 -466
  147. package/src/server/view.ts +56 -60
  148. package/src/server/workflow-runner-entry.ts +2 -55
  149. package/src/server/workflow-runner-protocol.ts +5 -15
  150. package/src/server/workflow-runner-store.ts +12 -27
  151. package/src/state/attempt-time.ts +122 -0
  152. package/src/state/json.ts +2 -1
  153. package/src/state/project-store.ts +218 -0
  154. package/src/state/prune.ts +408 -95
  155. package/src/state/schema.ts +23 -23
  156. package/src/state/workflow-messages.ts +4 -2
  157. package/src/viewer/backup.ts +7 -0
  158. package/src/workflows/diagnostics.ts +78 -0
  159. package/src/workflows/engine.ts +133 -291
  160. package/src/workflows/human-decision.ts +3 -81
  161. package/src/workflows/index.ts +8 -2
  162. package/src/workflows/queue.ts +2091 -0
  163. package/src/workflows/requests.ts +283 -0
  164. package/src/workflows/schema.ts +2 -6
  165. package/src/workflows/store.ts +223 -231
  166. package/src/workflows/tool-input.ts +20 -12
  167. package/src/workflows/transitions.ts +330 -0
  168. package/src/workflows/types.ts +9 -46
  169. package/src/workflows/workflow-message-content.ts +28 -4
  170. package/dist/resource-managers/runtime.d.ts +0 -59
  171. package/dist/resource-managers/runtime.js +0 -423
  172. package/dist/resource-managers/runtime.js.map +0 -1
  173. package/src/resource-managers/runtime.ts +0 -576
@@ -5,16 +5,38 @@ import { StateDatabase } from "./database.js";
5
5
 
6
6
  const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "timed_out", "cancelled"]);
7
7
  const LIVE_QUEUE_STATUSES = ["queued", "starting", "running", "parked"];
8
+ const ACTIVE_RUNNER_STATUSES = ["starting", "ready", "running"];
9
+ const ACTIVE_ATTEMPT_STATUSES = ["pending", "running", "waiting", "interrupted"];
8
10
  const UNSETTLED_EFFECT_STATUSES = ["pending", "applying", "ambiguous"];
9
11
 
12
+ export const AUTOMATIC_STATE_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
13
+ export const AUTOMATIC_STATE_PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1_000;
14
+ export const AUTOMATIC_STATE_VACUUM_MIN_BYTES = 64 * 1024 * 1024;
15
+ export const AUTOMATIC_STATE_VACUUM_MIN_FREE_RATIO = 0.2;
16
+
10
17
  type RunAgeRow = {
11
18
  runId: string;
12
19
  parentRunId: string | null;
20
+ rootRunId: string;
13
21
  launchOptionsHash: Buffer;
14
22
  status: string;
15
23
  finishedAt: number | null;
16
24
  };
17
25
 
26
+ type SelectedRunTree = {
27
+ rootRunId: string;
28
+ runIds: string[];
29
+ signature: string;
30
+ };
31
+
32
+ type RunTreeSelection = {
33
+ candidateTrees: number;
34
+ blockedTrees: number;
35
+ trees: SelectedRunTree[];
36
+ runIds: string[];
37
+ signature: string;
38
+ };
39
+
18
40
  export type StatePruneOptions = {
19
41
  before: string;
20
42
  apply: boolean;
@@ -34,6 +56,38 @@ export type StatePruneReport = {
34
56
  applied: boolean;
35
57
  };
36
58
 
59
+ export type AutomaticStatePruneOptions = {
60
+ now?: number;
61
+ activeBlobHashes?: () => readonly Buffer[];
62
+ shouldContinue?: () => boolean;
63
+ yieldControl?: () => Promise<void>;
64
+ vacuumMinBytes?: number;
65
+ vacuumMinFreeRatio?: number;
66
+ vacuum?: (database: Database.Database) => void;
67
+ };
68
+
69
+ export type AutomaticStatePruneReport = StatePruneReport & {
70
+ completed: boolean;
71
+ compacted: boolean;
72
+ pageCount: number;
73
+ freePageCount: number;
74
+ pageSize: number;
75
+ reclaimableBytes: number;
76
+ freePageRatio: number;
77
+ compactionError?: string;
78
+ };
79
+
80
+ type DatabasePageMetrics = Pick<
81
+ AutomaticStatePruneReport,
82
+ "pageCount" | "freePageCount" | "pageSize" | "reclaimableBytes" | "freePageRatio"
83
+ >;
84
+
85
+ type DeletedState = {
86
+ deletedRows: number;
87
+ deletedBlobs: number;
88
+ deletedBlobBytes: number;
89
+ };
90
+
37
91
  export function validateStatePruneOptions(options: StatePruneOptions): void {
38
92
  parseCutoff(options.before);
39
93
  if (options.apply && options.backupPath === undefined) {
@@ -57,13 +111,7 @@ export async function pruneState(
57
111
  try {
58
112
  const selection = selectRunTrees(state, cutoff);
59
113
  const sizeBefore = databaseBytes(databasePath);
60
- const base = {
61
- cutoff: new Date(cutoff).toISOString(),
62
- candidateTrees: selection.candidateTrees,
63
- blockedTrees: selection.blockedTrees,
64
- selectedRuns: selection.runIds.length,
65
- databaseBytesBefore: sizeBefore,
66
- };
114
+ const base = reportBase(cutoff, selection, sizeBefore);
67
115
  if (!options.apply) {
68
116
  return {
69
117
  ...base,
@@ -81,46 +129,14 @@ export async function pruneState(
81
129
  }
82
130
  await state.backup(backupPath);
83
131
 
84
- let deletedRows = 0;
85
- let deletedBlobs = 0;
86
- let deletedBlobBytes = 0;
132
+ let deleted: DeletedState;
87
133
  state.connection.exec("BEGIN EXCLUSIVE");
88
134
  try {
89
135
  const checked = selectRunTrees(state, cutoff);
90
- if (
91
- checked.runIds.join("\0") !== selection.runIds.join("\0") ||
92
- checked.signature !== selection.signature
93
- ) {
136
+ if (!sameSelection(checked, selection)) {
94
137
  throw new Error("Prune selection changed after backup; run the command again");
95
138
  }
96
- const beforeChanges = totalChanges(state.connection);
97
- if (checked.runIds.length !== 0) {
98
- deleteRunAggregates(state.connection, checked.runIds);
99
- }
100
- state.connection
101
- .prepare(
102
- `DELETE FROM workflow_definitions
103
- WHERE NOT EXISTS (
104
- SELECT 1 FROM runs WHERE runs.definition_digest = workflow_definitions.definition_digest
105
- )`,
106
- )
107
- .run();
108
- state.connection
109
- .prepare(
110
- `DELETE FROM projects
111
- WHERE NOT EXISTS (SELECT 1 FROM runs WHERE runs.project_id = projects.project_id)
112
- AND NOT EXISTS (
113
- SELECT 1 FROM controller_resources
114
- WHERE controller_resources.project_id = projects.project_id
115
- )`,
116
- )
117
- .run();
118
- const retainedBlobHashes = activeBlobHashes();
119
- const blobStats = unreferencedBlobStats(state.connection, retainedBlobHashes);
120
- deleteUnreferencedBlobs(state.connection, retainedBlobHashes);
121
- deletedRows = totalChanges(state.connection) - beforeChanges;
122
- deletedBlobs = blobStats.count;
123
- deletedBlobBytes = blobStats.bytes;
139
+ deleted = deleteSelectedState(state.connection, checked.runIds, activeBlobHashes());
124
140
  state.connection.exec("COMMIT");
125
141
  } catch (error) {
126
142
  if (state.connection.inTransaction) state.connection.exec("ROLLBACK");
@@ -132,25 +148,160 @@ export async function pruneState(
132
148
  state.integrityCheck();
133
149
  return {
134
150
  ...base,
151
+ ...deleted,
152
+ databaseBytesAfter: databaseBytes(databasePath),
153
+ applied: true,
154
+ };
155
+ } finally {
156
+ if (lock !== undefined) releaseMaintenanceLock(lockPath, lock);
157
+ }
158
+ }
159
+
160
+ export async function pruneStateAutomatically(
161
+ state: StateDatabase,
162
+ databasePath: string,
163
+ options: AutomaticStatePruneOptions = {},
164
+ ): Promise<AutomaticStatePruneReport> {
165
+ const now = options.now ?? Date.now();
166
+ const cutoff = now - AUTOMATIC_STATE_RETENTION_MS;
167
+ const shouldContinue = options.shouldContinue ?? (() => true);
168
+ const yieldControl = options.yieldControl ?? yieldToEventLoop;
169
+ const activeBlobHashes = options.activeBlobHashes ?? (() => []);
170
+ const sizeBefore = databaseBytes(databasePath);
171
+ const lockPath = `${databasePath}.maintenance.lock`;
172
+ const lock = acquireMaintenanceLock(lockPath);
173
+ let initial = emptySelection();
174
+ let deletedRows = 0;
175
+ let deletedBlobs = 0;
176
+ let deletedBlobBytes = 0;
177
+ let completed = true;
178
+ let compacted = false;
179
+ let compactionError: string | undefined;
180
+ let metrics: DatabasePageMetrics = {
181
+ pageCount: 0,
182
+ freePageCount: 0,
183
+ pageSize: 0,
184
+ reclaimableBytes: 0,
185
+ freePageRatio: 0,
186
+ };
187
+
188
+ try {
189
+ metrics = databasePageMetrics(state.connection);
190
+ initial = selectRunTrees(state, cutoff, now);
191
+ for (const selectedTree of initial.trees) {
192
+ if (!shouldContinue()) {
193
+ completed = false;
194
+ break;
195
+ }
196
+ state.connection.exec("BEGIN IMMEDIATE");
197
+ try {
198
+ const checked = selectRunTrees(state, cutoff, now).trees.find(
199
+ (tree) => tree.rootRunId === selectedTree.rootRunId,
200
+ );
201
+ if (checked === undefined || !sameTree(checked, selectedTree)) {
202
+ completed = false;
203
+ state.connection.exec("ROLLBACK");
204
+ break;
205
+ }
206
+ const beforeChanges = totalChanges(state.connection);
207
+ deleteRunAggregates(state.connection, checked.runIds);
208
+ deleteUnusedDefinitionsAndProjects(state.connection);
209
+ deletedRows += totalChanges(state.connection) - beforeChanges;
210
+ state.connection.exec("COMMIT");
211
+ } catch (error) {
212
+ if (state.connection.inTransaction) state.connection.exec("ROLLBACK");
213
+ throw error;
214
+ }
215
+ await yieldControl();
216
+ }
217
+
218
+ if (completed && shouldContinue()) {
219
+ state.connection.exec("BEGIN IMMEDIATE");
220
+ try {
221
+ const beforeChanges = totalChanges(state.connection);
222
+ deleteUnusedDefinitionsAndProjects(state.connection);
223
+ const retainedBlobHashes = activeBlobHashes();
224
+ const blobStats = unreferencedBlobStats(state.connection, retainedBlobHashes);
225
+ deleteUnreferencedBlobs(state.connection, retainedBlobHashes);
226
+ deletedRows += totalChanges(state.connection) - beforeChanges;
227
+ deletedBlobs = blobStats.count;
228
+ deletedBlobBytes = blobStats.bytes;
229
+ state.connection.exec("COMMIT");
230
+ } catch (error) {
231
+ if (state.connection.inTransaction) state.connection.exec("ROLLBACK");
232
+ throw error;
233
+ }
234
+ } else {
235
+ completed = false;
236
+ }
237
+
238
+ if (completed && shouldContinue()) {
239
+ try {
240
+ state.connection.pragma("wal_checkpoint(TRUNCATE)");
241
+ metrics = databasePageMetrics(state.connection);
242
+ if (
243
+ shouldContinue() &&
244
+ shouldVacuumStateAutomatically(
245
+ metrics,
246
+ options.vacuumMinBytes ?? AUTOMATIC_STATE_VACUUM_MIN_BYTES,
247
+ options.vacuumMinFreeRatio ?? AUTOMATIC_STATE_VACUUM_MIN_FREE_RATIO,
248
+ )
249
+ ) {
250
+ (options.vacuum ?? vacuumDatabase)(state.connection);
251
+ compacted = true;
252
+ state.connection.pragma("wal_checkpoint(TRUNCATE)");
253
+ metrics = databasePageMetrics(state.connection);
254
+ }
255
+ } catch (error) {
256
+ compactionError = errorMessage(error);
257
+ metrics = databasePageMetrics(state.connection);
258
+ }
259
+ }
260
+
261
+ return {
262
+ ...reportBase(cutoff, initial, sizeBefore),
135
263
  deletedRows,
136
264
  deletedBlobs,
137
265
  deletedBlobBytes,
138
266
  databaseBytesAfter: databaseBytes(databasePath),
139
267
  applied: true,
268
+ completed,
269
+ compacted,
270
+ ...metrics,
271
+ ...(compactionError === undefined ? {} : { compactionError }),
140
272
  };
141
273
  } finally {
142
- if (lock !== undefined) releaseMaintenanceLock(lockPath, lock);
274
+ releaseMaintenanceLock(lockPath, lock);
143
275
  }
144
276
  }
145
277
 
278
+ export function shouldVacuumStateAutomatically(
279
+ metrics: DatabasePageMetrics,
280
+ minimumBytes: number = AUTOMATIC_STATE_VACUUM_MIN_BYTES,
281
+ minimumFreeRatio: number = AUTOMATIC_STATE_VACUUM_MIN_FREE_RATIO,
282
+ ): boolean {
283
+ return metrics.reclaimableBytes >= minimumBytes && metrics.freePageRatio >= minimumFreeRatio;
284
+ }
285
+
286
+ function reportBase(cutoff: number, selection: RunTreeSelection, databaseBytesBefore: number) {
287
+ return {
288
+ cutoff: new Date(cutoff).toISOString(),
289
+ candidateTrees: selection.candidateTrees,
290
+ blockedTrees: selection.blockedTrees,
291
+ selectedRuns: selection.runIds.length,
292
+ databaseBytesBefore,
293
+ };
294
+ }
295
+
146
296
  function selectRunTrees(
147
297
  state: StateDatabase,
148
298
  cutoff: number,
149
- ): { candidateTrees: number; blockedTrees: number; runIds: string[]; signature: string } {
299
+ observedAt: number = Date.now(),
300
+ ): RunTreeSelection {
150
301
  const { connection: database } = state;
151
302
  const rows = database
152
303
  .prepare(
153
- `SELECT run_id AS runId, parent_run_id AS parentRunId,
304
+ `SELECT run_id AS runId, parent_run_id AS parentRunId, root_run_id AS rootRunId,
154
305
  launch_options_hash AS launchOptionsHash, status, finished_at AS finishedAt
155
306
  FROM runs ORDER BY created_at, run_id`,
156
307
  )
@@ -166,47 +317,64 @@ function selectRunTrees(
166
317
  )
167
318
  .map((row) => row.runId),
168
319
  );
169
- const children = new Map<string, string[]>();
170
- const parents = new Map<string, string[]>();
320
+ const rowIds = new Set(rows.map((row) => row.runId));
321
+ const links = new Map(rows.map((row) => [row.runId, new Set<string>()]));
171
322
  for (const row of rows) {
172
- const runParents = new Set<string>();
173
- if (row.parentRunId !== null) runParents.add(row.parentRunId);
174
- const restartParentRunId = restartParentFromLaunchOptions(
175
- state.readJson(row.launchOptionsHash),
323
+ linkRuns(links, rowIds, row.runId, row.parentRunId);
324
+ linkRuns(links, rowIds, row.runId, row.rootRunId);
325
+ linkRuns(
326
+ links,
327
+ rowIds,
328
+ row.runId,
329
+ restartParentFromLaunchOptions(state.readJson(row.launchOptionsHash)),
176
330
  );
177
- if (restartParentRunId !== null) runParents.add(restartParentRunId);
178
- parents.set(row.runId, [...runParents]);
179
- for (const parentRunId of runParents) {
180
- const values = children.get(parentRunId) ?? [];
181
- values.push(row.runId);
182
- children.set(parentRunId, values);
183
- }
184
331
  }
185
- const roots = rows.filter(
186
- (row) =>
187
- eligible.has(row.runId) &&
188
- (parents.get(row.runId) ?? []).every((parentRunId) => !eligible.has(parentRunId)),
189
- );
190
- const selected = new Set<string>();
332
+
333
+ const components: string[][] = [];
334
+ const visited = new Set<string>();
335
+ for (const row of rows) {
336
+ if (visited.has(row.runId)) continue;
337
+ const component = descendants(row.runId, links);
338
+ for (const runId of component) visited.add(runId);
339
+ components.push(component.sort());
340
+ }
341
+
342
+ const trees: SelectedRunTree[] = [];
343
+ let candidateTrees = 0;
191
344
  let blockedTrees = 0;
192
- for (const root of roots) {
193
- const tree = descendants(root.runId, children);
194
- if (tree.some((runId) => !eligible.has(runId)) || treeHasBlocker(database, tree)) {
345
+ for (const runIds of components) {
346
+ if (!runIds.some((runId) => eligible.has(runId))) continue;
347
+ candidateTrees += 1;
348
+ if (
349
+ runIds.some((runId) => !eligible.has(runId)) ||
350
+ treeHasBlocker(database, runIds, observedAt)
351
+ ) {
195
352
  blockedTrees += 1;
196
353
  continue;
197
354
  }
198
- for (const runId of tree) selected.add(runId);
355
+ const rootRunId = chooseRootRunId(rows, runIds);
356
+ trees.push({
357
+ rootRunId,
358
+ runIds,
359
+ signature: selectionSignature(database, runIds),
360
+ });
199
361
  }
200
- const runIds = [...selected].sort();
362
+ trees.sort((left, right) => left.rootRunId.localeCompare(right.rootRunId));
363
+ const runIds = trees.flatMap((tree) => tree.runIds).sort();
201
364
  return {
202
- candidateTrees: roots.length,
365
+ candidateTrees,
203
366
  blockedTrees,
367
+ trees,
204
368
  runIds,
205
- signature: selectionSignature(database, runIds),
369
+ signature: JSON.stringify(trees),
206
370
  };
207
371
  }
208
372
 
209
- function treeHasBlocker(database: Database.Database, runIds: string[]): boolean {
373
+ function treeHasBlocker(
374
+ database: Database.Database,
375
+ runIds: string[],
376
+ observedAt: number,
377
+ ): boolean {
210
378
  const values = placeholders(runIds);
211
379
  if (
212
380
  hasRow(
@@ -214,6 +382,55 @@ function treeHasBlocker(database: Database.Database, runIds: string[]): boolean
214
382
  `SELECT 1 FROM run_queue WHERE run_id IN (${values}) AND status IN (${placeholders(LIVE_QUEUE_STATUSES)})`,
215
383
  [...runIds, ...LIVE_QUEUE_STATUSES],
216
384
  ) ||
385
+ hasRow(
386
+ database,
387
+ `SELECT 1 FROM run_workers
388
+ WHERE run_id IN (${values}) AND status IN (${placeholders(ACTIVE_RUNNER_STATUSES)})`,
389
+ [...runIds, ...ACTIVE_RUNNER_STATUSES],
390
+ ) ||
391
+ hasRow(
392
+ database,
393
+ `SELECT 1 FROM node_attempts
394
+ WHERE run_id IN (${values}) AND status IN (${placeholders(ACTIVE_ATTEMPT_STATUSES)})`,
395
+ [...runIds, ...ACTIVE_ATTEMPT_STATUSES],
396
+ ) ||
397
+ hasRow(
398
+ database,
399
+ `SELECT 1 FROM session_segments WHERE run_id IN (${values}) AND status = 'recording'`,
400
+ runIds,
401
+ ) ||
402
+ hasRow(
403
+ database,
404
+ `SELECT 1 FROM interactive_requests WHERE run_id IN (${values}) AND status = 'pending'`,
405
+ runIds,
406
+ ) ||
407
+ hasRow(
408
+ database,
409
+ `SELECT 1 FROM human_decisions d
410
+ LEFT JOIN human_decision_resolutions r ON r.decision_id = d.decision_id
411
+ WHERE d.run_id IN (${values}) AND r.decision_id IS NULL`,
412
+ runIds,
413
+ ) ||
414
+ hasRow(
415
+ database,
416
+ `SELECT 1 FROM workflow_messages WHERE run_id IN (${values}) AND status = 'pending'`,
417
+ runIds,
418
+ ) ||
419
+ hasRow(
420
+ database,
421
+ `SELECT 1 FROM workflow_turns WHERE run_id IN (${values}) AND state = 'started'`,
422
+ runIds,
423
+ ) ||
424
+ hasRow(
425
+ database,
426
+ `SELECT 1 FROM run_bindings b
427
+ WHERE b.run_id IN (${values}) AND b.execution_mode = 'interactive'
428
+ AND NOT EXISTS (
429
+ SELECT 1 FROM workflow_messages m
430
+ WHERE m.run_id = b.run_id AND m.kind = 'terminal' AND m.status = 'sent'
431
+ )`,
432
+ runIds,
433
+ ) ||
217
434
  hasRow(
218
435
  database,
219
436
  `SELECT 1 FROM workflow_follow_ups
@@ -229,14 +446,8 @@ function treeHasBlocker(database: Database.Database, runIds: string[]): boolean
229
446
  hasRow(
230
447
  database,
231
448
  `SELECT 1 FROM run_steps s JOIN node_attempts a ON a.attempt_id = s.attempt_id
232
- WHERE a.run_id IN (${values}) AND s.run_id NOT IN (${values})`,
233
- [...runIds, ...runIds],
234
- ) ||
235
- hasRow(
236
- database,
237
- `SELECT 1 FROM continuations
238
- WHERE (parent_run_id IN (${values}) AND continuation_run_id NOT IN (${values}))
239
- OR (continuation_run_id IN (${values}) AND parent_run_id NOT IN (${values}))`,
449
+ WHERE (a.run_id IN (${values}) AND s.run_id NOT IN (${values}))
450
+ OR (s.run_id IN (${values}) AND a.run_id NOT IN (${values}))`,
240
451
  [...runIds, ...runIds, ...runIds, ...runIds],
241
452
  ) ||
242
453
  hasRow(
@@ -251,12 +462,12 @@ function treeHasBlocker(database: Database.Database, runIds: string[]): boolean
251
462
  }
252
463
  const resources = relatedResourceIds(database, runIds);
253
464
  const resourceValues = placeholders(resources);
254
- if (
465
+ return (
255
466
  hasRow(
256
467
  database,
257
468
  `SELECT 1 FROM leases
258
469
  WHERE resource_id IN (${resourceValues}) AND owner_id IS NOT NULL AND expires_at > ?`,
259
- [...resources, Date.now()],
470
+ [...resources, observedAt],
260
471
  ) ||
261
472
  hasRow(
262
473
  database,
@@ -271,10 +482,45 @@ function treeHasBlocker(database: Database.Database, runIds: string[]): boolean
271
482
  WHERE d.run_id IN (${values})`,
272
483
  runIds,
273
484
  )
274
- ) {
275
- return true;
276
- }
277
- return false;
485
+ );
486
+ }
487
+
488
+ function deleteSelectedState(
489
+ database: Database.Database,
490
+ runIds: string[],
491
+ retainedBlobHashes: readonly Buffer[],
492
+ ): DeletedState {
493
+ const beforeChanges = totalChanges(database);
494
+ if (runIds.length !== 0) deleteRunAggregates(database, runIds);
495
+ deleteUnusedDefinitionsAndProjects(database);
496
+ const blobStats = unreferencedBlobStats(database, retainedBlobHashes);
497
+ deleteUnreferencedBlobs(database, retainedBlobHashes);
498
+ return {
499
+ deletedRows: totalChanges(database) - beforeChanges,
500
+ deletedBlobs: blobStats.count,
501
+ deletedBlobBytes: blobStats.bytes,
502
+ };
503
+ }
504
+
505
+ function deleteUnusedDefinitionsAndProjects(database: Database.Database): void {
506
+ database
507
+ .prepare(
508
+ `DELETE FROM workflow_definitions
509
+ WHERE NOT EXISTS (
510
+ SELECT 1 FROM runs WHERE runs.definition_digest = workflow_definitions.definition_digest
511
+ )`,
512
+ )
513
+ .run();
514
+ database
515
+ .prepare(
516
+ `DELETE FROM projects
517
+ WHERE NOT EXISTS (SELECT 1 FROM runs WHERE runs.project_id = projects.project_id)
518
+ AND NOT EXISTS (
519
+ SELECT 1 FROM controller_resources
520
+ WHERE controller_resources.project_id = projects.project_id
521
+ )`,
522
+ )
523
+ .run();
278
524
  }
279
525
 
280
526
  function deleteRunAggregates(database: Database.Database, runIds: string[]): void {
@@ -292,12 +538,6 @@ function deleteRunAggregates(database: Database.Database, runIds: string[]): voi
292
538
  const effectValues = placeholders(effectIds);
293
539
  database.prepare(`DELETE FROM effects WHERE effect_id IN (${effectValues})`).run(...effectIds);
294
540
  }
295
- database
296
- .prepare(
297
- `DELETE FROM continuations
298
- WHERE parent_run_id IN (${values}) OR continuation_run_id IN (${values})`,
299
- )
300
- .run(...runIds, ...runIds);
301
541
  const allResources = [...new Set([...resources, ...effectRows.map((row) => row.resourceId)])];
302
542
  if (allResources.length !== 0) {
303
543
  database
@@ -408,7 +648,60 @@ function blobReferencePredicate(database: Database.Database, retainedBlobCount:
408
648
  return `${unreferenced} AND blob_hash NOT IN (${retainedPlaceholders})`;
409
649
  }
410
650
 
411
- function descendants(root: string, children: Map<string, string[]>): string[] {
651
+ function vacuumDatabase(database: Database.Database): void {
652
+ database.exec("VACUUM");
653
+ }
654
+
655
+ function databasePageMetrics(database: Database.Database): DatabasePageMetrics {
656
+ const pageCount = pragmaNumber(database, "page_count");
657
+ const freePageCount = pragmaNumber(database, "freelist_count");
658
+ const pageSize = pragmaNumber(database, "page_size");
659
+ return {
660
+ pageCount,
661
+ freePageCount,
662
+ pageSize,
663
+ reclaimableBytes: freePageCount * pageSize,
664
+ freePageRatio: pageCount === 0 ? 0 : freePageCount / pageCount,
665
+ };
666
+ }
667
+
668
+ function pragmaNumber(database: Database.Database, name: string): number {
669
+ const value = database.pragma(name, { simple: true });
670
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
671
+ throw new Error(`SQLite ${name} returned an invalid value`);
672
+ }
673
+ return value;
674
+ }
675
+
676
+ function sameSelection(left: RunTreeSelection, right: RunTreeSelection): boolean {
677
+ return left.runIds.join("\0") === right.runIds.join("\0") && left.signature === right.signature;
678
+ }
679
+
680
+ function sameTree(left: SelectedRunTree, right: SelectedRunTree): boolean {
681
+ return left.runIds.join("\0") === right.runIds.join("\0") && left.signature === right.signature;
682
+ }
683
+
684
+ function chooseRootRunId(rows: RunAgeRow[], runIds: string[]): string {
685
+ const runIdSet = new Set(runIds);
686
+ const candidates = rows
687
+ .filter((row) => runIdSet.has(row.runId) && row.rootRunId === row.runId)
688
+ .map((row) => row.runId)
689
+ .sort();
690
+ return candidates[0] ?? runIds[0] ?? "";
691
+ }
692
+
693
+ function linkRuns(
694
+ links: Map<string, Set<string>>,
695
+ rowIds: Set<string>,
696
+ left: string,
697
+ right: string | null,
698
+ ): void {
699
+ if (right === null || right === left || !rowIds.has(right)) return;
700
+ links.get(left)?.add(right);
701
+ links.get(right)?.add(left);
702
+ }
703
+
704
+ function descendants(root: string, links: Map<string, Set<string>>): string[] {
412
705
  const result: string[] = [];
413
706
  const seen = new Set<string>();
414
707
  const pending = [root];
@@ -417,7 +710,7 @@ function descendants(root: string, children: Map<string, string[]>): string[] {
417
710
  if (seen.has(runId)) continue;
418
711
  seen.add(runId);
419
712
  result.push(runId);
420
- pending.push(...(children.get(runId) ?? []));
713
+ pending.push(...(links.get(runId) ?? []));
421
714
  }
422
715
  return result;
423
716
  }
@@ -444,6 +737,10 @@ function databaseBytes(databasePath: string): number {
444
737
  .reduce((total, filePath) => total + fs.statSync(filePath).size, 0);
445
738
  }
446
739
 
740
+ function emptySelection(): RunTreeSelection {
741
+ return { candidateTrees: 0, blockedTrees: 0, trees: [], runIds: [], signature: "" };
742
+ }
743
+
447
744
  function acquireMaintenanceLock(lockPath: string): number {
448
745
  try {
449
746
  return fs.openSync(lockPath, "wx", 0o600);
@@ -478,12 +775,28 @@ function quoteIdentifier(value: string): string {
478
775
  return `"${value.replaceAll('"', '""')}"`;
479
776
  }
480
777
 
778
+ function errorMessage(error: unknown): string {
779
+ return error instanceof Error ? error.message : String(error);
780
+ }
781
+
782
+ async function yieldToEventLoop(): Promise<void> {
783
+ await new Promise<void>((resolve) => setImmediate(resolve));
784
+ }
785
+
481
786
  function isRecord(value: unknown): value is Record<string, unknown> {
482
787
  return typeof value === "object" && value !== null && !Array.isArray(value);
483
788
  }
484
789
 
485
790
  function isRunAgeRow(value: unknown): value is RunAgeRow {
486
- return isRecord(value);
791
+ return (
792
+ isRecord(value) &&
793
+ typeof value.runId === "string" &&
794
+ (value.parentRunId === null || typeof value.parentRunId === "string") &&
795
+ typeof value.rootRunId === "string" &&
796
+ Buffer.isBuffer(value.launchOptionsHash) &&
797
+ typeof value.status === "string" &&
798
+ (value.finishedAt === null || typeof value.finishedAt === "number")
799
+ );
487
800
  }
488
801
 
489
802
  function isResourceRow(value: unknown): value is { resourceId: string } {