@osolmaz/pi-workflows 0.13.2 → 0.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/dist/builtins/autodoc.workflow.d.ts +5 -5
- package/dist/builtins/autoimplement.workflow.d.ts +65 -61
- package/dist/builtins/autoimplement.workflow.js +47 -8
- package/dist/builtins/autoimplement.workflow.js.map +1 -1
- package/dist/builtins/autoplan.workflow.d.ts +5 -5
- package/dist/builtins/catalog.js +1 -1
- package/dist/builtins/change-verification.workflow.d.ts +1 -1
- package/dist/builtins/plain-summary.workflow.d.ts +1 -1
- package/dist/builtins/plan-approval.workflow.d.ts +1 -1
- package/dist/builtins/plan-change.workflow.d.ts +23 -23
- package/dist/builtins/sanity-check.workflow.d.ts +3 -3
- package/dist/builtins/workspace-preparation.workflow.d.ts +1 -1
- package/dist/controllers/index.d.ts +2 -2
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/sqlite.js +85 -23
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/controllers/types.d.ts +34 -0
- package/dist/controllers/workflow-engine-scheduler.d.ts +5 -1
- package/dist/controllers/workflow-engine-scheduler.js +83 -1
- package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
- package/dist/controllers/workflows.d.ts +8 -1
- package/dist/controllers/workflows.js +36 -0
- package/dist/controllers/workflows.js.map +1 -1
- package/dist/extension/controller-host.d.ts +2 -1
- package/dist/extension/controller-host.js +1 -1
- package/dist/extension/controller-host.js.map +1 -1
- package/dist/extension/follow-up-coordinator.d.ts +27 -0
- package/dist/extension/follow-up-coordinator.js +131 -0
- package/dist/extension/follow-up-coordinator.js.map +1 -0
- package/dist/extension/index.d.ts +9 -0
- package/dist/extension/index.js +460 -75
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/session-events.d.ts +2 -3
- package/dist/extension/session-events.js +11 -10
- package/dist/extension/session-events.js.map +1 -1
- package/dist/extension/widget.js +9 -0
- package/dist/extension/widget.js.map +1 -1
- package/dist/state/database.d.ts +2 -1
- package/dist/state/database.js +11 -13
- package/dist/state/database.js.map +1 -1
- package/dist/state/json.js +6 -1
- package/dist/state/json.js.map +1 -1
- package/dist/state/mutation.d.ts +1 -1
- package/dist/state/mutation.js.map +1 -1
- package/dist/state/prune.d.ts +18 -0
- package/dist/state/prune.js +373 -0
- package/dist/state/prune.js.map +1 -0
- package/dist/state/schema.d.ts +1 -1
- package/dist/state/schema.js +116 -18
- package/dist/state/schema.js.map +1 -1
- package/dist/viewer/cli.d.ts +3 -1
- package/dist/viewer/cli.js +55 -4
- package/dist/viewer/cli.js.map +1 -1
- package/dist/viewer/render.js +19 -1
- package/dist/viewer/render.js.map +1 -1
- package/dist/workflows/composition.d.ts +2 -0
- package/dist/workflows/composition.js +15 -0
- package/dist/workflows/composition.js.map +1 -1
- package/dist/workflows/definition.d.ts +6 -3
- package/dist/workflows/definition.js +3 -0
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.d.ts +6 -1
- package/dist/workflows/engine.js +301 -35
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/human-decision.d.ts +1 -0
- package/dist/workflows/human-decision.js +25 -9
- package/dist/workflows/human-decision.js.map +1 -1
- package/dist/workflows/index.d.ts +3 -1
- package/dist/workflows/index.js +2 -0
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/json-patch.d.ts +47 -0
- package/dist/workflows/json-patch.js +298 -0
- package/dist/workflows/json-patch.js.map +1 -0
- package/dist/workflows/schema.js +29 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/settings.d.ts +128 -0
- package/dist/workflows/settings.js +199 -0
- package/dist/workflows/settings.js.map +1 -0
- package/dist/workflows/store.d.ts +75 -2
- package/dist/workflows/store.js +1349 -89
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/tool-input.d.ts +22 -0
- package/dist/workflows/tool-input.js +43 -0
- package/dist/workflows/tool-input.js.map +1 -1
- package/dist/workflows/types.d.ts +40 -5
- package/docs/2026-08-25-workflow-follow-ups.md +132 -0
- package/docs/2026-08-25-workflow-settings.md +169 -0
- package/docs/DEFERRED_TURNS.md +6 -0
- package/docs/DESIGN_PHILOSOPHY.md +2 -0
- package/docs/SQLITE_STATE.md +31 -18
- package/docs/WORKFLOW_COMPOSITION.md +15 -0
- package/docs/WORKFLOW_STEP_MESSAGES.md +4 -2
- package/docs/plans/2026-08-25-live-workflow-settings-plan.md +532 -0
- package/docs/plans/2026-08-25-workflow-run-storage-plan.md +67 -0
- package/docs/tui-viewer.md +7 -6
- package/docs/workflows.md +22 -1
- package/examples/workflows/live-settings.workflow.ts +93 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/src/builtins/autoimplement.workflow.ts +56 -8
- package/src/builtins/catalog.ts +1 -1
- package/src/controllers/index.ts +6 -0
- package/src/controllers/sqlite.ts +129 -35
- package/src/controllers/types.ts +40 -0
- package/src/controllers/workflow-engine-scheduler.ts +103 -1
- package/src/controllers/workflows.ts +68 -0
- package/src/extension/controller-host.ts +6 -1
- package/src/extension/follow-up-coordinator.ts +151 -0
- package/src/extension/index.ts +538 -88
- package/src/extension/session-events.ts +15 -13
- package/src/extension/widget.ts +8 -0
- package/src/state/database.ts +12 -12
- package/src/state/json.ts +6 -1
- package/src/state/mutation.ts +4 -1
- package/src/state/prune.ts +502 -0
- package/src/state/schema.ts +116 -18
- package/src/viewer/cli.ts +52 -5
- package/src/viewer/render.ts +43 -1
- package/src/workflows/composition.ts +19 -0
- package/src/workflows/definition.ts +19 -5
- package/src/workflows/engine.ts +363 -35
- package/src/workflows/human-decision.ts +41 -11
- package/src/workflows/index.ts +43 -0
- package/src/workflows/json-patch.ts +375 -0
- package/src/workflows/schema.ts +31 -1
- package/src/workflows/settings.ts +430 -0
- package/src/workflows/store.ts +1977 -184
- package/src/workflows/tool-input.ts +58 -0
- 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
|
|
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
|
|
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;
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
108
|
+
return { toolName: event.toolName, isError: event.isError };
|
|
107
109
|
}
|
|
108
110
|
|
|
109
111
|
export function turnFinishedPayload(
|
package/src/extension/widget.ts
CHANGED
|
@@ -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) {
|
package/src/state/database.ts
CHANGED
|
@@ -89,14 +89,15 @@ export class StateDatabase {
|
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
try {
|
|
92
|
-
this.
|
|
93
|
-
if (this.mode === "read-write") {
|
|
94
|
-
this.prepareSchema(
|
|
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
|
|
216
|
+
private configureConnection(): void {
|
|
216
217
|
this.connection.pragma("foreign_keys = ON");
|
|
217
218
|
this.connection.pragma(`busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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
|
}
|
package/src/state/mutation.ts
CHANGED
|
@@ -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
|
+
}
|