@abelxiaoxing/cadence 1.8.4 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +324 -117
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/agents/design-explorer.md +14 -18
- package/agents/diagnosis-worker.md +14 -14
- package/agents/implementation-worker.md +16 -17
- package/config/workflow-task.example.json +7 -0
- package/docs/remote-access.md +60 -0
- package/docs/reuse-and-distribution.md +44 -0
- package/docs/service-management.md +108 -0
- package/licenses/bundled-web.txt +6245 -0
- package/licenses/pi-web-ui-MIT.txt +21 -0
- package/licenses/pi-web-ui-reuse.md +22 -0
- package/package.json +94 -21
- package/src/index.ts +21 -1802
- package/src/native-build.ts +6 -0
- package/src/native-client.ts +103 -0
- package/src/native-export.ts +58 -0
- package/src/native-history.ts +359 -0
- package/src/operator-cli.mjs +453 -1937
- package/src/plan-proposal.ts +87 -0
- package/src/sdk-worker.mjs +549 -0
- package/src/service/contracts/active.ts +82 -0
- package/src/service/contracts/budget.ts +148 -0
- package/src/service/contracts/build.ts +67 -0
- package/src/service/contracts/capture.ts +48 -0
- package/src/service/contracts/core.ts +33 -0
- package/src/service/contracts/current.ts +261 -0
- package/src/service/contracts/domain.ts +68 -0
- package/src/service/contracts/history.ts +2245 -0
- package/src/service/contracts/models.ts +139 -0
- package/src/service/coordination.ts +862 -0
- package/src/service/private-files.ts +204 -0
- package/src/service-cli.mjs +5592 -0
- package/src/service-client.ts +747 -0
- package/src/service-contract.ts +245 -0
- package/src/service-host.mjs +22382 -0
- package/src/service-http-client.ts +425 -0
- package/src/service-wire.ts +372 -0
- package/src/task-shell.ts +1045 -0
- package/src/task-store.ts +479 -0
- package/src/task-types.ts +184 -0
- package/src/verification-diagnostics.ts +29 -72
- package/src/worker-capture.mjs +1197 -0
- package/src/worker-public.mjs +1753 -0
- package/src/worker-redaction.mjs +608 -0
- package/src/worker-tools.mjs +202 -0
- package/web/dist/assets/KaTeX_AMS-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Bold.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Bold.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic.woff +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Italic.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Italic.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic.woff +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-Italic.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-Italic.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Script-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Script-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular.ttf +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular.woff +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/web/dist/assets/cadence.css +2 -0
- package/web/dist/assets/cadence.js +7 -0
- package/web/dist/assets/markdown.js +296 -0
- package/web/dist/assets/react.js +9 -0
- package/web/dist/assets/rolldown-runtime.js +1 -0
- package/web/dist/assets/timeline-client.js +41 -0
- package/web/dist/assets/vis-timeline.css +1274 -0
- package/web/dist/assets/vis-timeline.js +32 -0
- package/web/dist/index.html +18 -0
- package/web/dist/manifest.json +505 -0
- package/config/plan-draft.example.json +0 -125
- package/config/plan-draft.multiple-tasks.example.json +0 -375
- package/config/plan-draft.quick.example.json +0 -78
- package/prompts/abel-design.md +0 -382
- package/prompts/abel-diagnose.md +0 -71
- package/prompts/abel-implement.md +0 -284
- package/prompts/abel-init.md +0 -52
- package/src/activation.ts +0 -44
- package/src/activity-contracts.ts +0 -82
- package/src/agent-registry.ts +0 -53
- package/src/apply-transaction.ts +0 -1437
- package/src/artifact-store.ts +0 -213
- package/src/build-windows-job.mjs +0 -95
- package/src/candidate-artifact.ts +0 -143
- package/src/candidate-context.ts +0 -289
- package/src/candidate-patch.ts +0 -271
- package/src/candidate-workspace.ts +0 -80
- package/src/canonical.ts +0 -16
- package/src/change-contract-schema.ts +0 -191
- package/src/change-contract.ts +0 -324
- package/src/change-verification.ts +0 -1617
- package/src/contracts.ts +0 -2466
- package/src/control-contracts.ts +0 -382
- package/src/delivery-compiler.ts +0 -1805
- package/src/delivery-revision.ts +0 -42
- package/src/design-control.ts +0 -1299
- package/src/design-diagnostics.ts +0 -181
- package/src/design-journal.ts +0 -1773
- package/src/directory-sync.ts +0 -36
- package/src/durable-contracts.ts +0 -227
- package/src/durable-workflow.ts +0 -822
- package/src/evidence-draft.ts +0 -122
- package/src/execution-profile.ts +0 -64
- package/src/execution-retention.ts +0 -77
- package/src/file-snapshot.ts +0 -163
- package/src/implement-continuation.ts +0 -559
- package/src/implement-graph.ts +0 -690
- package/src/implement-plan.ts +0 -72
- package/src/install-config.d.mts +0 -6
- package/src/isolation-backend.ts +0 -353
- package/src/model-source.ts +0 -22
- package/src/openspec-cli.ts +0 -494
- package/src/operator-cli-entry.mjs +0 -24
- package/src/operator-tools.ts +0 -309
- package/src/package-candidate-process.ts +0 -206
- package/src/package-candidate.ts +0 -24
- package/src/package-delivery.ts +0 -486
- package/src/package-state.ts +0 -320
- package/src/package-verification.ts +0 -635
- package/src/package-workflow.ts +0 -305
- package/src/packet-runtime.ts +0 -459
- package/src/phase-execution.ts +0 -2163
- package/src/pi-adapter.ts +0 -19
- package/src/plan-draft-summary.ts +0 -527
- package/src/plan-draft.ts +0 -502
- package/src/remove-path.ts +0 -32
- package/src/run-state.ts +0 -420
- package/src/run-store.ts +0 -1179
- package/src/safe-path.ts +0 -77
- package/src/scoped-grep-worker.mjs +0 -23
- package/src/scoped-tools.ts +0 -775
- package/src/single-task-draft.ts +0 -142
- package/src/sqlite-schema.ts +0 -171
- package/src/state-root.ts +0 -215
- package/src/storage-schema.ts +0 -360
- package/src/subagent-activity.ts +0 -1417
- package/src/subagent-process.ts +0 -476
- package/src/task-ledger.ts +0 -1338
- package/src/transport-budget.ts +0 -50
- package/src/verification-capability.ts +0 -1191
- package/src/verification-environment-io.ts +0 -389
- package/src/verification-environment.ts +0 -162
- package/src/verification-identity.ts +0 -77
- package/src/verification-scan.ts +0 -64
- package/src/windows-job-backend.ts +0 -419
- package/src/windows-job.c +0 -149
- package/src/workflow-engine.ts +0 -28
- package/src/workflow-policy.ts +0 -1670
- package/src/workflow-recovery-policy.ts +0 -97
- package/src/workflow-scheduling.ts +0 -105
- package/src/workflow-state-machine.ts +0 -4341
- package/src/workflow-status.ts +0 -576
- package/src/workspace-io-entry.mjs +0 -76
- package/src/workspace-io-worker.mjs +0 -1312
- package/src/workspace-io.ts +0 -89
- package/src/workspace-store.ts +0 -634
package/src/run-store.ts
DELETED
|
@@ -1,1179 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { chmodSync, closeSync, lstatSync, openSync } from "node:fs";
|
|
3
|
-
import { DatabaseSync } from "node:sqlite";
|
|
4
|
-
import {
|
|
5
|
-
createRunEvent,
|
|
6
|
-
type DeliveryGate,
|
|
7
|
-
type RunEvent,
|
|
8
|
-
type RunProjection,
|
|
9
|
-
type RunStage,
|
|
10
|
-
type RunState,
|
|
11
|
-
reduceRunEvents,
|
|
12
|
-
ZERO_EVENT_HASH,
|
|
13
|
-
} from "./run-state.ts";
|
|
14
|
-
import { configureSqlite, matchesSqliteSchema } from "./sqlite-schema.ts";
|
|
15
|
-
import {
|
|
16
|
-
prepareStateRoot,
|
|
17
|
-
type ResolvedStateRoot,
|
|
18
|
-
StateRootError,
|
|
19
|
-
} from "./state-root.ts";
|
|
20
|
-
import {
|
|
21
|
-
LEGACY_RUN_SCHEMA_V2,
|
|
22
|
-
RUN_SCHEMA as SCHEMA,
|
|
23
|
-
} from "./storage-schema.ts";
|
|
24
|
-
|
|
25
|
-
export class RunStoreFormatError extends Error {
|
|
26
|
-
readonly code = "run-store-reset-required" as const;
|
|
27
|
-
readonly databasePath: string;
|
|
28
|
-
readonly recovery = Object.freeze({
|
|
29
|
-
action: "reset-private-run-store" as const,
|
|
30
|
-
requiresBackup: true as const,
|
|
31
|
-
retryCommand: "status-or-start" as const,
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
constructor(databasePath: string) {
|
|
35
|
-
super("run-store-reset-required");
|
|
36
|
-
this.name = "RunStoreFormatError";
|
|
37
|
-
this.databasePath = databasePath;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export class RunStoreMigrationError extends Error {
|
|
42
|
-
readonly code = "run-store-migration-failed" as const;
|
|
43
|
-
readonly schemaVersion: number;
|
|
44
|
-
|
|
45
|
-
constructor(schemaVersion: number, cause: unknown) {
|
|
46
|
-
super("run-store-migration-failed", { cause });
|
|
47
|
-
this.name = "RunStoreMigrationError";
|
|
48
|
-
this.schemaVersion = schemaVersion;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const CHANGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/u;
|
|
53
|
-
const IDENTIFIER = /^[a-z0-9](?:[a-z0-9._:-]{0,126}[a-z0-9])?$/iu;
|
|
54
|
-
const SHA256 = /^[a-f0-9]{64}$/u;
|
|
55
|
-
|
|
56
|
-
interface RunRow {
|
|
57
|
-
run_id: string;
|
|
58
|
-
projection_json: string;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
interface EventRow {
|
|
62
|
-
sequence: number;
|
|
63
|
-
type: string;
|
|
64
|
-
payload_json: string;
|
|
65
|
-
prior_hash: string;
|
|
66
|
-
event_hash: string;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
interface OperationRow {
|
|
70
|
-
kind: string;
|
|
71
|
-
state: string;
|
|
72
|
-
outcome_json: string | null;
|
|
73
|
-
lease_token: string | null;
|
|
74
|
-
lease_expires_at: number | null;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export interface RunStoreOptions {
|
|
78
|
-
now?: () => number;
|
|
79
|
-
busyTimeoutMs?: number;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export interface StartRunInput {
|
|
83
|
-
stage: RunStage;
|
|
84
|
-
change?: string;
|
|
85
|
-
provisionalKey?: string;
|
|
86
|
-
provisionalAliases?: readonly string[];
|
|
87
|
-
operationId: string;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export interface BindDeliveryInput {
|
|
91
|
-
runId: string;
|
|
92
|
-
gate: DeliveryGate;
|
|
93
|
-
revision: number;
|
|
94
|
-
receiptHash: string;
|
|
95
|
-
approvalProof?: {
|
|
96
|
-
revision: number;
|
|
97
|
-
contractHash: string;
|
|
98
|
-
recordHash: string;
|
|
99
|
-
};
|
|
100
|
-
operationId: string;
|
|
101
|
-
lease?: OperationLease;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export interface OperationLease {
|
|
105
|
-
runId: string;
|
|
106
|
-
operationId: string;
|
|
107
|
-
token: string;
|
|
108
|
-
expiresAt: number;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export interface OperationLeaseStatus {
|
|
112
|
-
state: "running" | "committed" | "interrupted";
|
|
113
|
-
outcome?: Record<string, unknown>;
|
|
114
|
-
expiresAt?: number;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function requireIdentifier(value: string, label: string): void {
|
|
118
|
-
if (!IDENTIFIER.test(value)) throw new Error(`invalid-${label}`);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const LEGACY_SCHEMA_VERSIONS = new Map([
|
|
122
|
-
[2, LEGACY_RUN_SCHEMA_V2],
|
|
123
|
-
[4, SCHEMA],
|
|
124
|
-
]);
|
|
125
|
-
|
|
126
|
-
function applicationTables(database: DatabaseSync): string[] {
|
|
127
|
-
return (
|
|
128
|
-
database
|
|
129
|
-
.prepare(
|
|
130
|
-
`SELECT name FROM sqlite_master
|
|
131
|
-
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
|
132
|
-
ORDER BY name`,
|
|
133
|
-
)
|
|
134
|
-
.all() as unknown as Array<{ name: string }>
|
|
135
|
-
).map(({ name }) => name);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function tableColumns(database: DatabaseSync, table: string): string[] {
|
|
139
|
-
return (
|
|
140
|
-
database.prepare(`PRAGMA table_info(${table})`).all() as unknown as Array<{
|
|
141
|
-
name: string;
|
|
142
|
-
}>
|
|
143
|
-
).map(({ name }) => name);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function hasRequiredCoreSchema(
|
|
147
|
-
database: DatabaseSync,
|
|
148
|
-
_tables: readonly string[],
|
|
149
|
-
): boolean {
|
|
150
|
-
return matchesSqliteSchema(database, SCHEMA);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function hasCurrentSchema(
|
|
154
|
-
database: DatabaseSync,
|
|
155
|
-
tables: readonly string[],
|
|
156
|
-
): boolean {
|
|
157
|
-
return (
|
|
158
|
-
!tables.includes("schema_meta") && hasRequiredCoreSchema(database, tables)
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function supportedLegacyVersion(
|
|
163
|
-
database: DatabaseSync,
|
|
164
|
-
tables: readonly string[],
|
|
165
|
-
): number | undefined {
|
|
166
|
-
if (
|
|
167
|
-
!tables.includes("schema_meta") ||
|
|
168
|
-
tableColumns(database, "schema_meta").join("\0") !== "version"
|
|
169
|
-
) {
|
|
170
|
-
return undefined;
|
|
171
|
-
}
|
|
172
|
-
const rows = database
|
|
173
|
-
.prepare("SELECT version FROM schema_meta")
|
|
174
|
-
.all() as unknown as Array<{
|
|
175
|
-
version: number;
|
|
176
|
-
}>;
|
|
177
|
-
if (rows.length !== 1) return undefined;
|
|
178
|
-
const version = rows[0]?.version;
|
|
179
|
-
const schema = LEGACY_SCHEMA_VERSIONS.get(version);
|
|
180
|
-
if (!schema || !matchesSqliteSchema(database, schema)) return undefined;
|
|
181
|
-
// A v2 database cannot already contain any proof fields. Do not repair a
|
|
182
|
-
// partial/ambiguous upgrade or reinterpret malformed fields as authority.
|
|
183
|
-
if (
|
|
184
|
-
version === 2 &&
|
|
185
|
-
tableColumns(database, "delivery_bindings").some((column) =>
|
|
186
|
-
["approval_revision", "contract_hash", "record_hash"].includes(column),
|
|
187
|
-
)
|
|
188
|
-
)
|
|
189
|
-
return undefined;
|
|
190
|
-
return version;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function isSqliteLocked(error: unknown): boolean {
|
|
194
|
-
return (
|
|
195
|
-
typeof error === "object" &&
|
|
196
|
-
error !== null &&
|
|
197
|
-
"errcode" in error &&
|
|
198
|
-
((error as { errcode?: unknown }).errcode === 5 ||
|
|
199
|
-
(error as { errcode?: unknown }).errcode === 6)
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function isStoreUnavailable(error: unknown): boolean {
|
|
204
|
-
if (typeof error !== "object" || error === null) return false;
|
|
205
|
-
const errcode = (error as { errcode?: unknown }).errcode;
|
|
206
|
-
if ([8, 10, 13, 14, 23, 24].includes(Number(errcode))) return true;
|
|
207
|
-
const code = (error as { code?: unknown }).code;
|
|
208
|
-
return (
|
|
209
|
-
typeof code === "string" &&
|
|
210
|
-
[
|
|
211
|
-
"EACCES",
|
|
212
|
-
"EBUSY",
|
|
213
|
-
"EMFILE",
|
|
214
|
-
"ENFILE",
|
|
215
|
-
"ENOSPC",
|
|
216
|
-
"EPERM",
|
|
217
|
-
"EROFS",
|
|
218
|
-
].includes(code)
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function ensureCurrentSchema(
|
|
223
|
-
database: DatabaseSync,
|
|
224
|
-
databasePath: string,
|
|
225
|
-
): void {
|
|
226
|
-
const initialTables = applicationTables(database);
|
|
227
|
-
if (hasCurrentSchema(database, initialTables)) return;
|
|
228
|
-
let transactionStarted = false;
|
|
229
|
-
let migrating: number | undefined;
|
|
230
|
-
try {
|
|
231
|
-
database.exec("BEGIN IMMEDIATE");
|
|
232
|
-
transactionStarted = true;
|
|
233
|
-
const tables = applicationTables(database);
|
|
234
|
-
if (tables.length === 0) {
|
|
235
|
-
database.exec(SCHEMA);
|
|
236
|
-
} else if (hasCurrentSchema(database, tables)) {
|
|
237
|
-
// Another opener may already have completed the same migration.
|
|
238
|
-
} else {
|
|
239
|
-
migrating = supportedLegacyVersion(database, tables);
|
|
240
|
-
if (migrating === undefined) throw new RunStoreFormatError(databasePath);
|
|
241
|
-
if (migrating === 2) {
|
|
242
|
-
database.exec(`
|
|
243
|
-
ALTER TABLE delivery_bindings ADD COLUMN approval_revision INTEGER;
|
|
244
|
-
ALTER TABLE delivery_bindings ADD COLUMN contract_hash TEXT;
|
|
245
|
-
ALTER TABLE delivery_bindings ADD COLUMN record_hash TEXT;
|
|
246
|
-
`);
|
|
247
|
-
}
|
|
248
|
-
if (!hasRequiredCoreSchema(database, tables))
|
|
249
|
-
throw new RunStoreFormatError(databasePath);
|
|
250
|
-
database.exec("DROP TABLE schema_meta");
|
|
251
|
-
}
|
|
252
|
-
database.exec("COMMIT");
|
|
253
|
-
} catch (error) {
|
|
254
|
-
if (transactionStarted) {
|
|
255
|
-
try {
|
|
256
|
-
database.exec("ROLLBACK");
|
|
257
|
-
} catch {
|
|
258
|
-
// The original schema or commit failure remains authoritative.
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
if (
|
|
262
|
-
error instanceof RunStoreFormatError ||
|
|
263
|
-
error instanceof RunStoreMigrationError ||
|
|
264
|
-
isSqliteLocked(error)
|
|
265
|
-
) {
|
|
266
|
-
throw error;
|
|
267
|
-
}
|
|
268
|
-
if (migrating) {
|
|
269
|
-
throw new RunStoreMigrationError(migrating, error);
|
|
270
|
-
}
|
|
271
|
-
throw error;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function parseProjection(value: string): RunProjection {
|
|
276
|
-
const parsed = JSON.parse(value) as RunProjection;
|
|
277
|
-
if (
|
|
278
|
-
!parsed ||
|
|
279
|
-
typeof parsed !== "object" ||
|
|
280
|
-
typeof parsed.runId !== "string"
|
|
281
|
-
) {
|
|
282
|
-
throw new Error("run-projection-invalid");
|
|
283
|
-
}
|
|
284
|
-
return parsed;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function lookupKeys(input: StartRunInput): string[] {
|
|
288
|
-
const hasChange = typeof input.change === "string";
|
|
289
|
-
const hasProvisional = typeof input.provisionalKey === "string";
|
|
290
|
-
if (hasChange === hasProvisional) throw new Error("invalid-run-lookup-key");
|
|
291
|
-
if (hasChange) {
|
|
292
|
-
if (input.provisionalAliases !== undefined) {
|
|
293
|
-
throw new Error("invalid-run-lookup-key");
|
|
294
|
-
}
|
|
295
|
-
if (!CHANGE_NAME.test(input.change as string))
|
|
296
|
-
throw new Error("invalid-change");
|
|
297
|
-
return [`change:${input.change}`];
|
|
298
|
-
}
|
|
299
|
-
if (!SHA256.test(input.provisionalKey as string)) {
|
|
300
|
-
throw new Error("invalid-provisional-key");
|
|
301
|
-
}
|
|
302
|
-
if (
|
|
303
|
-
input.provisionalAliases !== undefined &&
|
|
304
|
-
(!Array.isArray(input.provisionalAliases) ||
|
|
305
|
-
input.provisionalAliases.length > 4 ||
|
|
306
|
-
input.provisionalAliases.some((alias) => !SHA256.test(alias)))
|
|
307
|
-
) {
|
|
308
|
-
throw new Error("invalid-provisional-key");
|
|
309
|
-
}
|
|
310
|
-
if (input.stage !== "abel-design") {
|
|
311
|
-
throw new Error("provisional-run-requires-design");
|
|
312
|
-
}
|
|
313
|
-
return [
|
|
314
|
-
...new Set([input.provisionalKey, ...(input.provisionalAliases ?? [])]),
|
|
315
|
-
].map((hash) => `provisional:${hash}`);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
export class RunStore {
|
|
319
|
-
readonly stateRoot: ResolvedStateRoot;
|
|
320
|
-
readonly #database: DatabaseSync;
|
|
321
|
-
readonly #now: () => number;
|
|
322
|
-
#closed = false;
|
|
323
|
-
|
|
324
|
-
private constructor(
|
|
325
|
-
stateRoot: ResolvedStateRoot,
|
|
326
|
-
database: DatabaseSync,
|
|
327
|
-
options: RunStoreOptions,
|
|
328
|
-
) {
|
|
329
|
-
this.stateRoot = stateRoot;
|
|
330
|
-
this.#database = database;
|
|
331
|
-
this.#now = options.now ?? Date.now;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
static open(
|
|
335
|
-
unresolved: ResolvedStateRoot,
|
|
336
|
-
options: RunStoreOptions = {},
|
|
337
|
-
): RunStore {
|
|
338
|
-
const stateRoot = prepareStateRoot(unresolved);
|
|
339
|
-
try {
|
|
340
|
-
const existing = lstatSync(stateRoot.databasePath);
|
|
341
|
-
if (existing.isSymbolicLink()) {
|
|
342
|
-
throw new StateRootError("state-component-symlink");
|
|
343
|
-
}
|
|
344
|
-
if (!existing.isFile()) {
|
|
345
|
-
throw new StateRootError("state-component-type");
|
|
346
|
-
}
|
|
347
|
-
} catch (error) {
|
|
348
|
-
if (
|
|
349
|
-
error instanceof StateRootError ||
|
|
350
|
-
(error as NodeJS.ErrnoException).code !== "ENOENT"
|
|
351
|
-
) {
|
|
352
|
-
throw error;
|
|
353
|
-
}
|
|
354
|
-
const descriptor = openSync(stateRoot.databasePath, "ax", 0o600);
|
|
355
|
-
closeSync(descriptor);
|
|
356
|
-
}
|
|
357
|
-
chmodSync(stateRoot.databasePath, 0o600);
|
|
358
|
-
|
|
359
|
-
let database: DatabaseSync | undefined;
|
|
360
|
-
try {
|
|
361
|
-
database = new DatabaseSync(stateRoot.databasePath);
|
|
362
|
-
configureSqlite(database, options.busyTimeoutMs);
|
|
363
|
-
ensureCurrentSchema(database, stateRoot.databasePath);
|
|
364
|
-
} catch (error) {
|
|
365
|
-
try {
|
|
366
|
-
database?.close();
|
|
367
|
-
} catch {
|
|
368
|
-
// The run-store initialization failure remains authoritative.
|
|
369
|
-
}
|
|
370
|
-
if (
|
|
371
|
-
error instanceof RunStoreFormatError ||
|
|
372
|
-
error instanceof RunStoreMigrationError ||
|
|
373
|
-
isSqliteLocked(error) ||
|
|
374
|
-
isStoreUnavailable(error)
|
|
375
|
-
) {
|
|
376
|
-
throw error;
|
|
377
|
-
}
|
|
378
|
-
throw new RunStoreFormatError(stateRoot.databasePath);
|
|
379
|
-
}
|
|
380
|
-
if (!database) throw new RunStoreFormatError(stateRoot.databasePath);
|
|
381
|
-
const store = new RunStore(stateRoot, database, options);
|
|
382
|
-
try {
|
|
383
|
-
store.#secureDatabaseFiles();
|
|
384
|
-
store.recoverExpiredLeases();
|
|
385
|
-
return store;
|
|
386
|
-
} catch (error) {
|
|
387
|
-
try {
|
|
388
|
-
database.close();
|
|
389
|
-
} catch {
|
|
390
|
-
// The permission or lease-recovery failure remains authoritative.
|
|
391
|
-
}
|
|
392
|
-
throw error;
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
#assertOpen(): void {
|
|
397
|
-
if (this.#closed) throw new Error("run-store-closed");
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
#transaction<T>(work: () => T): T {
|
|
401
|
-
this.#assertOpen();
|
|
402
|
-
this.#database.exec("BEGIN IMMEDIATE");
|
|
403
|
-
let value: T;
|
|
404
|
-
try {
|
|
405
|
-
value = work();
|
|
406
|
-
this.#database.exec("COMMIT");
|
|
407
|
-
} catch (error) {
|
|
408
|
-
try {
|
|
409
|
-
this.#database.exec("ROLLBACK");
|
|
410
|
-
} catch {
|
|
411
|
-
// The original operation or commit failure remains authoritative.
|
|
412
|
-
}
|
|
413
|
-
throw error;
|
|
414
|
-
}
|
|
415
|
-
this.#secureDatabaseFiles();
|
|
416
|
-
return value;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
#secureDatabaseFiles(): void {
|
|
420
|
-
for (const file of [
|
|
421
|
-
this.stateRoot.databasePath,
|
|
422
|
-
`${this.stateRoot.databasePath}-wal`,
|
|
423
|
-
`${this.stateRoot.databasePath}-shm`,
|
|
424
|
-
]) {
|
|
425
|
-
try {
|
|
426
|
-
const stat = lstatSync(file);
|
|
427
|
-
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
428
|
-
throw new StateRootError(
|
|
429
|
-
stat.isSymbolicLink()
|
|
430
|
-
? "state-component-symlink"
|
|
431
|
-
: "state-component-type",
|
|
432
|
-
);
|
|
433
|
-
}
|
|
434
|
-
chmodSync(file, 0o600);
|
|
435
|
-
} catch (error) {
|
|
436
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
#runRow(runId: string): RunRow {
|
|
442
|
-
const row = this.#database
|
|
443
|
-
.prepare("SELECT run_id, projection_json FROM runs WHERE run_id = ?")
|
|
444
|
-
.get(runId) as RunRow | undefined;
|
|
445
|
-
if (!row) throw new Error("run-not-found");
|
|
446
|
-
return row;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
#operation(runId: string, operationId: string): OperationRow | undefined {
|
|
450
|
-
return this.#database
|
|
451
|
-
.prepare(
|
|
452
|
-
`SELECT kind, state, outcome_json, lease_token, lease_expires_at
|
|
453
|
-
FROM operations WHERE run_id = ? AND operation_id = ?`,
|
|
454
|
-
)
|
|
455
|
-
.get(runId, operationId) as OperationRow | undefined;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
#operationOutcome(
|
|
459
|
-
runId: string,
|
|
460
|
-
operationId: string,
|
|
461
|
-
kind: string,
|
|
462
|
-
): RunProjection | undefined {
|
|
463
|
-
const row = this.#operation(runId, operationId);
|
|
464
|
-
if (row && row.kind !== kind) throw new Error("operation-id-conflict");
|
|
465
|
-
if (row?.state !== "committed" || row.outcome_json === null)
|
|
466
|
-
return undefined;
|
|
467
|
-
return parseProjection(row.outcome_json);
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
#recordOperation(
|
|
471
|
-
runId: string,
|
|
472
|
-
operationId: string,
|
|
473
|
-
kind: string,
|
|
474
|
-
outcome: RunProjection,
|
|
475
|
-
): void {
|
|
476
|
-
this.#database
|
|
477
|
-
.prepare(
|
|
478
|
-
`INSERT INTO operations(
|
|
479
|
-
run_id, operation_id, kind, state, outcome_json
|
|
480
|
-
) VALUES (?, ?, ?, 'committed', ?)
|
|
481
|
-
ON CONFLICT(run_id, operation_id) DO UPDATE SET
|
|
482
|
-
state = 'committed', outcome_json = excluded.outcome_json,
|
|
483
|
-
lease_token = NULL, lease_expires_at = NULL`,
|
|
484
|
-
)
|
|
485
|
-
.run(runId, operationId, kind, JSON.stringify(outcome));
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
#events(runId: string): RunEvent[] {
|
|
489
|
-
const rows = this.#database
|
|
490
|
-
.prepare(
|
|
491
|
-
`SELECT sequence, type, payload_json, prior_hash, event_hash
|
|
492
|
-
FROM events WHERE run_id = ? ORDER BY sequence`,
|
|
493
|
-
)
|
|
494
|
-
.all(runId) as unknown as EventRow[];
|
|
495
|
-
return rows.map((row) => ({
|
|
496
|
-
sequence: Number(row.sequence),
|
|
497
|
-
type: row.type as RunEvent["type"],
|
|
498
|
-
payload: JSON.parse(row.payload_json) as Record<string, unknown>,
|
|
499
|
-
priorHash: row.prior_hash,
|
|
500
|
-
hash: row.event_hash,
|
|
501
|
-
}));
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
#appendEvent(
|
|
505
|
-
runId: string,
|
|
506
|
-
type: RunEvent["type"],
|
|
507
|
-
payload: Record<string, unknown>,
|
|
508
|
-
): RunProjection {
|
|
509
|
-
const row = this.#runRow(runId);
|
|
510
|
-
const current = parseProjection(row.projection_json);
|
|
511
|
-
const event = createRunEvent(
|
|
512
|
-
current.sequence + 1,
|
|
513
|
-
type,
|
|
514
|
-
payload,
|
|
515
|
-
current.eventHash,
|
|
516
|
-
);
|
|
517
|
-
const projection = reduceRunEvents([...this.#events(runId), event]);
|
|
518
|
-
this.#database
|
|
519
|
-
.prepare(
|
|
520
|
-
`INSERT INTO events(
|
|
521
|
-
run_id, sequence, type, payload_json, prior_hash, event_hash
|
|
522
|
-
) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
523
|
-
)
|
|
524
|
-
.run(
|
|
525
|
-
runId,
|
|
526
|
-
event.sequence,
|
|
527
|
-
event.type,
|
|
528
|
-
JSON.stringify(event.payload),
|
|
529
|
-
event.priorHash,
|
|
530
|
-
event.hash,
|
|
531
|
-
);
|
|
532
|
-
this.#database
|
|
533
|
-
.prepare(
|
|
534
|
-
`UPDATE runs SET state = ?, sequence = ?, event_hash = ?,
|
|
535
|
-
delivery_revision = ?, projection_json = ?, terminal_tombstone = ?
|
|
536
|
-
WHERE run_id = ?`,
|
|
537
|
-
)
|
|
538
|
-
.run(
|
|
539
|
-
projection.state,
|
|
540
|
-
projection.sequence,
|
|
541
|
-
projection.eventHash,
|
|
542
|
-
projection.deliveryRevision ?? null,
|
|
543
|
-
JSON.stringify(projection),
|
|
544
|
-
projection.terminal ?? null,
|
|
545
|
-
runId,
|
|
546
|
-
);
|
|
547
|
-
return projection;
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
startRun(input: StartRunInput): RunProjection {
|
|
551
|
-
requireIdentifier(input.operationId, "operation-id");
|
|
552
|
-
if (input.stage !== "abel-design" && input.stage !== "abel-implement") {
|
|
553
|
-
throw new Error("invalid-run-stage");
|
|
554
|
-
}
|
|
555
|
-
const keys = lookupKeys(input);
|
|
556
|
-
const key = keys[0];
|
|
557
|
-
if (!key) throw new Error("invalid-run-lookup-key");
|
|
558
|
-
return this.#transaction(() => {
|
|
559
|
-
const operationRows = this.#database
|
|
560
|
-
.prepare(
|
|
561
|
-
`SELECT operations.run_id, operations.kind, operations.state,
|
|
562
|
-
operations.outcome_json, runs.lookup_key
|
|
563
|
-
FROM operations
|
|
564
|
-
JOIN runs ON runs.run_id = operations.run_id
|
|
565
|
-
WHERE runs.root_hash = ? AND runs.stage = ?
|
|
566
|
-
AND operations.operation_id = ?`,
|
|
567
|
-
)
|
|
568
|
-
.all(
|
|
569
|
-
this.stateRoot.consumerRootHash,
|
|
570
|
-
input.stage,
|
|
571
|
-
input.operationId,
|
|
572
|
-
) as unknown as Array<{
|
|
573
|
-
run_id: string;
|
|
574
|
-
kind: string;
|
|
575
|
-
state: string;
|
|
576
|
-
outcome_json: string | null;
|
|
577
|
-
lookup_key: string;
|
|
578
|
-
}>;
|
|
579
|
-
if (operationRows.length > 0) {
|
|
580
|
-
const replay = operationRows.find(
|
|
581
|
-
(row) =>
|
|
582
|
-
row.kind === "start" &&
|
|
583
|
-
row.state === "committed" &&
|
|
584
|
-
row.outcome_json !== null &&
|
|
585
|
-
keys.includes(row.lookup_key),
|
|
586
|
-
);
|
|
587
|
-
if (operationRows.length !== 1 || !replay) {
|
|
588
|
-
throw new Error("operation-id-conflict");
|
|
589
|
-
}
|
|
590
|
-
const outcome = parseProjection(replay.outcome_json as string);
|
|
591
|
-
if (outcome.runId !== replay.run_id) {
|
|
592
|
-
throw new Error("operation-outcome-invalid");
|
|
593
|
-
}
|
|
594
|
-
return outcome;
|
|
595
|
-
}
|
|
596
|
-
let existing: RunRow | undefined;
|
|
597
|
-
for (const compatibleKey of keys) {
|
|
598
|
-
const candidate = this.#database
|
|
599
|
-
.prepare(
|
|
600
|
-
`SELECT run_id, projection_json FROM runs
|
|
601
|
-
WHERE root_hash = ? AND stage = ? AND lookup_key = ?`,
|
|
602
|
-
)
|
|
603
|
-
.get(this.stateRoot.consumerRootHash, input.stage, compatibleKey) as
|
|
604
|
-
| RunRow
|
|
605
|
-
| undefined;
|
|
606
|
-
if (candidate && existing && candidate.run_id !== existing.run_id) {
|
|
607
|
-
throw new Error("run-lookup-conflict");
|
|
608
|
-
}
|
|
609
|
-
existing ??= candidate;
|
|
610
|
-
}
|
|
611
|
-
if (existing) {
|
|
612
|
-
const projection = parseProjection(existing.projection_json);
|
|
613
|
-
if (
|
|
614
|
-
input.stage === "abel-design" &&
|
|
615
|
-
["completed", "discarded", "rejected"].includes(projection.state)
|
|
616
|
-
) {
|
|
617
|
-
this.#database
|
|
618
|
-
.prepare("UPDATE runs SET lookup_key = ? WHERE run_id = ?")
|
|
619
|
-
.run(`terminal:${existing.run_id}`, existing.run_id);
|
|
620
|
-
existing = undefined;
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
if (existing) {
|
|
624
|
-
const replay = this.#operationOutcome(
|
|
625
|
-
existing.run_id,
|
|
626
|
-
input.operationId,
|
|
627
|
-
"start",
|
|
628
|
-
);
|
|
629
|
-
if (replay) return replay;
|
|
630
|
-
const projection = parseProjection(existing.projection_json);
|
|
631
|
-
this.#recordOperation(
|
|
632
|
-
existing.run_id,
|
|
633
|
-
input.operationId,
|
|
634
|
-
"start",
|
|
635
|
-
projection,
|
|
636
|
-
);
|
|
637
|
-
return projection;
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
const runId = randomUUID();
|
|
641
|
-
const event = createRunEvent(
|
|
642
|
-
1,
|
|
643
|
-
"run-created",
|
|
644
|
-
{
|
|
645
|
-
runId,
|
|
646
|
-
rootHash: this.stateRoot.consumerRootHash,
|
|
647
|
-
stage: input.stage,
|
|
648
|
-
...(input.change ? { change: input.change } : {}),
|
|
649
|
-
...(input.provisionalKey
|
|
650
|
-
? { provisionalKey: input.provisionalKey }
|
|
651
|
-
: {}),
|
|
652
|
-
},
|
|
653
|
-
ZERO_EVENT_HASH,
|
|
654
|
-
);
|
|
655
|
-
const projection = reduceRunEvents([event]);
|
|
656
|
-
this.#database
|
|
657
|
-
.prepare(
|
|
658
|
-
`INSERT INTO runs(
|
|
659
|
-
run_id, root_hash, stage, lookup_key, change_name,
|
|
660
|
-
provisional_key, state, sequence, event_hash, projection_json
|
|
661
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
662
|
-
)
|
|
663
|
-
.run(
|
|
664
|
-
runId,
|
|
665
|
-
this.stateRoot.consumerRootHash,
|
|
666
|
-
input.stage,
|
|
667
|
-
key,
|
|
668
|
-
input.change ?? null,
|
|
669
|
-
input.provisionalKey ?? null,
|
|
670
|
-
projection.state,
|
|
671
|
-
projection.sequence,
|
|
672
|
-
projection.eventHash,
|
|
673
|
-
JSON.stringify(projection),
|
|
674
|
-
);
|
|
675
|
-
this.#database
|
|
676
|
-
.prepare(
|
|
677
|
-
`INSERT INTO events(
|
|
678
|
-
run_id, sequence, type, payload_json, prior_hash, event_hash
|
|
679
|
-
) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
680
|
-
)
|
|
681
|
-
.run(
|
|
682
|
-
runId,
|
|
683
|
-
event.sequence,
|
|
684
|
-
event.type,
|
|
685
|
-
JSON.stringify(event.payload),
|
|
686
|
-
event.priorHash,
|
|
687
|
-
event.hash,
|
|
688
|
-
);
|
|
689
|
-
this.#recordOperation(runId, input.operationId, "start", projection);
|
|
690
|
-
return projection;
|
|
691
|
-
});
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
bindChange(input: {
|
|
695
|
-
runId: string;
|
|
696
|
-
change: string;
|
|
697
|
-
operationId: string;
|
|
698
|
-
}): RunProjection {
|
|
699
|
-
requireIdentifier(input.operationId, "operation-id");
|
|
700
|
-
if (!CHANGE_NAME.test(input.change)) throw new Error("invalid-change");
|
|
701
|
-
return this.#transaction(() => {
|
|
702
|
-
const replay = this.#operationOutcome(
|
|
703
|
-
input.runId,
|
|
704
|
-
input.operationId,
|
|
705
|
-
"change-bound",
|
|
706
|
-
);
|
|
707
|
-
if (replay) return replay;
|
|
708
|
-
const current = this.status(input.runId);
|
|
709
|
-
if (current.stage !== "abel-design" || !current.provisionalKey) {
|
|
710
|
-
throw new Error("change-binding-not-allowed");
|
|
711
|
-
}
|
|
712
|
-
const conflict = this.#database
|
|
713
|
-
.prepare(
|
|
714
|
-
`SELECT run_id FROM runs
|
|
715
|
-
WHERE root_hash = ? AND stage = ? AND lookup_key = ?`,
|
|
716
|
-
)
|
|
717
|
-
.get(current.rootHash, current.stage, `change:${input.change}`) as
|
|
718
|
-
| { run_id: string }
|
|
719
|
-
| undefined;
|
|
720
|
-
if (conflict && conflict.run_id !== input.runId) {
|
|
721
|
-
throw new Error("change-run-already-exists");
|
|
722
|
-
}
|
|
723
|
-
this.#database
|
|
724
|
-
.prepare(
|
|
725
|
-
`UPDATE runs SET lookup_key = ?, change_name = ?, provisional_key = NULL
|
|
726
|
-
WHERE run_id = ?`,
|
|
727
|
-
)
|
|
728
|
-
.run(`change:${input.change}`, input.change, input.runId);
|
|
729
|
-
const projection = this.#appendEvent(input.runId, "change-bound", {
|
|
730
|
-
change: input.change,
|
|
731
|
-
});
|
|
732
|
-
this.#recordOperation(
|
|
733
|
-
input.runId,
|
|
734
|
-
input.operationId,
|
|
735
|
-
"change-bound",
|
|
736
|
-
projection,
|
|
737
|
-
);
|
|
738
|
-
return projection;
|
|
739
|
-
});
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
#requireDeliveryBinding(input: BindDeliveryInput): void {
|
|
743
|
-
requireIdentifier(input.operationId, "operation-id");
|
|
744
|
-
if (
|
|
745
|
-
(input.gate !== "gate-a" && input.gate !== "gate-b") ||
|
|
746
|
-
!Number.isSafeInteger(input.revision) ||
|
|
747
|
-
input.revision < 1 ||
|
|
748
|
-
!SHA256.test(input.receiptHash) ||
|
|
749
|
-
(input.approvalProof !== undefined &&
|
|
750
|
-
(!Number.isSafeInteger(input.approvalProof.revision) ||
|
|
751
|
-
input.approvalProof.revision < 1 ||
|
|
752
|
-
!SHA256.test(input.approvalProof.contractHash) ||
|
|
753
|
-
!SHA256.test(input.approvalProof.recordHash)))
|
|
754
|
-
) {
|
|
755
|
-
throw new Error("invalid-delivery-binding");
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
#bindDelivery(input: BindDeliveryInput): RunProjection {
|
|
760
|
-
if (input.lease) {
|
|
761
|
-
if (input.lease.runId !== input.runId) throw new Error("lease-fenced");
|
|
762
|
-
this.#assertLease(input.lease);
|
|
763
|
-
}
|
|
764
|
-
const replay = this.#operationOutcome(
|
|
765
|
-
input.runId,
|
|
766
|
-
input.operationId,
|
|
767
|
-
"delivery-bound",
|
|
768
|
-
);
|
|
769
|
-
if (replay) return replay;
|
|
770
|
-
const existing = this.#database
|
|
771
|
-
.prepare(
|
|
772
|
-
`SELECT receipt_hash, approval_revision, contract_hash, record_hash
|
|
773
|
-
FROM delivery_bindings
|
|
774
|
-
WHERE run_id = ? AND gate = ? AND revision = ?`,
|
|
775
|
-
)
|
|
776
|
-
.get(input.runId, input.gate, input.revision) as
|
|
777
|
-
| {
|
|
778
|
-
receipt_hash: string;
|
|
779
|
-
approval_revision: number | null;
|
|
780
|
-
contract_hash: string | null;
|
|
781
|
-
record_hash: string | null;
|
|
782
|
-
}
|
|
783
|
-
| undefined;
|
|
784
|
-
if (existing) {
|
|
785
|
-
if (
|
|
786
|
-
existing.receipt_hash !== input.receiptHash ||
|
|
787
|
-
existing.approval_revision !==
|
|
788
|
-
(input.approvalProof?.revision ?? null) ||
|
|
789
|
-
existing.contract_hash !==
|
|
790
|
-
(input.approvalProof?.contractHash ?? null) ||
|
|
791
|
-
existing.record_hash !== (input.approvalProof?.recordHash ?? null)
|
|
792
|
-
) {
|
|
793
|
-
throw new Error("delivery-revision-conflict");
|
|
794
|
-
}
|
|
795
|
-
const projection = this.status(input.runId);
|
|
796
|
-
this.#recordOperation(
|
|
797
|
-
input.runId,
|
|
798
|
-
input.operationId,
|
|
799
|
-
"delivery-bound",
|
|
800
|
-
projection,
|
|
801
|
-
);
|
|
802
|
-
return projection;
|
|
803
|
-
}
|
|
804
|
-
this.#runRow(input.runId);
|
|
805
|
-
this.#database
|
|
806
|
-
.prepare(
|
|
807
|
-
`INSERT INTO delivery_bindings(
|
|
808
|
-
run_id, gate, revision, receipt_hash, approval_revision,
|
|
809
|
-
contract_hash, record_hash, operation_id
|
|
810
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
811
|
-
)
|
|
812
|
-
.run(
|
|
813
|
-
input.runId,
|
|
814
|
-
input.gate,
|
|
815
|
-
input.revision,
|
|
816
|
-
input.receiptHash,
|
|
817
|
-
input.approvalProof?.revision ?? null,
|
|
818
|
-
input.approvalProof?.contractHash ?? null,
|
|
819
|
-
input.approvalProof?.recordHash ?? null,
|
|
820
|
-
input.operationId,
|
|
821
|
-
);
|
|
822
|
-
const projection = this.#appendEvent(input.runId, "delivery-bound", {
|
|
823
|
-
gate: input.gate,
|
|
824
|
-
revision: input.revision,
|
|
825
|
-
receiptHash: input.receiptHash,
|
|
826
|
-
...(input.approvalProof
|
|
827
|
-
? { approvalProof: structuredClone(input.approvalProof) }
|
|
828
|
-
: {}),
|
|
829
|
-
});
|
|
830
|
-
this.#recordOperation(
|
|
831
|
-
input.runId,
|
|
832
|
-
input.operationId,
|
|
833
|
-
"delivery-bound",
|
|
834
|
-
projection,
|
|
835
|
-
);
|
|
836
|
-
return projection;
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
bindDelivery(input: BindDeliveryInput): RunProjection {
|
|
840
|
-
this.#requireDeliveryBinding(input);
|
|
841
|
-
return this.#transaction(() => this.#bindDelivery(input));
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
bindDeliveryAtomically(
|
|
845
|
-
input: BindDeliveryInput,
|
|
846
|
-
write: (database: DatabaseSync) => void,
|
|
847
|
-
): RunProjection {
|
|
848
|
-
this.#requireDeliveryBinding(input);
|
|
849
|
-
if (typeof write !== "function") {
|
|
850
|
-
throw new Error("delivery-binding-write-invalid");
|
|
851
|
-
}
|
|
852
|
-
return this.#transaction(() => {
|
|
853
|
-
const projection = this.#bindDelivery(input);
|
|
854
|
-
write(this.#database);
|
|
855
|
-
return projection;
|
|
856
|
-
});
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
bindDeliveriesAtomically(
|
|
860
|
-
inputs: readonly BindDeliveryInput[],
|
|
861
|
-
write: (database: DatabaseSync) => void,
|
|
862
|
-
): RunProjection {
|
|
863
|
-
if (inputs.length === 0) throw new Error("delivery-binding-empty");
|
|
864
|
-
for (const input of inputs) this.#requireDeliveryBinding(input);
|
|
865
|
-
const runId = inputs[0]?.runId;
|
|
866
|
-
if (
|
|
867
|
-
!runId ||
|
|
868
|
-
inputs.some(
|
|
869
|
-
(input) =>
|
|
870
|
-
input.runId !== runId ||
|
|
871
|
-
(input.lease?.token ?? null) !== (inputs[0]?.lease?.token ?? null),
|
|
872
|
-
) ||
|
|
873
|
-
typeof write !== "function"
|
|
874
|
-
) {
|
|
875
|
-
throw new Error("delivery-binding-write-invalid");
|
|
876
|
-
}
|
|
877
|
-
return this.#transaction(() => {
|
|
878
|
-
let projection: RunProjection | undefined;
|
|
879
|
-
for (const input of inputs) projection = this.#bindDelivery(input);
|
|
880
|
-
write(this.#database);
|
|
881
|
-
if (!projection) throw new Error("delivery-binding-empty");
|
|
882
|
-
return projection;
|
|
883
|
-
});
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
acquireLease(input: {
|
|
887
|
-
runId: string;
|
|
888
|
-
operationId: string;
|
|
889
|
-
ttlMs: number;
|
|
890
|
-
preempt?: boolean;
|
|
891
|
-
exclusive?: boolean;
|
|
892
|
-
}): OperationLease {
|
|
893
|
-
requireIdentifier(input.operationId, "operation-id");
|
|
894
|
-
if (!Number.isSafeInteger(input.ttlMs) || input.ttlMs < 1) {
|
|
895
|
-
throw new Error("invalid-lease-ttl");
|
|
896
|
-
}
|
|
897
|
-
return this.#transaction(() => {
|
|
898
|
-
this.#runRow(input.runId);
|
|
899
|
-
const now = this.#now();
|
|
900
|
-
const existing = this.#operation(input.runId, input.operationId);
|
|
901
|
-
if (
|
|
902
|
-
existing?.state === "running" &&
|
|
903
|
-
existing.lease_token &&
|
|
904
|
-
existing.lease_expires_at !== null &&
|
|
905
|
-
existing.lease_expires_at >= now
|
|
906
|
-
) {
|
|
907
|
-
if (!input.preempt) {
|
|
908
|
-
if (input.exclusive) throw new Error("operation-already-running");
|
|
909
|
-
return {
|
|
910
|
-
runId: input.runId,
|
|
911
|
-
operationId: input.operationId,
|
|
912
|
-
token: existing.lease_token,
|
|
913
|
-
expiresAt: existing.lease_expires_at,
|
|
914
|
-
};
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
const token = randomUUID();
|
|
918
|
-
const expiresAt = now + input.ttlMs;
|
|
919
|
-
this.#database
|
|
920
|
-
.prepare(
|
|
921
|
-
`INSERT INTO operations(
|
|
922
|
-
run_id, operation_id, kind, state, lease_token, lease_expires_at
|
|
923
|
-
) VALUES (?, ?, 'lease', 'running', ?, ?)
|
|
924
|
-
ON CONFLICT(run_id, operation_id) DO UPDATE SET
|
|
925
|
-
state = 'running', outcome_json = NULL,
|
|
926
|
-
lease_token = excluded.lease_token,
|
|
927
|
-
lease_expires_at = excluded.lease_expires_at`,
|
|
928
|
-
)
|
|
929
|
-
.run(input.runId, input.operationId, token, expiresAt);
|
|
930
|
-
return {
|
|
931
|
-
runId: input.runId,
|
|
932
|
-
operationId: input.operationId,
|
|
933
|
-
token,
|
|
934
|
-
expiresAt,
|
|
935
|
-
};
|
|
936
|
-
});
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
renewLease(lease: OperationLease, ttlMs: number): OperationLease {
|
|
940
|
-
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1)
|
|
941
|
-
throw new Error("invalid-lease-ttl");
|
|
942
|
-
return this.#transaction(() => {
|
|
943
|
-
const existing = this.#operation(lease.runId, lease.operationId);
|
|
944
|
-
if (
|
|
945
|
-
existing?.state !== "running" ||
|
|
946
|
-
existing.lease_token !== lease.token ||
|
|
947
|
-
existing.lease_expires_at === null ||
|
|
948
|
-
existing.lease_expires_at < this.#now()
|
|
949
|
-
) {
|
|
950
|
-
throw new Error("lease-fenced");
|
|
951
|
-
}
|
|
952
|
-
const expiresAt = this.#now() + ttlMs;
|
|
953
|
-
this.#database
|
|
954
|
-
.prepare(
|
|
955
|
-
`UPDATE operations SET lease_expires_at = ?
|
|
956
|
-
WHERE run_id = ? AND operation_id = ? AND lease_token = ?`,
|
|
957
|
-
)
|
|
958
|
-
.run(expiresAt, lease.runId, lease.operationId, lease.token);
|
|
959
|
-
return { ...lease, expiresAt };
|
|
960
|
-
});
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
#assertLease(lease: OperationLease): void {
|
|
964
|
-
const existing = this.#operation(lease.runId, lease.operationId);
|
|
965
|
-
if (
|
|
966
|
-
existing?.state !== "running" ||
|
|
967
|
-
existing.lease_token !== lease.token ||
|
|
968
|
-
existing.lease_expires_at === null ||
|
|
969
|
-
existing.lease_expires_at < this.#now()
|
|
970
|
-
) {
|
|
971
|
-
throw new Error("lease-fenced");
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
assertLease(lease: OperationLease): void {
|
|
976
|
-
this.#transaction(() => this.#assertLease(lease));
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
inspectOperation(
|
|
980
|
-
runId: string,
|
|
981
|
-
operationId: string,
|
|
982
|
-
): OperationLeaseStatus | undefined {
|
|
983
|
-
this.#assertOpen();
|
|
984
|
-
requireIdentifier(operationId, "operation-id");
|
|
985
|
-
this.#runRow(runId);
|
|
986
|
-
let operation = this.#operation(runId, operationId);
|
|
987
|
-
if (!operation) return undefined;
|
|
988
|
-
if (
|
|
989
|
-
operation.state === "running" &&
|
|
990
|
-
operation.lease_expires_at !== null &&
|
|
991
|
-
operation.lease_expires_at < this.#now()
|
|
992
|
-
) {
|
|
993
|
-
this.#transaction(() => {
|
|
994
|
-
this.#database
|
|
995
|
-
.prepare(
|
|
996
|
-
`UPDATE operations
|
|
997
|
-
SET state = 'interrupted', lease_token = NULL,
|
|
998
|
-
lease_expires_at = NULL
|
|
999
|
-
WHERE run_id = ? AND operation_id = ? AND state = 'running'
|
|
1000
|
-
AND lease_expires_at < ?`,
|
|
1001
|
-
)
|
|
1002
|
-
.run(runId, operationId, this.#now());
|
|
1003
|
-
});
|
|
1004
|
-
operation = this.#operation(runId, operationId);
|
|
1005
|
-
if (!operation) throw new Error("operation-recovery-invalid");
|
|
1006
|
-
}
|
|
1007
|
-
if (
|
|
1008
|
-
operation.state !== "running" &&
|
|
1009
|
-
operation.state !== "committed" &&
|
|
1010
|
-
operation.state !== "interrupted"
|
|
1011
|
-
) {
|
|
1012
|
-
throw new Error("operation-state-invalid");
|
|
1013
|
-
}
|
|
1014
|
-
let outcome: Record<string, unknown> | undefined;
|
|
1015
|
-
if (operation.outcome_json !== null) {
|
|
1016
|
-
const parsed = JSON.parse(operation.outcome_json) as unknown;
|
|
1017
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1018
|
-
throw new Error("operation-outcome-invalid");
|
|
1019
|
-
}
|
|
1020
|
-
outcome = parsed as Record<string, unknown>;
|
|
1021
|
-
}
|
|
1022
|
-
return {
|
|
1023
|
-
state: operation.state,
|
|
1024
|
-
...(outcome ? { outcome: structuredClone(outcome) } : {}),
|
|
1025
|
-
...(operation.lease_expires_at === null
|
|
1026
|
-
? {}
|
|
1027
|
-
: { expiresAt: operation.lease_expires_at }),
|
|
1028
|
-
};
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
commitLease(
|
|
1032
|
-
lease: OperationLease,
|
|
1033
|
-
outcome: Record<string, unknown>,
|
|
1034
|
-
): Record<string, unknown> {
|
|
1035
|
-
const serialized = JSON.stringify(outcome);
|
|
1036
|
-
if (!serialized) throw new Error("operation-outcome-invalid");
|
|
1037
|
-
return this.#transaction(() => {
|
|
1038
|
-
this.#assertLease(lease);
|
|
1039
|
-
const result = this.#database
|
|
1040
|
-
.prepare(
|
|
1041
|
-
`UPDATE operations
|
|
1042
|
-
SET state = 'committed', outcome_json = ?,
|
|
1043
|
-
lease_token = NULL, lease_expires_at = NULL
|
|
1044
|
-
WHERE run_id = ? AND operation_id = ? AND state = 'running'
|
|
1045
|
-
AND lease_token = ?`,
|
|
1046
|
-
)
|
|
1047
|
-
.run(serialized, lease.runId, lease.operationId, lease.token);
|
|
1048
|
-
if (Number(result.changes) !== 1) throw new Error("lease-fenced");
|
|
1049
|
-
return structuredClone(outcome);
|
|
1050
|
-
});
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
interruptLease(lease: OperationLease): void {
|
|
1054
|
-
this.#transaction(() => {
|
|
1055
|
-
this.#assertLease(lease);
|
|
1056
|
-
const result = this.#database
|
|
1057
|
-
.prepare(
|
|
1058
|
-
`UPDATE operations
|
|
1059
|
-
SET state = 'interrupted', outcome_json = NULL,
|
|
1060
|
-
lease_token = NULL, lease_expires_at = NULL
|
|
1061
|
-
WHERE run_id = ? AND operation_id = ? AND state = 'running'
|
|
1062
|
-
AND lease_token = ?`,
|
|
1063
|
-
)
|
|
1064
|
-
.run(lease.runId, lease.operationId, lease.token);
|
|
1065
|
-
if (Number(result.changes) !== 1) throw new Error("lease-fenced");
|
|
1066
|
-
});
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
transition(input: {
|
|
1070
|
-
runId: string;
|
|
1071
|
-
to: RunState;
|
|
1072
|
-
operationId: string;
|
|
1073
|
-
code?: string;
|
|
1074
|
-
lease?: OperationLease;
|
|
1075
|
-
}): RunProjection {
|
|
1076
|
-
return this.transitionAtomically(input, () => {});
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
transitionAtomically(
|
|
1080
|
-
input: {
|
|
1081
|
-
runId: string;
|
|
1082
|
-
to: RunState;
|
|
1083
|
-
operationId: string;
|
|
1084
|
-
code?: string;
|
|
1085
|
-
lease?: OperationLease;
|
|
1086
|
-
},
|
|
1087
|
-
write: (database: DatabaseSync) => void,
|
|
1088
|
-
): RunProjection {
|
|
1089
|
-
requireIdentifier(input.operationId, "operation-id");
|
|
1090
|
-
if (typeof write !== "function") {
|
|
1091
|
-
throw new Error("state-transition-write-invalid");
|
|
1092
|
-
}
|
|
1093
|
-
return this.#transaction(() => {
|
|
1094
|
-
if (input.lease) {
|
|
1095
|
-
if (input.lease.runId !== input.runId) throw new Error("lease-fenced");
|
|
1096
|
-
this.#assertLease(input.lease);
|
|
1097
|
-
}
|
|
1098
|
-
const replay = this.#operationOutcome(
|
|
1099
|
-
input.runId,
|
|
1100
|
-
input.operationId,
|
|
1101
|
-
"state-transitioned",
|
|
1102
|
-
);
|
|
1103
|
-
if (replay) return replay;
|
|
1104
|
-
const current = this.status(input.runId);
|
|
1105
|
-
const projection = this.#appendEvent(input.runId, "state-transitioned", {
|
|
1106
|
-
from: current.state,
|
|
1107
|
-
to: input.to,
|
|
1108
|
-
...(input.code ? { code: input.code } : {}),
|
|
1109
|
-
});
|
|
1110
|
-
write(this.#database);
|
|
1111
|
-
this.#recordOperation(
|
|
1112
|
-
input.runId,
|
|
1113
|
-
input.operationId,
|
|
1114
|
-
"state-transitioned",
|
|
1115
|
-
projection,
|
|
1116
|
-
);
|
|
1117
|
-
return projection;
|
|
1118
|
-
});
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
status(runId: string): RunProjection {
|
|
1122
|
-
this.#assertOpen();
|
|
1123
|
-
return parseProjection(this.#runRow(runId).projection_json);
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
listEvents(runId: string): RunEvent[] {
|
|
1127
|
-
this.#assertOpen();
|
|
1128
|
-
this.#runRow(runId);
|
|
1129
|
-
return structuredClone(this.#events(runId));
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
rebuildProjection(runId: string): RunProjection {
|
|
1133
|
-
return reduceRunEvents(this.listEvents(runId));
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
verifyIntegrity(runId: string): RunProjection {
|
|
1137
|
-
const rebuilt = this.rebuildProjection(runId);
|
|
1138
|
-
const stored = this.status(runId);
|
|
1139
|
-
if (JSON.stringify(rebuilt) !== JSON.stringify(stored)) {
|
|
1140
|
-
throw new Error("run-projection-integrity-mismatch");
|
|
1141
|
-
}
|
|
1142
|
-
return rebuilt;
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
recoverExpiredLeases(): number {
|
|
1146
|
-
this.#assertOpen();
|
|
1147
|
-
const expired = this.#database
|
|
1148
|
-
.prepare(
|
|
1149
|
-
`SELECT run_id, operation_id FROM operations
|
|
1150
|
-
WHERE state = 'running' AND lease_expires_at < ?`,
|
|
1151
|
-
)
|
|
1152
|
-
.all(this.#now()) as unknown as Array<{
|
|
1153
|
-
run_id: string;
|
|
1154
|
-
operation_id: string;
|
|
1155
|
-
}>;
|
|
1156
|
-
if (expired.length === 0) return 0;
|
|
1157
|
-
return this.#transaction(() => {
|
|
1158
|
-
for (const row of expired) {
|
|
1159
|
-
this.#database
|
|
1160
|
-
.prepare(
|
|
1161
|
-
`UPDATE operations
|
|
1162
|
-
SET state = 'interrupted', lease_token = NULL,
|
|
1163
|
-
lease_expires_at = NULL
|
|
1164
|
-
WHERE run_id = ? AND operation_id = ? AND state = 'running'`,
|
|
1165
|
-
)
|
|
1166
|
-
.run(row.run_id, row.operation_id);
|
|
1167
|
-
}
|
|
1168
|
-
return expired.length;
|
|
1169
|
-
});
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
close(): void {
|
|
1173
|
-
if (this.#closed) return;
|
|
1174
|
-
this.#database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
1175
|
-
this.#secureDatabaseFiles();
|
|
1176
|
-
this.#database.close();
|
|
1177
|
-
this.#closed = true;
|
|
1178
|
-
}
|
|
1179
|
-
}
|