@davidahmann/mill 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +335 -0
  3. package/dist/audit/repository.d.ts +8 -0
  4. package/dist/audit/repository.d.ts.map +1 -0
  5. package/dist/audit/repository.js +232 -0
  6. package/dist/audit/repository.js.map +1 -0
  7. package/dist/cli-program.d.ts +12 -0
  8. package/dist/cli-program.d.ts.map +1 -0
  9. package/dist/cli-program.js +1020 -0
  10. package/dist/cli-program.js.map +1 -0
  11. package/dist/cli.d.ts +3 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +7 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/config/lock.d.ts +12 -0
  16. package/dist/config/lock.d.ts.map +1 -0
  17. package/dist/config/lock.js +83 -0
  18. package/dist/config/lock.js.map +1 -0
  19. package/dist/contracts/canonical.d.ts +7 -0
  20. package/dist/contracts/canonical.d.ts.map +1 -0
  21. package/dist/contracts/canonical.js +30 -0
  22. package/dist/contracts/canonical.js.map +1 -0
  23. package/dist/contracts/schemas.d.ts +2705 -0
  24. package/dist/contracts/schemas.d.ts.map +1 -0
  25. package/dist/contracts/schemas.js +988 -0
  26. package/dist/contracts/schemas.js.map +1 -0
  27. package/dist/doctor.d.ts +21 -0
  28. package/dist/doctor.d.ts.map +1 -0
  29. package/dist/doctor.js +138 -0
  30. package/dist/doctor.js.map +1 -0
  31. package/dist/errors.d.ts +18 -0
  32. package/dist/errors.d.ts.map +1 -0
  33. package/dist/errors.js +31 -0
  34. package/dist/errors.js.map +1 -0
  35. package/dist/index.d.ts +20 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +20 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/intake/prd.d.ts +14 -0
  40. package/dist/intake/prd.d.ts.map +1 -0
  41. package/dist/intake/prd.js +39 -0
  42. package/dist/intake/prd.js.map +1 -0
  43. package/dist/planning/impact.d.ts +59 -0
  44. package/dist/planning/impact.d.ts.map +1 -0
  45. package/dist/planning/impact.js +400 -0
  46. package/dist/planning/impact.js.map +1 -0
  47. package/dist/planning/specification.d.ts +42 -0
  48. package/dist/planning/specification.d.ts.map +1 -0
  49. package/dist/planning/specification.js +235 -0
  50. package/dist/planning/specification.js.map +1 -0
  51. package/dist/qualification/public-alpha.d.ts +16 -0
  52. package/dist/qualification/public-alpha.d.ts.map +1 -0
  53. package/dist/qualification/public-alpha.js +128 -0
  54. package/dist/qualification/public-alpha.js.map +1 -0
  55. package/dist/recipes/node-typescript-next-web.d.ts +19 -0
  56. package/dist/recipes/node-typescript-next-web.d.ts.map +1 -0
  57. package/dist/recipes/node-typescript-next-web.js +136 -0
  58. package/dist/recipes/node-typescript-next-web.js.map +1 -0
  59. package/dist/repository/integration.d.ts +58 -0
  60. package/dist/repository/integration.d.ts.map +1 -0
  61. package/dist/repository/integration.js +1385 -0
  62. package/dist/repository/integration.js.map +1 -0
  63. package/dist/repository/scan.d.ts +27 -0
  64. package/dist/repository/scan.d.ts.map +1 -0
  65. package/dist/repository/scan.js +388 -0
  66. package/dist/repository/scan.js.map +1 -0
  67. package/dist/result.d.ts +23 -0
  68. package/dist/result.d.ts.map +1 -0
  69. package/dist/result.js +24 -0
  70. package/dist/result.js.map +1 -0
  71. package/dist/runtime/codex.d.ts +28 -0
  72. package/dist/runtime/codex.d.ts.map +1 -0
  73. package/dist/runtime/codex.js +439 -0
  74. package/dist/runtime/codex.js.map +1 -0
  75. package/dist/runtime/context.d.ts +10 -0
  76. package/dist/runtime/context.d.ts.map +1 -0
  77. package/dist/runtime/context.js +151 -0
  78. package/dist/runtime/context.js.map +1 -0
  79. package/dist/runtime/delivery.d.ts +58 -0
  80. package/dist/runtime/delivery.d.ts.map +1 -0
  81. package/dist/runtime/delivery.js +1073 -0
  82. package/dist/runtime/delivery.js.map +1 -0
  83. package/dist/runtime/dependencies.d.ts +19 -0
  84. package/dist/runtime/dependencies.d.ts.map +1 -0
  85. package/dist/runtime/dependencies.js +495 -0
  86. package/dist/runtime/dependencies.js.map +1 -0
  87. package/dist/runtime/github.d.ts +112 -0
  88. package/dist/runtime/github.d.ts.map +1 -0
  89. package/dist/runtime/github.js +510 -0
  90. package/dist/runtime/github.js.map +1 -0
  91. package/dist/runtime/inputs.d.ts +24 -0
  92. package/dist/runtime/inputs.d.ts.map +1 -0
  93. package/dist/runtime/inputs.js +237 -0
  94. package/dist/runtime/inputs.js.map +1 -0
  95. package/dist/runtime/lease.d.ts +11 -0
  96. package/dist/runtime/lease.d.ts.map +1 -0
  97. package/dist/runtime/lease.js +52 -0
  98. package/dist/runtime/lease.js.map +1 -0
  99. package/dist/runtime/lifecycle.d.ts +87 -0
  100. package/dist/runtime/lifecycle.d.ts.map +1 -0
  101. package/dist/runtime/lifecycle.js +1024 -0
  102. package/dist/runtime/lifecycle.js.map +1 -0
  103. package/dist/runtime/process.d.ts +39 -0
  104. package/dist/runtime/process.d.ts.map +1 -0
  105. package/dist/runtime/process.js +304 -0
  106. package/dist/runtime/process.js.map +1 -0
  107. package/dist/runtime/repository.d.ts +36 -0
  108. package/dist/runtime/repository.d.ts.map +1 -0
  109. package/dist/runtime/repository.js +458 -0
  110. package/dist/runtime/repository.js.map +1 -0
  111. package/dist/runtime/state.d.ts +121 -0
  112. package/dist/runtime/state.d.ts.map +1 -0
  113. package/dist/runtime/state.js +1017 -0
  114. package/dist/runtime/state.js.map +1 -0
  115. package/dist/runtime/verifier.d.ts +31 -0
  116. package/dist/runtime/verifier.d.ts.map +1 -0
  117. package/dist/runtime/verifier.js +478 -0
  118. package/dist/runtime/verifier.js.map +1 -0
  119. package/dist/runtime/worker.d.ts +67 -0
  120. package/dist/runtime/worker.d.ts.map +1 -0
  121. package/dist/runtime/worker.js +34 -0
  122. package/dist/runtime/worker.js.map +1 -0
  123. package/dist/security/safe-path.d.ts +3 -0
  124. package/dist/security/safe-path.d.ts.map +1 -0
  125. package/dist/security/safe-path.js +102 -0
  126. package/dist/security/safe-path.js.map +1 -0
  127. package/dist/version.d.ts +4 -0
  128. package/dist/version.d.ts.map +1 -0
  129. package/dist/version.js +4 -0
  130. package/dist/version.js.map +1 -0
  131. package/dist/workflows/founder.d.ts +33 -0
  132. package/dist/workflows/founder.d.ts.map +1 -0
  133. package/dist/workflows/founder.js +422 -0
  134. package/dist/workflows/founder.js.map +1 -0
  135. package/package.json +79 -0
  136. package/recipes/node-typescript-next-web/.github/workflows/ci.yml +34 -0
  137. package/recipes/node-typescript-next-web/.node-version +1 -0
  138. package/recipes/node-typescript-next-web/.tool-versions +1 -0
  139. package/recipes/node-typescript-next-web/AGENTS.md +44 -0
  140. package/recipes/node-typescript-next-web/README.md +16 -0
  141. package/recipes/node-typescript-next-web/WORKFLOW.md +6 -0
  142. package/recipes/node-typescript-next-web/app/api/health/route.ts +5 -0
  143. package/recipes/node-typescript-next-web/app/layout.tsx +19 -0
  144. package/recipes/node-typescript-next-web/app/page.tsx +12 -0
  145. package/recipes/node-typescript-next-web/app/styles.css +21 -0
  146. package/recipes/node-typescript-next-web/docs/development.md +16 -0
  147. package/recipes/node-typescript-next-web/eslint.config.mjs +9 -0
  148. package/recipes/node-typescript-next-web/gitignore.template +5 -0
  149. package/recipes/node-typescript-next-web/next-env.d.ts +7 -0
  150. package/recipes/node-typescript-next-web/next.config.ts +11 -0
  151. package/recipes/node-typescript-next-web/package-lock.json +7540 -0
  152. package/recipes/node-typescript-next-web/package.json +39 -0
  153. package/recipes/node-typescript-next-web/playwright.config.ts +23 -0
  154. package/recipes/node-typescript-next-web/recipe.yaml +71 -0
  155. package/recipes/node-typescript-next-web/scripts/check-package.mjs +3 -0
  156. package/recipes/node-typescript-next-web/src/health.ts +3 -0
  157. package/recipes/node-typescript-next-web/test/browser/home.spec.ts +14 -0
  158. package/recipes/node-typescript-next-web/test/integration/health-route.test.ts +11 -0
  159. package/recipes/node-typescript-next-web/test/unit/health.test.ts +9 -0
  160. package/recipes/node-typescript-next-web/tsconfig.json +33 -0
  161. package/schemas/README.md +64 -0
  162. package/schemas/audit-report.schema.json +59 -0
  163. package/schemas/blueprint.schema.json +34 -0
  164. package/schemas/context-manifest.schema.json +88 -0
  165. package/schemas/delivery-record.schema.json +266 -0
  166. package/schemas/impact-manifest.schema.json +146 -0
  167. package/schemas/managed-repository.schema.json +36 -0
  168. package/schemas/mill-config.schema.json +212 -0
  169. package/schemas/mill-lock.schema.json +96 -0
  170. package/schemas/outcome-plan.schema.json +51 -0
  171. package/schemas/product-contract.schema.json +181 -0
  172. package/schemas/public-alpha-qualification.schema.json +391 -0
  173. package/schemas/recipe-manifest.schema.json +187 -0
  174. package/schemas/release-evidence.schema.json +170 -0
  175. package/schemas/repository-integration-plan.schema.json +156 -0
  176. package/schemas/review-result.schema.json +57 -0
  177. package/schemas/scenario-set.schema.json +84 -0
  178. package/schemas/source-manifest.schema.json +96 -0
  179. package/schemas/specification-proposal.schema.json +406 -0
  180. package/schemas/support-tuple.schema.json +107 -0
  181. package/schemas/task-packet.schema.json +395 -0
  182. package/schemas/validation-evidence.schema.json +125 -0
  183. package/schemas/worker-invocation.schema.json +119 -0
  184. package/schemas/worker-profile.schema.json +53 -0
@@ -0,0 +1,1017 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { access, chmod, copyFile, lstat, mkdir, readdir, rename, rm, writeFile, } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import path from "node:path";
6
+ import { backup, DatabaseSync } from "node:sqlite";
7
+ import { ExitCode, MillError } from "../errors.js";
8
+ import { isWithin } from "../security/safe-path.js";
9
+ import { acquireExclusiveLease } from "./lease.js";
10
+ export function publicRunRecord(run) {
11
+ const publicRun = { ...run };
12
+ delete publicRun.worktreePath;
13
+ delete publicRun.contextJson;
14
+ delete publicRun.controlJson;
15
+ delete publicRun.activeProcessId;
16
+ delete publicRun.activeProcessGroup;
17
+ delete publicRun.activeProcessIdentity;
18
+ delete publicRun.deliveryJson;
19
+ delete publicRun.remoteFeedbackJson;
20
+ return publicRun;
21
+ }
22
+ const terminal = new Set(["closed", "cancelled", "failed", "stale"]);
23
+ export function isTerminalRun(status) {
24
+ return terminal.has(status);
25
+ }
26
+ export function isPurgeSafeRun(status) {
27
+ return status === "reviewed" || isTerminalRun(status);
28
+ }
29
+ const transitions = {
30
+ approved: ["ready", "blocked", "cancelled", "failed"],
31
+ ready: ["running", "blocked", "cancelled", "failed", "stale"],
32
+ running: ["committed", "blocked", "cancelled", "failed", "stale"],
33
+ committed: ["verified", "blocked", "cancelled", "failed", "stale"],
34
+ verified: ["reviewed", "blocked", "cancelled", "failed", "stale"],
35
+ reviewed: ["proposing", "cancelled", "failed", "stale"],
36
+ proposing: [
37
+ "effect_unknown",
38
+ "awaiting_ci",
39
+ "blocked",
40
+ "cancelled",
41
+ "failed",
42
+ "stale",
43
+ ],
44
+ effect_unknown: [
45
+ "proposing",
46
+ "awaiting_ci",
47
+ "blocked",
48
+ "cancelled",
49
+ "failed",
50
+ "stale",
51
+ ],
52
+ awaiting_ci: [
53
+ "awaiting_human",
54
+ "blocked",
55
+ "effect_unknown",
56
+ "cancelled",
57
+ "failed",
58
+ "stale",
59
+ ],
60
+ awaiting_human: ["merged", "blocked", "failed", "stale"],
61
+ merged: ["post_merge_verified", "blocked", "failed", "stale"],
62
+ post_merge_verified: ["closed", "blocked", "failed", "stale"],
63
+ closed: [],
64
+ blocked: [
65
+ "ready",
66
+ "running",
67
+ "committed",
68
+ "verified",
69
+ "proposing",
70
+ "awaiting_ci",
71
+ "awaiting_human",
72
+ "merged",
73
+ "post_merge_verified",
74
+ "cancelled",
75
+ "failed",
76
+ "stale",
77
+ ],
78
+ cancelled: [],
79
+ failed: [],
80
+ stale: [],
81
+ };
82
+ function stateRoot() {
83
+ const configured = process.env.MILL_STATE_HOME;
84
+ if (configured !== undefined) {
85
+ if (!path.isAbsolute(configured)) {
86
+ throw new MillError("INVALID_STATE_HOME", "MILL_STATE_HOME must be absolute.", ExitCode.configuration);
87
+ }
88
+ return configured;
89
+ }
90
+ if (process.platform === "darwin") {
91
+ return path.join(homedir(), "Library", "Application Support", "mill");
92
+ }
93
+ const xdg = process.env.XDG_STATE_HOME;
94
+ return xdg !== undefined && path.isAbsolute(xdg)
95
+ ? path.join(xdg, "mill")
96
+ : path.join(homedir(), ".local", "state", "mill");
97
+ }
98
+ function namespace(repositoryId, commonDirectory) {
99
+ return createHash("sha256")
100
+ .update(`${repositoryId}\0${path.resolve(commonDirectory)}`, "utf8")
101
+ .digest("hex");
102
+ }
103
+ export function repositoryStateDirectory(repositoryId, commonDirectory) {
104
+ return path.join(stateRoot(), "repositories", namespace(repositoryId, commonDirectory));
105
+ }
106
+ function fromRow(row) {
107
+ return {
108
+ id: row.id,
109
+ repositoryId: row.repository_id,
110
+ taskId: row.task_id,
111
+ taskDigest: row.task_digest,
112
+ configDigest: row.config_digest,
113
+ status: row.status,
114
+ baseCommit: row.base_commit,
115
+ ...(row.worktree_path === null ? {} : { worktreePath: row.worktree_path }),
116
+ ...(row.context_digest === null
117
+ ? {}
118
+ : { contextDigest: row.context_digest }),
119
+ ...(row.context_json === null ? {} : { contextJson: row.context_json }),
120
+ ...(row.control_json === null ? {} : { controlJson: row.control_json }),
121
+ ...(row.candidate_commit === null
122
+ ? {}
123
+ : { candidateCommit: row.candidate_commit }),
124
+ ...(row.candidate_tree === null
125
+ ? {}
126
+ : { candidateTree: row.candidate_tree }),
127
+ deadlineAt: row.deadline_at,
128
+ ...(row.active_process_id === null
129
+ ? {}
130
+ : { activeProcessId: row.active_process_id }),
131
+ ...(row.active_pid === null ? {} : { activePid: row.active_pid }),
132
+ ...(row.active_process_group === null
133
+ ? {}
134
+ : { activeProcessGroup: row.active_process_group }),
135
+ ...(row.active_process_identity === null
136
+ ? {}
137
+ : { activeProcessIdentity: row.active_process_identity }),
138
+ cancelRequested: row.cancel_requested === 1,
139
+ repairCount: row.repair_count,
140
+ attemptCount: row.attempt_count,
141
+ ...(row.block_code === null ? {} : { blockCode: row.block_code }),
142
+ ...(row.validation_json === null
143
+ ? {}
144
+ : { validationJson: row.validation_json }),
145
+ ...(row.review_json === null ? {} : { reviewJson: row.review_json }),
146
+ ...(row.delivery_json === null ? {} : { deliveryJson: row.delivery_json }),
147
+ ...(row.remote_feedback_json === null
148
+ ? {}
149
+ : { remoteFeedbackJson: row.remote_feedback_json }),
150
+ createdAt: row.created_at,
151
+ updatedAt: row.updated_at,
152
+ };
153
+ }
154
+ export class StateStore {
155
+ directory;
156
+ databasePath;
157
+ worktreesDirectory;
158
+ #database;
159
+ #closed = false;
160
+ constructor(directory, database) {
161
+ this.directory = directory;
162
+ this.databasePath = path.join(directory, "state.sqlite3");
163
+ this.worktreesDirectory = path.join(directory, "worktrees");
164
+ this.#database = database;
165
+ }
166
+ static async open(repositoryId, commonDirectory) {
167
+ const directory = repositoryStateDirectory(repositoryId, commonDirectory);
168
+ await mkdir(path.join(directory, "worktrees"), {
169
+ recursive: true,
170
+ mode: 0o700,
171
+ });
172
+ await chmod(directory, 0o700);
173
+ await chmod(path.join(directory, "worktrees"), 0o700);
174
+ const databasePath = path.join(directory, "state.sqlite3");
175
+ const database = new DatabaseSync(databasePath, {
176
+ timeout: 5_000,
177
+ allowExtension: false,
178
+ enableDoubleQuotedStringLiterals: false,
179
+ });
180
+ database.exec(`
181
+ PRAGMA journal_mode = WAL;
182
+ PRAGMA synchronous = FULL;
183
+ PRAGMA foreign_keys = ON;
184
+ PRAGMA trusted_schema = OFF;
185
+ CREATE TABLE IF NOT EXISTS metadata (
186
+ key TEXT PRIMARY KEY,
187
+ value TEXT NOT NULL
188
+ ) STRICT;
189
+ INSERT OR IGNORE INTO metadata(key, value) VALUES ('schema_version', '2');
190
+ CREATE TABLE IF NOT EXISTS runs (
191
+ id TEXT PRIMARY KEY,
192
+ repository_id TEXT NOT NULL,
193
+ task_id TEXT NOT NULL,
194
+ task_digest TEXT NOT NULL,
195
+ config_digest TEXT NOT NULL,
196
+ status TEXT NOT NULL,
197
+ base_commit TEXT NOT NULL,
198
+ worktree_path TEXT,
199
+ context_digest TEXT,
200
+ context_json TEXT,
201
+ control_json TEXT,
202
+ candidate_commit TEXT,
203
+ candidate_tree TEXT,
204
+ deadline_at TEXT NOT NULL,
205
+ active_process_id TEXT,
206
+ active_pid INTEGER,
207
+ active_process_group INTEGER,
208
+ active_process_identity TEXT,
209
+ cancel_requested INTEGER NOT NULL DEFAULT 0 CHECK(cancel_requested IN (0, 1)),
210
+ repair_count INTEGER NOT NULL DEFAULT 0 CHECK(repair_count BETWEEN 0 AND 1),
211
+ attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count BETWEEN 0 AND 2),
212
+ block_code TEXT,
213
+ validation_json TEXT,
214
+ review_json TEXT,
215
+ delivery_json TEXT,
216
+ remote_feedback_json TEXT,
217
+ created_at TEXT NOT NULL,
218
+ updated_at TEXT NOT NULL
219
+ ) STRICT;
220
+ CREATE TABLE IF NOT EXISTS run_events (
221
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
222
+ run_id TEXT NOT NULL REFERENCES runs(id),
223
+ occurred_at TEXT NOT NULL,
224
+ type TEXT NOT NULL,
225
+ data_json TEXT NOT NULL
226
+ ) STRICT;
227
+ CREATE TABLE IF NOT EXISTS baseline_qualifications (
228
+ approval_digest TEXT PRIMARY KEY,
229
+ repository_id TEXT NOT NULL,
230
+ task_digest TEXT NOT NULL,
231
+ config_digest TEXT NOT NULL,
232
+ base_commit TEXT NOT NULL,
233
+ evidence_digest TEXT NOT NULL,
234
+ created_at TEXT NOT NULL
235
+ ) STRICT;
236
+ CREATE TABLE IF NOT EXISTS worker_invocations (
237
+ id TEXT PRIMARY KEY,
238
+ run_id TEXT NOT NULL REFERENCES runs(id),
239
+ phase TEXT NOT NULL,
240
+ envelope_digest TEXT NOT NULL,
241
+ envelope_json TEXT NOT NULL,
242
+ created_at TEXT NOT NULL
243
+ ) STRICT;
244
+ CREATE TABLE IF NOT EXISTS worker_invocation_events (
245
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
246
+ invocation_id TEXT NOT NULL REFERENCES worker_invocations(id),
247
+ occurred_at TEXT NOT NULL,
248
+ type TEXT NOT NULL,
249
+ data_json TEXT NOT NULL
250
+ ) STRICT;
251
+ CREATE TRIGGER IF NOT EXISTS run_events_no_update
252
+ BEFORE UPDATE ON run_events BEGIN SELECT RAISE(ABORT, 'run events are append-only'); END;
253
+ CREATE TRIGGER IF NOT EXISTS run_events_no_delete
254
+ BEFORE DELETE ON run_events BEGIN SELECT RAISE(ABORT, 'run events are append-only'); END;
255
+ CREATE TRIGGER IF NOT EXISTS worker_invocations_no_update
256
+ BEFORE UPDATE ON worker_invocations BEGIN SELECT RAISE(ABORT, 'worker invocations are immutable'); END;
257
+ CREATE TRIGGER IF NOT EXISTS worker_invocations_no_delete
258
+ BEFORE DELETE ON worker_invocations BEGIN SELECT RAISE(ABORT, 'worker invocations are immutable'); END;
259
+ CREATE TRIGGER IF NOT EXISTS worker_invocation_events_no_update
260
+ BEFORE UPDATE ON worker_invocation_events BEGIN SELECT RAISE(ABORT, 'worker invocation events are append-only'); END;
261
+ CREATE TRIGGER IF NOT EXISTS worker_invocation_events_no_delete
262
+ BEFORE DELETE ON worker_invocation_events BEGIN SELECT RAISE(ABORT, 'worker invocation events are append-only'); END;
263
+ `);
264
+ const runColumns = database
265
+ .prepare("PRAGMA table_info(runs)")
266
+ .all();
267
+ for (const column of [
268
+ "active_process_id TEXT",
269
+ "active_process_group INTEGER",
270
+ "active_process_identity TEXT",
271
+ "delivery_json TEXT",
272
+ "remote_feedback_json TEXT",
273
+ ]) {
274
+ const name = column.split(" ")[0];
275
+ if (!runColumns.some((candidate) => candidate.name === name)) {
276
+ database.exec(`ALTER TABLE runs ADD COLUMN ${column}`);
277
+ }
278
+ }
279
+ const version = database
280
+ .prepare("SELECT value FROM metadata WHERE key = 'schema_version'")
281
+ .get();
282
+ if (version?.value !== "1" && version?.value !== "2") {
283
+ database.close();
284
+ throw new MillError("UNSUPPORTED_STATE_SCHEMA", "Operational state uses an unsupported schema version.", ExitCode.configuration);
285
+ }
286
+ if (version.value === "1") {
287
+ database
288
+ .prepare("UPDATE metadata SET value = '2' WHERE key = 'schema_version'")
289
+ .run();
290
+ }
291
+ await chmod(databasePath, 0o600);
292
+ return new StateStore(directory, database);
293
+ }
294
+ close() {
295
+ if (!this.#closed) {
296
+ this.#database.close();
297
+ this.#closed = true;
298
+ }
299
+ }
300
+ createRun(input) {
301
+ const id = randomUUID();
302
+ const now = new Date().toISOString();
303
+ this.#transaction(() => {
304
+ this.#database
305
+ .prepare(`INSERT INTO runs(
306
+ id, repository_id, task_id, task_digest, config_digest, status,
307
+ base_commit, deadline_at, created_at, updated_at
308
+ ) VALUES (?, ?, ?, ?, ?, 'approved', ?, ?, ?, ?)`)
309
+ .run(id, input.repositoryId, input.taskId, input.taskDigest, input.configDigest, input.baseCommit, input.deadlineAt, now, now);
310
+ this.#event(id, "run.created", { status: "approved" });
311
+ });
312
+ return this.getRun(id);
313
+ }
314
+ getRun(id) {
315
+ const row = this.#database
316
+ .prepare("SELECT * FROM runs WHERE id = ?")
317
+ .get(id);
318
+ if (row === undefined) {
319
+ throw new MillError("RUN_NOT_FOUND", `Run not found: ${id}`, ExitCode.data);
320
+ }
321
+ return fromRow(row);
322
+ }
323
+ latestRun() {
324
+ const row = this.#database
325
+ .prepare("SELECT * FROM runs ORDER BY created_at DESC LIMIT 1")
326
+ .get();
327
+ return row === undefined ? undefined : fromRow(row);
328
+ }
329
+ runs() {
330
+ return this.#database
331
+ .prepare("SELECT * FROM runs ORDER BY created_at")
332
+ .all().map(fromRow);
333
+ }
334
+ transition(id, to, eventType, details = {}) {
335
+ this.#transaction(() => {
336
+ const current = this.getRun(id);
337
+ if (!transitions[current.status].includes(to)) {
338
+ throw new MillError("INVALID_RUN_TRANSITION", `Cannot transition run from ${current.status} to ${to}.`, ExitCode.configuration);
339
+ }
340
+ if (to === "running" && current.cancelRequested) {
341
+ throw new MillError("OPERATOR_CANCELLED", "A cancelled run cannot start or resume execution.", ExitCode.temporary);
342
+ }
343
+ if (to === "cancelled" && current.activeProcessId !== undefined) {
344
+ throw new MillError("CANCELLATION_IN_PROGRESS", "The active execution must exit or be reconciled before cancellation is terminal.", ExitCode.temporary);
345
+ }
346
+ const now = new Date().toISOString();
347
+ this.#database
348
+ .prepare("UPDATE runs SET status = ?, block_code = ?, updated_at = ? WHERE id = ?")
349
+ .run(to, typeof details.code === "string" ? details.code : null, now, id);
350
+ this.#event(id, eventType, { from: current.status, to, ...details });
351
+ });
352
+ return this.getRun(id);
353
+ }
354
+ setWorkspace(id, worktreePath, contextDigest, contextJson, controlJson) {
355
+ this.#transaction(() => {
356
+ const current = this.getRun(id);
357
+ if (current.status !== "ready" ||
358
+ current.worktreePath !== undefined ||
359
+ current.contextDigest !== undefined ||
360
+ current.contextJson !== undefined ||
361
+ current.controlJson !== undefined) {
362
+ throw new MillError("INVALID_RUN_TRANSITION", "A workspace can be bound exactly once while the run is ready.", ExitCode.configuration);
363
+ }
364
+ this.#database
365
+ .prepare("UPDATE runs SET worktree_path = ?, context_digest = ?, context_json = ?, control_json = ?, updated_at = ? WHERE id = ?")
366
+ .run(worktreePath, contextDigest, contextJson, controlJson, new Date().toISOString(), id);
367
+ this.#event(id, "workspace.created", { contextDigest });
368
+ });
369
+ }
370
+ commitCandidate(id, commit, tree, invocationId) {
371
+ this.#transaction(() => {
372
+ const current = this.getRun(id);
373
+ if (current.cancelRequested) {
374
+ throw new MillError("OPERATOR_CANCELLED", "A cancelled run cannot publish a candidate commit.", ExitCode.temporary);
375
+ }
376
+ if (current.status !== "running") {
377
+ throw new MillError("INVALID_RUN_TRANSITION", `Cannot commit a candidate from ${current.status}.`, ExitCode.configuration);
378
+ }
379
+ if (invocationId !== undefined) {
380
+ const invocation = this.#database
381
+ .prepare("SELECT run_id, phase FROM worker_invocations WHERE id = ?")
382
+ .get(invocationId);
383
+ if (invocation?.run_id !== id ||
384
+ (invocation.phase !== "build" && invocation.phase !== "repair") ||
385
+ this.workerInvocationStatus(invocationId) !== "launch_started") {
386
+ throw new MillError("WORKER_INVOCATION_SETTLEMENT_CONFLICT", "Candidate publication requires its one started mutating worker invocation.", ExitCode.configuration);
387
+ }
388
+ }
389
+ this.#database
390
+ .prepare(`UPDATE runs SET candidate_commit = ?, candidate_tree = ?,
391
+ validation_json = NULL, review_json = NULL,
392
+ remote_feedback_json = NULL, status = 'committed',
393
+ block_code = NULL, updated_at = ? WHERE id = ?`)
394
+ .run(commit, tree, new Date().toISOString(), id);
395
+ this.#event(id, "candidate.committed", {
396
+ from: current.status,
397
+ to: "committed",
398
+ commit,
399
+ tree,
400
+ });
401
+ if (invocationId !== undefined) {
402
+ this.#invocationEvent(invocationId, "settled", {
403
+ outcome: "completed",
404
+ candidateCommit: commit,
405
+ candidateTree: tree,
406
+ });
407
+ }
408
+ });
409
+ return this.getRun(id);
410
+ }
411
+ completeValidation(id, value, passed) {
412
+ this.#transaction(() => {
413
+ const current = this.getRun(id);
414
+ if (current.cancelRequested) {
415
+ throw new MillError("OPERATOR_CANCELLED", "A cancelled run cannot publish validation evidence.", ExitCode.temporary);
416
+ }
417
+ if (current.status !== "committed") {
418
+ throw new MillError("INVALID_RUN_TRANSITION", `Cannot record validation from ${current.status}.`, ExitCode.configuration);
419
+ }
420
+ const status = passed ? "verified" : "blocked";
421
+ const code = passed ? null : "VALIDATION_FAILED";
422
+ this.#database
423
+ .prepare("UPDATE runs SET validation_json = ?, status = ?, block_code = ?, updated_at = ? WHERE id = ?")
424
+ .run(value, status, code, new Date().toISOString(), id);
425
+ this.#event(id, passed ? "validation.passed" : "validation.failed", {
426
+ from: current.status,
427
+ to: status,
428
+ ...(code === null ? {} : { code }),
429
+ });
430
+ });
431
+ return this.getRun(id);
432
+ }
433
+ completeReview(id, value, findings, nonConverged, invocationId, completionDetails = {}) {
434
+ this.#transaction(() => {
435
+ const current = this.getRun(id);
436
+ if (current.cancelRequested) {
437
+ throw new MillError("OPERATOR_CANCELLED", "A cancelled run cannot publish review evidence.", ExitCode.temporary);
438
+ }
439
+ if (current.status !== "verified") {
440
+ throw new MillError("INVALID_RUN_TRANSITION", `Cannot record review from ${current.status}.`, ExitCode.configuration);
441
+ }
442
+ const invocation = this.#database
443
+ .prepare("SELECT run_id, phase FROM worker_invocations WHERE id = ?")
444
+ .get(invocationId);
445
+ if (invocation?.run_id !== id ||
446
+ invocation.phase !== "review" ||
447
+ this.workerInvocationStatus(invocationId) !== "launch_started") {
448
+ throw new MillError("WORKER_INVOCATION_SETTLEMENT_CONFLICT", "Review publication requires its one started reviewer invocation.", ExitCode.configuration);
449
+ }
450
+ const status = findings === 0 ? "reviewed" : "blocked";
451
+ const code = findings === 0
452
+ ? null
453
+ : nonConverged
454
+ ? "REVIEW_NON_CONVERGENCE"
455
+ : "REVIEW_FINDINGS";
456
+ this.#database
457
+ .prepare("UPDATE runs SET review_json = ?, status = ?, block_code = ?, updated_at = ? WHERE id = ?")
458
+ .run(value, status, code, new Date().toISOString(), id);
459
+ this.#event(id, "review.completed", {
460
+ ...completionDetails,
461
+ candidateCommit: current.candidateCommit ?? null,
462
+ findings,
463
+ });
464
+ this.#event(id, findings === 0 ? "review.passed" : "review.blocked", {
465
+ from: current.status,
466
+ to: status,
467
+ findings,
468
+ ...(code === null ? {} : { code }),
469
+ });
470
+ this.#invocationEvent(invocationId, "settled", {
471
+ outcome: "completed",
472
+ candidateCommit: current.candidateCommit ?? null,
473
+ findings,
474
+ });
475
+ });
476
+ return this.getRun(id);
477
+ }
478
+ setDelivery(id, deliveryJson, eventType, details = {}) {
479
+ this.#transaction(() => {
480
+ this.getRun(id);
481
+ this.#database
482
+ .prepare("UPDATE runs SET delivery_json = ?, updated_at = ? WHERE id = ?")
483
+ .run(deliveryJson, new Date().toISOString(), id);
484
+ this.#event(id, eventType, details);
485
+ });
486
+ return this.getRun(id);
487
+ }
488
+ setRemoteFeedback(id, feedbackJson) {
489
+ this.#transaction(() => {
490
+ this.getRun(id);
491
+ this.#database
492
+ .prepare("UPDATE runs SET remote_feedback_json = ?, updated_at = ? WHERE id = ?")
493
+ .run(feedbackJson, new Date().toISOString(), id);
494
+ this.#event(id, "remote.feedback_recorded", {});
495
+ });
496
+ return this.getRun(id);
497
+ }
498
+ replaceBlocker(id, code, eventType, details = {}) {
499
+ this.#transaction(() => {
500
+ const current = this.getRun(id);
501
+ if (current.status !== "blocked") {
502
+ throw new MillError("INVALID_RUN_TRANSITION", `Cannot replace a blocker while run is ${current.status}.`, ExitCode.configuration);
503
+ }
504
+ this.#database
505
+ .prepare("UPDATE runs SET block_code = ?, updated_at = ? WHERE id = ?")
506
+ .run(code, new Date().toISOString(), id);
507
+ this.#event(id, eventType, {
508
+ from: current.blockCode ?? null,
509
+ to: code,
510
+ ...details,
511
+ });
512
+ });
513
+ return this.getRun(id);
514
+ }
515
+ beginRepair(id) {
516
+ this.#transaction(() => {
517
+ const current = this.getRun(id);
518
+ if (current.cancelRequested) {
519
+ throw new MillError("OPERATOR_CANCELLED", "A cancelled run cannot begin a repair execution.", ExitCode.temporary);
520
+ }
521
+ if (current.status !== "blocked" || current.repairCount >= 1) {
522
+ throw new MillError("REPAIR_BUDGET_EXHAUSTED", "The single systemic repair budget is exhausted or the run is not blocked.", ExitCode.configuration);
523
+ }
524
+ this.#database
525
+ .prepare("UPDATE runs SET repair_count = repair_count + 1, status = 'running', block_code = NULL, updated_at = ? WHERE id = ?")
526
+ .run(new Date().toISOString(), id);
527
+ this.#event(id, "repair.started", {
528
+ from: current.status,
529
+ to: "running",
530
+ repairCount: current.repairCount + 1,
531
+ });
532
+ });
533
+ return this.getRun(id);
534
+ }
535
+ beginReviewAttempt(id, maximum) {
536
+ let attempt = 0;
537
+ this.#transaction(() => {
538
+ const current = this.getRun(id);
539
+ if (current.cancelRequested) {
540
+ throw new MillError("OPERATOR_CANCELLED", "A cancelled run cannot begin a review attempt.", ExitCode.temporary);
541
+ }
542
+ if (current.status !== "verified") {
543
+ throw new MillError("INVALID_RUN_TRANSITION", `Cannot begin review from ${current.status}.`, ExitCode.configuration);
544
+ }
545
+ if (current.candidateCommit === undefined) {
546
+ throw new MillError("CANDIDATE_MISSING", "A review attempt requires an exact committed candidate.", ExitCode.configuration);
547
+ }
548
+ const row = this.#database
549
+ .prepare(`SELECT COUNT(*) AS count FROM run_events
550
+ WHERE run_id = ? AND type = 'review.started'
551
+ AND json_extract(data_json, '$.candidateCommit') = ?`)
552
+ .get(id, current.candidateCommit);
553
+ if (row.count >= maximum) {
554
+ throw new MillError("REVIEW_RETRY_BUDGET_EXHAUSTED", "The bounded review attempt budget is exhausted.", ExitCode.configuration);
555
+ }
556
+ attempt = row.count + 1;
557
+ this.#event(id, "review.started", {
558
+ candidateCommit: current.candidateCommit,
559
+ attempt,
560
+ });
561
+ });
562
+ return attempt;
563
+ }
564
+ recordBaselineQualification(input) {
565
+ this.#transaction(() => {
566
+ this.#database
567
+ .prepare(`INSERT OR IGNORE INTO baseline_qualifications(
568
+ approval_digest, repository_id, task_digest, config_digest,
569
+ base_commit, evidence_digest, created_at
570
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`)
571
+ .run(input.approvalDigest, input.repositoryId, input.taskDigest, input.configDigest, input.baseCommit, input.evidenceDigest, new Date().toISOString());
572
+ const stored = this.#database
573
+ .prepare(`SELECT repository_id, task_digest, config_digest, base_commit,
574
+ evidence_digest
575
+ FROM baseline_qualifications WHERE approval_digest = ?`)
576
+ .get(input.approvalDigest);
577
+ if (stored?.repository_id !== input.repositoryId ||
578
+ stored.task_digest !== input.taskDigest ||
579
+ stored.config_digest !== input.configDigest ||
580
+ stored.base_commit !== input.baseCommit ||
581
+ stored.evidence_digest !== input.evidenceDigest) {
582
+ throw new MillError("QUALIFICATION_IDENTITY_COLLISION", "Baseline qualification identity conflicts with stored state.", ExitCode.io);
583
+ }
584
+ });
585
+ }
586
+ hasBaselineQualification(input) {
587
+ const row = this.#database
588
+ .prepare(`SELECT 1 AS present FROM baseline_qualifications
589
+ WHERE approval_digest = ? AND repository_id = ? AND task_digest = ?
590
+ AND config_digest = ? AND base_commit = ?`)
591
+ .get(input.approvalDigest, input.repositoryId, input.taskDigest, input.configDigest, input.baseCommit);
592
+ return row?.present === 1;
593
+ }
594
+ beginBuilderAttempt(id, maximum) {
595
+ if (this.getRun(id).cancelRequested) {
596
+ throw new MillError("OPERATOR_CANCELLED", "A cancelled run cannot consume another builder attempt.", ExitCode.temporary);
597
+ }
598
+ const result = this.#database
599
+ .prepare(`UPDATE runs SET attempt_count = attempt_count + 1, updated_at = ?
600
+ WHERE id = ? AND attempt_count < ? AND cancel_requested = 0`)
601
+ .run(new Date().toISOString(), id, maximum);
602
+ if (result.changes !== 1) {
603
+ if (this.getRun(id).cancelRequested) {
604
+ throw new MillError("OPERATOR_CANCELLED", "A cancelled run cannot consume another builder attempt.", ExitCode.temporary);
605
+ }
606
+ throw new MillError("BUILDER_RETRY_BUDGET_EXHAUSTED", "The builder retry budget is exhausted.", ExitCode.configuration);
607
+ }
608
+ }
609
+ setActiveProcess(id, process) {
610
+ if (process !== null &&
611
+ (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(process.id) ||
612
+ !Number.isSafeInteger(process.pid) ||
613
+ process.pid <= 0 ||
614
+ process.processGroup !== process.pid ||
615
+ !/^sha256:[a-f0-9]{64}$/u.test(process.identity))) {
616
+ throw new MillError("INVALID_PROCESS_IDENTITY", "An active PID and its opaque process-start identity must be stored together.", ExitCode.configuration);
617
+ }
618
+ if (process === null) {
619
+ this.#database
620
+ .prepare("UPDATE runs SET active_process_id = NULL, active_pid = NULL, active_process_group = NULL, active_process_identity = NULL, updated_at = ? WHERE id = ?")
621
+ .run(new Date().toISOString(), id);
622
+ return;
623
+ }
624
+ const result = this.#database
625
+ .prepare(`UPDATE runs SET active_process_id = ?, active_pid = ?,
626
+ active_process_group = ?, active_process_identity = ?, updated_at = ?
627
+ WHERE id = ? AND cancel_requested = 0 AND active_process_id IS NULL`)
628
+ .run(process.id, process.pid, process.processGroup, process.identity, new Date().toISOString(), id);
629
+ if (result.changes !== 1) {
630
+ throw new MillError("ACTIVE_PROCESS_BINDING_REJECTED", "Cancellation or another active execution won before process binding.", ExitCode.temporary);
631
+ }
632
+ }
633
+ clearActiveProcess(id, processId) {
634
+ this.#database
635
+ .prepare(`UPDATE runs SET active_process_id = NULL, active_pid = NULL,
636
+ active_process_group = NULL, active_process_identity = NULL,
637
+ updated_at = ? WHERE id = ? AND active_process_id = ?`)
638
+ .run(new Date().toISOString(), id, processId);
639
+ }
640
+ requestCancellation(id) {
641
+ this.#transaction(() => {
642
+ const run = this.getRun(id);
643
+ if (terminal.has(run.status))
644
+ return;
645
+ this.#database
646
+ .prepare("UPDATE runs SET cancel_requested = 1, updated_at = ? WHERE id = ?")
647
+ .run(new Date().toISOString(), id);
648
+ this.#event(id, "run.cancellation_requested", {});
649
+ });
650
+ return this.getRun(id);
651
+ }
652
+ async backup() {
653
+ const destination = path.join(this.directory, `state-backup-${new Date().toISOString().replaceAll(/[:.]/gu, "-")}.sqlite3`);
654
+ await backup(this.#database, destination);
655
+ await chmod(destination, 0o600);
656
+ return destination;
657
+ }
658
+ events(id) {
659
+ return this.#database
660
+ .prepare("SELECT sequence, occurred_at, type, data_json FROM run_events WHERE run_id = ? ORDER BY sequence")
661
+ .all(id)
662
+ .map((row) => {
663
+ const item = row;
664
+ return {
665
+ sequence: item.sequence,
666
+ occurredAt: item.occurred_at,
667
+ type: item.type,
668
+ data: JSON.parse(item.data_json),
669
+ };
670
+ });
671
+ }
672
+ recordEvent(id, type, data) {
673
+ this.#transaction(() => this.#event(id, type, data));
674
+ }
675
+ admitWorkerInvocation(input) {
676
+ let disposition = "existing";
677
+ this.#transaction(() => {
678
+ this.getRun(input.runId);
679
+ const result = this.#database
680
+ .prepare(`INSERT OR IGNORE INTO worker_invocations(
681
+ id, run_id, phase, envelope_digest, envelope_json, created_at
682
+ ) VALUES (?, ?, ?, ?, ?, ?)`)
683
+ .run(input.invocationId, input.runId, input.phase, input.envelopeDigest, input.envelopeJson, new Date().toISOString());
684
+ const stored = this.#database
685
+ .prepare(`SELECT run_id, phase, envelope_digest, envelope_json
686
+ FROM worker_invocations WHERE id = ?`)
687
+ .get(input.invocationId);
688
+ if (stored?.run_id !== input.runId ||
689
+ stored.phase !== input.phase ||
690
+ stored.envelope_digest !== input.envelopeDigest ||
691
+ stored.envelope_json !== input.envelopeJson) {
692
+ throw new MillError("WORKER_INVOCATION_IDENTITY_CONFLICT", "Worker invocation identity was reused with a different immutable envelope.", ExitCode.configuration);
693
+ }
694
+ if (result.changes === 1) {
695
+ disposition = "created";
696
+ this.#invocationEvent(input.invocationId, "admitted", {
697
+ envelopeDigest: input.envelopeDigest,
698
+ });
699
+ this.#event(input.runId, "worker.admitted", {
700
+ invocationId: input.invocationId,
701
+ phase: input.phase,
702
+ envelopeDigest: input.envelopeDigest,
703
+ });
704
+ }
705
+ });
706
+ return disposition;
707
+ }
708
+ workerInvocationStatus(invocationId) {
709
+ const exists = this.#database
710
+ .prepare("SELECT 1 AS present FROM worker_invocations WHERE id = ?")
711
+ .get(invocationId);
712
+ if (exists?.present !== 1) {
713
+ throw new MillError("WORKER_INVOCATION_NOT_FOUND", "Worker invocation admission was not found.", ExitCode.data);
714
+ }
715
+ const events = this.#database
716
+ .prepare(`SELECT type FROM worker_invocation_events
717
+ WHERE invocation_id = ? ORDER BY sequence`)
718
+ .all(invocationId);
719
+ if (events.some((event) => event.type === "reconciled")) {
720
+ return "reconciled";
721
+ }
722
+ if (events.some((event) => event.type === "uncertain"))
723
+ return "uncertain";
724
+ if (events.some((event) => event.type === "settled"))
725
+ return "settled";
726
+ if (events.some((event) => event.type === "launch_started")) {
727
+ return "launch_started";
728
+ }
729
+ return "admitted";
730
+ }
731
+ markWorkerLaunchStarted(invocationId) {
732
+ this.#transaction(() => {
733
+ const status = this.workerInvocationStatus(invocationId);
734
+ if (status !== "admitted") {
735
+ throw new MillError("WORKER_INVOCATION_POSSIBLY_STARTED", "A worker invocation with this identity may already have started and cannot be replayed.", ExitCode.temporary, { status });
736
+ }
737
+ this.#invocationEvent(invocationId, "launch_started", {});
738
+ });
739
+ }
740
+ settleWorkerInvocation(invocationId, outcome, details = {}) {
741
+ this.#transaction(() => {
742
+ const status = this.workerInvocationStatus(invocationId);
743
+ if (status !== "launch_started") {
744
+ throw new MillError("WORKER_INVOCATION_SETTLEMENT_CONFLICT", "Worker settlement requires exactly one started, unsettled invocation.", ExitCode.configuration, { status });
745
+ }
746
+ this.#invocationEvent(invocationId, outcome === "uncertain" ? "uncertain" : "settled", { outcome, ...details });
747
+ });
748
+ }
749
+ recordWorkerProcessExit(runId, invocationId, processId) {
750
+ this.#transaction(() => {
751
+ this.getRun(runId);
752
+ const invocation = this.#database
753
+ .prepare("SELECT run_id FROM worker_invocations WHERE id = ?")
754
+ .get(invocationId);
755
+ if (invocation?.run_id !== runId ||
756
+ this.workerInvocationStatus(invocationId) !== "launch_started") {
757
+ throw new MillError("WORKER_PROCESS_EXIT_CONFLICT", "Process exit evidence requires its one started, unsettled worker invocation.", ExitCode.configuration);
758
+ }
759
+ const active = this.#database
760
+ .prepare("SELECT active_process_id FROM runs WHERE id = ?")
761
+ .get(runId);
762
+ if ((processId !== undefined && active.active_process_id !== processId) ||
763
+ (processId === undefined && active.active_process_id !== null)) {
764
+ throw new MillError("WORKER_PROCESS_EXIT_CONFLICT", "Process exit evidence does not match the active worker identity.", ExitCode.configuration);
765
+ }
766
+ this.#invocationEvent(invocationId, "process_exited", {
767
+ processId: processId ?? null,
768
+ });
769
+ this.#database
770
+ .prepare(`UPDATE runs SET active_process_id = NULL, active_pid = NULL,
771
+ active_process_group = NULL, active_process_identity = NULL,
772
+ updated_at = ? WHERE id = ?`)
773
+ .run(new Date().toISOString(), runId);
774
+ this.#event(runId, "worker.process_exited", { invocationId });
775
+ });
776
+ }
777
+ unresolvedMutatingWorkerInvocations(runId) {
778
+ this.getRun(runId);
779
+ const rows = this.#database
780
+ .prepare(`SELECT id, phase FROM worker_invocations
781
+ WHERE run_id = ? AND phase IN ('build', 'repair')
782
+ ORDER BY created_at, id`)
783
+ .all(runId);
784
+ return rows.flatMap((row) => {
785
+ const status = this.workerInvocationStatus(row.id);
786
+ if (status !== "launch_started" && status !== "uncertain")
787
+ return [];
788
+ const uncertain = this.#database
789
+ .prepare(`SELECT data_json FROM worker_invocation_events
790
+ WHERE invocation_id = ? AND type = 'uncertain'
791
+ ORDER BY sequence DESC LIMIT 1`)
792
+ .get(row.id);
793
+ const observedExit = this.#database
794
+ .prepare(`SELECT 1 AS present FROM worker_invocation_events
795
+ WHERE invocation_id = ? AND type = 'process_exited' LIMIT 1`)
796
+ .get(row.id);
797
+ let processExited = observedExit?.present === 1;
798
+ if (uncertain !== undefined) {
799
+ try {
800
+ const details = JSON.parse(uncertain.data_json);
801
+ processExited = processExited || details.processExited === true;
802
+ }
803
+ catch {
804
+ // Preserve separately journaled process-exit evidence.
805
+ }
806
+ }
807
+ return [
808
+ {
809
+ invocationId: row.id,
810
+ phase: row.phase,
811
+ status,
812
+ processExited,
813
+ },
814
+ ];
815
+ });
816
+ }
817
+ reconcileWorkerInvocation(runId, invocationId, reason) {
818
+ this.#transaction(() => {
819
+ this.getRun(runId);
820
+ const invocation = this.#database
821
+ .prepare(`SELECT run_id, phase FROM worker_invocations WHERE id = ?`)
822
+ .get(invocationId);
823
+ if (invocation?.run_id !== runId ||
824
+ (invocation.phase !== "build" && invocation.phase !== "repair")) {
825
+ throw new MillError("WORKER_INVOCATION_RECONCILIATION_CONFLICT", "Worker invocation is not a mutating admission for this run.", ExitCode.configuration);
826
+ }
827
+ const status = this.workerInvocationStatus(invocationId);
828
+ if (status !== "launch_started" && status !== "uncertain") {
829
+ throw new MillError("WORKER_INVOCATION_RECONCILIATION_CONFLICT", "Only an unresolved mutating worker invocation can be reconciled.", ExitCode.configuration, { status });
830
+ }
831
+ this.#invocationEvent(invocationId, "reconciled", { reason });
832
+ this.#event(runId, "worker.reconciled", {
833
+ invocationId,
834
+ phase: invocation.phase,
835
+ reason,
836
+ });
837
+ });
838
+ }
839
+ #transaction(action) {
840
+ try {
841
+ this.#database.exec("BEGIN IMMEDIATE");
842
+ action();
843
+ this.#database.exec("COMMIT");
844
+ }
845
+ catch (error) {
846
+ try {
847
+ this.#database.exec("ROLLBACK");
848
+ }
849
+ catch {
850
+ // Preserve the original state error.
851
+ }
852
+ if (error instanceof MillError)
853
+ throw error;
854
+ throw new MillError("STATE_WRITE_FAILED", "Operational state transaction failed.", ExitCode.io, { cause: String(error) });
855
+ }
856
+ }
857
+ #event(id, type, data) {
858
+ this.#database
859
+ .prepare("INSERT INTO run_events(run_id, occurred_at, type, data_json) VALUES (?, ?, ?, ?)")
860
+ .run(id, new Date().toISOString(), type, JSON.stringify(data));
861
+ }
862
+ #invocationEvent(invocationId, type, data) {
863
+ this.#database
864
+ .prepare(`INSERT INTO worker_invocation_events(
865
+ invocation_id, occurred_at, type, data_json
866
+ ) VALUES (?, ?, ?, ?)`)
867
+ .run(invocationId, new Date().toISOString(), type, JSON.stringify(data));
868
+ }
869
+ }
870
+ export async function acquireWriterLease(store) {
871
+ const leasePath = path.join(store.directory, "writer-lease.sqlite3");
872
+ return await acquireExclusiveLease({
873
+ path: leasePath,
874
+ activeCode: "WRITER_ALREADY_ACTIVE",
875
+ activeMessage: "Another Mill writer is active for this repository.",
876
+ unavailableCode: "WRITER_LEASE_UNAVAILABLE",
877
+ unavailableMessage: "The repository writer lease could not be acquired safely.",
878
+ });
879
+ }
880
+ export async function restoreStateBackup(repositoryId, commonDirectory, backupPath) {
881
+ const directory = repositoryStateDirectory(repositoryId, commonDirectory);
882
+ const resolvedBackup = path.resolve(backupPath);
883
+ if (!isWithin(directory, resolvedBackup)) {
884
+ throw new MillError("INVALID_STATE_BACKUP", "State backup must be a Mill-owned file in this repository namespace.", ExitCode.configuration);
885
+ }
886
+ const information = await lstat(resolvedBackup);
887
+ if (!information.isFile() || information.isSymbolicLink()) {
888
+ throw new MillError("INVALID_STATE_BACKUP", "State backup is not a regular file.", ExitCode.configuration);
889
+ }
890
+ const databasePath = path.join(directory, "state.sqlite3");
891
+ const temporaryPath = path.join(directory, `restore-${randomUUID()}.sqlite3`);
892
+ const expectedWorktrees = new Set();
893
+ let quarantineManifest;
894
+ const moved = [];
895
+ try {
896
+ await copyFile(resolvedBackup, temporaryPath, constants.COPYFILE_EXCL);
897
+ await chmod(temporaryPath, 0o600);
898
+ let candidate;
899
+ try {
900
+ candidate = new DatabaseSync(temporaryPath, {
901
+ readOnly: true,
902
+ allowExtension: false,
903
+ enableDoubleQuotedStringLiterals: false,
904
+ });
905
+ candidate.exec("PRAGMA trusted_schema = OFF; PRAGMA foreign_keys = ON;");
906
+ const integrity = candidate.prepare("PRAGMA integrity_check").get();
907
+ const version = candidate
908
+ .prepare("SELECT value FROM metadata WHERE key = 'schema_version'")
909
+ .get();
910
+ const requiredObjects = candidate
911
+ .prepare(`SELECT name FROM sqlite_schema
912
+ WHERE (type = 'table' AND name IN ('metadata', 'runs', 'run_events'))
913
+ OR (type = 'table' AND name = 'baseline_qualifications')
914
+ OR (type = 'table' AND name IN ('worker_invocations', 'worker_invocation_events'))
915
+ OR (type = 'trigger' AND name IN (
916
+ 'run_events_no_update', 'run_events_no_delete',
917
+ 'worker_invocations_no_update', 'worker_invocations_no_delete',
918
+ 'worker_invocation_events_no_update', 'worker_invocation_events_no_delete'
919
+ ))`)
920
+ .all();
921
+ const worktrees = candidate
922
+ .prepare("SELECT worktree_path FROM runs WHERE worktree_path IS NOT NULL")
923
+ .all();
924
+ if (integrity?.integrity_check !== "ok" ||
925
+ (version?.value !== "1" && version?.value !== "2") ||
926
+ new Set(requiredObjects.map((object) => object.name)).size !==
927
+ (version.value === "1" ? 6 : 12)) {
928
+ throw new Error("backup integrity, schema version, or objects invalid");
929
+ }
930
+ const worktreesDirectory = path.join(directory, "worktrees");
931
+ for (const row of worktrees) {
932
+ const resolved = path.resolve(row.worktree_path);
933
+ if (!isWithin(worktreesDirectory, resolved)) {
934
+ throw new Error("backup references a worktree outside Mill state");
935
+ }
936
+ expectedWorktrees.add(resolved);
937
+ }
938
+ }
939
+ catch (error) {
940
+ throw new MillError("INVALID_STATE_BACKUP", "State backup failed integrity and schema validation.", ExitCode.data, { cause: String(error) });
941
+ }
942
+ finally {
943
+ candidate?.close();
944
+ }
945
+ const worktreesDirectory = path.join(directory, "worktrees");
946
+ const entries = await readdir(worktreesDirectory, { withFileTypes: true });
947
+ const orphaned = entries
948
+ .map((entry) => ({
949
+ entry,
950
+ original: path.join(worktreesDirectory, entry.name),
951
+ }))
952
+ .filter(({ original }) => !expectedWorktrees.has(path.resolve(original)));
953
+ if (orphaned.length > 0) {
954
+ for (const { entry } of orphaned) {
955
+ if (!entry.isDirectory() || entry.isSymbolicLink()) {
956
+ throw new MillError("UNSAFE_ORPHANED_WORKTREE", "Restore found an unclassified entry in the Mill worktree directory.", ExitCode.configuration, { name: entry.name });
957
+ }
958
+ }
959
+ const quarantineId = `restore-${new Date()
960
+ .toISOString()
961
+ .replaceAll(/[:.]/gu, "-")}-${randomUUID()}`;
962
+ const quarantineDirectory = path.join(directory, "quarantine", quarantineId);
963
+ await mkdir(quarantineDirectory, { recursive: true, mode: 0o700 });
964
+ await chmod(quarantineDirectory, 0o700);
965
+ quarantineManifest = path.join(quarantineDirectory, "manifest.json");
966
+ const planned = orphaned.map(({ entry, original }) => ({
967
+ original,
968
+ quarantined: path.join(quarantineDirectory, entry.name),
969
+ }));
970
+ await writeFile(quarantineManifest, `${JSON.stringify({
971
+ schemaVersion: "1",
972
+ repositoryId,
973
+ backupPath: resolvedBackup,
974
+ protocol: "database_swap_commit_point",
975
+ worktrees: planned,
976
+ }, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
977
+ for (const item of planned) {
978
+ await rename(item.original, item.quarantined);
979
+ moved.push(item);
980
+ }
981
+ }
982
+ await rm(`${databasePath}-wal`, { force: true });
983
+ await rm(`${databasePath}-shm`, { force: true });
984
+ await rename(temporaryPath, databasePath);
985
+ return {
986
+ quarantinedCount: moved.length,
987
+ ...(quarantineManifest === undefined ? {} : { quarantineManifest }),
988
+ };
989
+ }
990
+ catch (error) {
991
+ for (const item of moved.toReversed()) {
992
+ try {
993
+ await rename(item.quarantined, item.original);
994
+ }
995
+ catch {
996
+ // The recovery manifest preserves attended recovery evidence.
997
+ }
998
+ }
999
+ throw error;
1000
+ }
1001
+ finally {
1002
+ await rm(temporaryPath, { force: true });
1003
+ }
1004
+ }
1005
+ export async function purgeRepositoryState(repositoryId, commonDirectory) {
1006
+ const directory = repositoryStateDirectory(repositoryId, commonDirectory);
1007
+ try {
1008
+ await access(directory);
1009
+ }
1010
+ catch (error) {
1011
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
1012
+ return;
1013
+ throw error;
1014
+ }
1015
+ await rm(directory, { recursive: true });
1016
+ }
1017
+ //# sourceMappingURL=state.js.map