codex-orchestrator 0.1.47 → 0.1.48

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 (156) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/src/config/schema.d.ts +5 -1
  3. package/dist/src/config/schema.d.ts.map +1 -1
  4. package/dist/src/config/schema.js +16 -0
  5. package/dist/src/config/schema.js.map +1 -1
  6. package/dist/src/runner/command-utils.d.ts.map +1 -1
  7. package/dist/src/runner/command-utils.js +1 -0
  8. package/dist/src/runner/command-utils.js.map +1 -1
  9. package/dist/src/runner/local-execution-session.d.ts.map +1 -1
  10. package/dist/src/runner/local-execution-session.js +5 -2
  11. package/dist/src/runner/local-execution-session.js.map +1 -1
  12. package/dist/src/runner/mission-activation.d.ts +29 -0
  13. package/dist/src/runner/mission-activation.d.ts.map +1 -0
  14. package/dist/src/runner/mission-activation.js +54 -0
  15. package/dist/src/runner/mission-activation.js.map +1 -0
  16. package/dist/src/runner/mission-agent-broker.d.ts +53 -0
  17. package/dist/src/runner/mission-agent-broker.d.ts.map +1 -0
  18. package/dist/src/runner/mission-agent-broker.js +182 -0
  19. package/dist/src/runner/mission-agent-broker.js.map +1 -0
  20. package/dist/src/runner/mission-cancellation.d.ts +77 -0
  21. package/dist/src/runner/mission-cancellation.d.ts.map +1 -0
  22. package/dist/src/runner/mission-cancellation.js +180 -0
  23. package/dist/src/runner/mission-cancellation.js.map +1 -0
  24. package/dist/src/runner/mission-canonical-path.d.ts +34 -0
  25. package/dist/src/runner/mission-canonical-path.d.ts.map +1 -0
  26. package/dist/src/runner/mission-canonical-path.js +121 -0
  27. package/dist/src/runner/mission-canonical-path.js.map +1 -0
  28. package/dist/src/runner/mission-capability-kernel.d.ts +34 -0
  29. package/dist/src/runner/mission-capability-kernel.d.ts.map +1 -0
  30. package/dist/src/runner/mission-capability-kernel.js +158 -0
  31. package/dist/src/runner/mission-capability-kernel.js.map +1 -0
  32. package/dist/src/runner/mission-check-policy.d.ts +17 -0
  33. package/dist/src/runner/mission-check-policy.d.ts.map +1 -0
  34. package/dist/src/runner/mission-check-policy.js +31 -0
  35. package/dist/src/runner/mission-check-policy.js.map +1 -0
  36. package/dist/src/runner/mission-coordinator-lock.d.ts +16 -0
  37. package/dist/src/runner/mission-coordinator-lock.d.ts.map +1 -0
  38. package/dist/src/runner/mission-coordinator-lock.js +170 -0
  39. package/dist/src/runner/mission-coordinator-lock.js.map +1 -0
  40. package/dist/src/runner/mission-deployment.d.ts +21 -0
  41. package/dist/src/runner/mission-deployment.d.ts.map +1 -0
  42. package/dist/src/runner/mission-deployment.js +108 -0
  43. package/dist/src/runner/mission-deployment.js.map +1 -0
  44. package/dist/src/runner/mission-evaluation.d.ts +61 -0
  45. package/dist/src/runner/mission-evaluation.d.ts.map +1 -0
  46. package/dist/src/runner/mission-evaluation.js +183 -0
  47. package/dist/src/runner/mission-evaluation.js.map +1 -0
  48. package/dist/src/runner/mission-executor-probe.d.ts +10 -0
  49. package/dist/src/runner/mission-executor-probe.d.ts.map +1 -0
  50. package/dist/src/runner/mission-executor-probe.js +180 -0
  51. package/dist/src/runner/mission-executor-probe.js.map +1 -0
  52. package/dist/src/runner/mission-git-contracts.d.ts +75 -0
  53. package/dist/src/runner/mission-git-contracts.d.ts.map +1 -0
  54. package/dist/src/runner/mission-git-contracts.js +201 -0
  55. package/dist/src/runner/mission-git-contracts.js.map +1 -0
  56. package/dist/src/runner/mission-git-runtime-owner-ref.d.ts +13 -0
  57. package/dist/src/runner/mission-git-runtime-owner-ref.d.ts.map +1 -0
  58. package/dist/src/runner/mission-git-runtime-owner-ref.js +111 -0
  59. package/dist/src/runner/mission-git-runtime-owner-ref.js.map +1 -0
  60. package/dist/src/runner/mission-git-transaction.d.ts +97 -0
  61. package/dist/src/runner/mission-git-transaction.d.ts.map +1 -0
  62. package/dist/src/runner/mission-git-transaction.js +1009 -0
  63. package/dist/src/runner/mission-git-transaction.js.map +1 -0
  64. package/dist/src/runner/mission-identifiers.d.ts +31 -0
  65. package/dist/src/runner/mission-identifiers.d.ts.map +1 -0
  66. package/dist/src/runner/mission-identifiers.js +75 -0
  67. package/dist/src/runner/mission-identifiers.js.map +1 -0
  68. package/dist/src/runner/mission-input-snapshot.d.ts +21 -0
  69. package/dist/src/runner/mission-input-snapshot.d.ts.map +1 -0
  70. package/dist/src/runner/mission-input-snapshot.js +63 -0
  71. package/dist/src/runner/mission-input-snapshot.js.map +1 -0
  72. package/dist/src/runner/mission-ownership.d.ts +38 -0
  73. package/dist/src/runner/mission-ownership.d.ts.map +1 -0
  74. package/dist/src/runner/mission-ownership.js +88 -0
  75. package/dist/src/runner/mission-ownership.js.map +1 -0
  76. package/dist/src/runner/mission-patch-audit.d.ts +21 -0
  77. package/dist/src/runner/mission-patch-audit.d.ts.map +1 -0
  78. package/dist/src/runner/mission-patch-audit.js +186 -0
  79. package/dist/src/runner/mission-patch-audit.js.map +1 -0
  80. package/dist/src/runner/mission-path-language.d.ts +6 -0
  81. package/dist/src/runner/mission-path-language.d.ts.map +1 -0
  82. package/dist/src/runner/mission-path-language.js +106 -0
  83. package/dist/src/runner/mission-path-language.js.map +1 -0
  84. package/dist/src/runner/mission-permit-authority.d.ts +26 -0
  85. package/dist/src/runner/mission-permit-authority.d.ts.map +1 -0
  86. package/dist/src/runner/mission-permit-authority.js +145 -0
  87. package/dist/src/runner/mission-permit-authority.js.map +1 -0
  88. package/dist/src/runner/mission-plan-parent-coordinator.d.ts +87 -0
  89. package/dist/src/runner/mission-plan-parent-coordinator.d.ts.map +1 -0
  90. package/dist/src/runner/mission-plan-parent-coordinator.js +412 -0
  91. package/dist/src/runner/mission-plan-parent-coordinator.js.map +1 -0
  92. package/dist/src/runner/mission-plan-parent.d.ts +154 -0
  93. package/dist/src/runner/mission-plan-parent.d.ts.map +1 -0
  94. package/dist/src/runner/mission-plan-parent.js +583 -0
  95. package/dist/src/runner/mission-plan-parent.js.map +1 -0
  96. package/dist/src/runner/mission-process-executor.d.ts +25 -0
  97. package/dist/src/runner/mission-process-executor.d.ts.map +1 -0
  98. package/dist/src/runner/mission-process-executor.js +182 -0
  99. package/dist/src/runner/mission-process-executor.js.map +1 -0
  100. package/dist/src/runner/mission-quarantine-patch-executor.d.ts +29 -0
  101. package/dist/src/runner/mission-quarantine-patch-executor.d.ts.map +1 -0
  102. package/dist/src/runner/mission-quarantine-patch-executor.js +121 -0
  103. package/dist/src/runner/mission-quarantine-patch-executor.js.map +1 -0
  104. package/dist/src/runner/mission-repository-executor.d.ts +54 -0
  105. package/dist/src/runner/mission-repository-executor.d.ts.map +1 -0
  106. package/dist/src/runner/mission-repository-executor.js +192 -0
  107. package/dist/src/runner/mission-repository-executor.js.map +1 -0
  108. package/dist/src/runner/mission-repository-observer.d.ts +8 -0
  109. package/dist/src/runner/mission-repository-observer.d.ts.map +1 -0
  110. package/dist/src/runner/mission-repository-observer.js +24 -0
  111. package/dist/src/runner/mission-repository-observer.js.map +1 -0
  112. package/dist/src/runner/mission-reservations.d.ts +15 -0
  113. package/dist/src/runner/mission-reservations.d.ts.map +1 -0
  114. package/dist/src/runner/mission-reservations.js +116 -0
  115. package/dist/src/runner/mission-reservations.js.map +1 -0
  116. package/dist/src/runner/mission-resolution-loop.d.ts +172 -0
  117. package/dist/src/runner/mission-resolution-loop.d.ts.map +1 -0
  118. package/dist/src/runner/mission-resolution-loop.js +318 -0
  119. package/dist/src/runner/mission-resolution-loop.js.map +1 -0
  120. package/dist/src/runner/mission-runtime-owner.d.ts +36 -0
  121. package/dist/src/runner/mission-runtime-owner.d.ts.map +1 -0
  122. package/dist/src/runner/mission-runtime-owner.js +174 -0
  123. package/dist/src/runner/mission-runtime-owner.js.map +1 -0
  124. package/dist/src/runner/mission-safe-executor.d.ts +96 -0
  125. package/dist/src/runner/mission-safe-executor.d.ts.map +1 -0
  126. package/dist/src/runner/mission-safe-executor.js +288 -0
  127. package/dist/src/runner/mission-safe-executor.js.map +1 -0
  128. package/dist/src/runner/mission-sandbox.d.ts +16 -0
  129. package/dist/src/runner/mission-sandbox.d.ts.map +1 -0
  130. package/dist/src/runner/mission-sandbox.js +91 -0
  131. package/dist/src/runner/mission-sandbox.js.map +1 -0
  132. package/dist/src/runner/mission-scheduler.d.ts +57 -0
  133. package/dist/src/runner/mission-scheduler.d.ts.map +1 -0
  134. package/dist/src/runner/mission-scheduler.js +205 -0
  135. package/dist/src/runner/mission-scheduler.js.map +1 -0
  136. package/dist/src/runner/mission-scope-expansion.d.ts +47 -0
  137. package/dist/src/runner/mission-scope-expansion.d.ts.map +1 -0
  138. package/dist/src/runner/mission-scope-expansion.js +147 -0
  139. package/dist/src/runner/mission-scope-expansion.js.map +1 -0
  140. package/dist/src/runner/mission-state-machine.d.ts +118 -0
  141. package/dist/src/runner/mission-state-machine.d.ts.map +1 -0
  142. package/dist/src/runner/mission-state-machine.js +316 -0
  143. package/dist/src/runner/mission-state-machine.js.map +1 -0
  144. package/dist/src/runner/mission-state-store.d.ts +70 -0
  145. package/dist/src/runner/mission-state-store.d.ts.map +1 -0
  146. package/dist/src/runner/mission-state-store.js +856 -0
  147. package/dist/src/runner/mission-state-store.js.map +1 -0
  148. package/dist/src/runner/rework-policy.d.ts +2 -1
  149. package/dist/src/runner/rework-policy.d.ts.map +1 -1
  150. package/dist/src/runner/rework-policy.js +3 -0
  151. package/dist/src/runner/rework-policy.js.map +1 -1
  152. package/dist/src/setup/project-config.d.ts.map +1 -1
  153. package/dist/src/setup/project-config.js +5 -0
  154. package/dist/src/setup/project-config.js.map +1 -1
  155. package/docs/deep-dive.md +25 -16
  156. package/package.json +1 -1
@@ -0,0 +1,856 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { link, lstat, mkdir, open, readFile, readdir, realpath, rename, unlink, } from 'node:fs/promises';
4
+ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path';
5
+ import { hostname } from 'node:os';
6
+ import { acquireMissionCoordinatorLock } from './mission-coordinator-lock.js';
7
+ import { authorizeMissionCapability } from './mission-capability-kernel.js';
8
+ import { assertMissionApplyIntent, assertMissionApplyReceipt, missionApplyPermitFingerprint, validateMissionApplyPermit, } from './mission-git-contracts.js';
9
+ import { missionStates, safeResumeTargets, terminalMissionStates, } from './mission-state-machine.js';
10
+ import { assertPlanParentRecord, planParentScheduleKey, } from './mission-plan-parent.js';
11
+ const mutationTails = new Map();
12
+ const processBootNonce = randomUUID();
13
+ export class MissionStateStore {
14
+ targetRoot;
15
+ stateDir;
16
+ maxStateBytes;
17
+ atomicOperations;
18
+ constructor(targetRoot, stateDir, options = {}) {
19
+ this.targetRoot = targetRoot;
20
+ this.stateDir = stateDir;
21
+ const normalizedStateDir = normalize(stateDir);
22
+ if (stateDir.length === 0 || isAbsolute(stateDir) || normalizedStateDir === '..'
23
+ || normalizedStateDir.startsWith(`..${sep}`)) {
24
+ throw new Error('Mission stateDir must stay inside the target root.');
25
+ }
26
+ this.maxStateBytes = options.maxStateBytes ?? 16 * 1024 * 1024;
27
+ this.atomicOperations = {
28
+ syncFile: options.atomicOperations?.syncFile ?? ((file) => file.sync()),
29
+ rename: options.atomicOperations?.rename ?? ((source, destination) => rename(source, destination)),
30
+ syncDirectory: options.atomicOperations?.syncDirectory ?? ((directory) => directory.sync()),
31
+ };
32
+ if (!Number.isInteger(this.maxStateBytes) || this.maxStateBytes <= 0) {
33
+ throw new Error('Mission state maxStateBytes must be a positive integer.');
34
+ }
35
+ }
36
+ statePath() {
37
+ return join(this.targetRoot, this.stateDir, 'mission-state-v1.json');
38
+ }
39
+ stateDirectory() {
40
+ return dirname(this.statePath());
41
+ }
42
+ async temporaryDirectory(name) {
43
+ if (!/^[a-z0-9-]+$/u.test(name))
44
+ throw new Error('Mission temporary directory name is invalid.');
45
+ const directory = join(this.stateDirectory(), name);
46
+ await this.ensureDurableDirectory(directory, 0);
47
+ return directory;
48
+ }
49
+ async load() {
50
+ await this.assertExistingStatePathSafe();
51
+ try {
52
+ const content = await readFile(this.statePath(), 'utf8');
53
+ const parsed = JSON.parse(content);
54
+ assertMissionStateSnapshot(parsed);
55
+ const expectedChecksum = checksumFor(parsed);
56
+ if (parsed.checksum !== expectedChecksum) {
57
+ throw new Error(`Invalid Mission state checksum: expected ${expectedChecksum}, received ${parsed.checksum}`);
58
+ }
59
+ return parsed;
60
+ }
61
+ catch (error) {
62
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
63
+ return createSnapshot(0, emptyDraft());
64
+ }
65
+ throw error;
66
+ }
67
+ }
68
+ async mutate(expectedGeneration, reducer) {
69
+ return this.exclusive((session) => session.mutate(expectedGeneration, reducer));
70
+ }
71
+ async exclusive(operation) {
72
+ return withMutationLock(this.statePath(), async () => {
73
+ const coordinator = await this.acquireCoordinator();
74
+ try {
75
+ return await operation({
76
+ load: () => this.load(),
77
+ mutate: (expectedGeneration, reducer) => this.commitMutation(expectedGeneration, reducer),
78
+ });
79
+ }
80
+ finally {
81
+ await coordinator.release();
82
+ }
83
+ });
84
+ }
85
+ async mutateWithBlobs(expectedGeneration, contents, reducer) {
86
+ return withMutationLock(this.statePath(), async () => {
87
+ const coordinator = await this.acquireCoordinator();
88
+ try {
89
+ const references = [];
90
+ for (const content of contents) {
91
+ references.push(await this.putBlob(content, expectedGeneration + 1));
92
+ }
93
+ return await this.commitMutation(expectedGeneration, (draft) => reducer(draft, references));
94
+ }
95
+ finally {
96
+ await coordinator.release();
97
+ }
98
+ });
99
+ }
100
+ async putBlob(content, generation) {
101
+ const bytes = Buffer.from(content);
102
+ const reference = {
103
+ sha256: createHash('sha256').update(bytes).digest('hex'),
104
+ size: bytes.byteLength,
105
+ };
106
+ const directory = join(this.targetRoot, this.stateDir, 'mission-blobs');
107
+ const path = join(directory, reference.sha256);
108
+ await this.ensureDurableDirectory(directory, generation);
109
+ const tempPath = join(directory, `.${reference.sha256}.${process.pid}.${randomUUID()}.tmp`);
110
+ const file = await open(tempPath, 'wx', 0o600);
111
+ try {
112
+ await file.writeFile(bytes);
113
+ await file.sync();
114
+ }
115
+ finally {
116
+ await file.close();
117
+ }
118
+ try {
119
+ await link(tempPath, path);
120
+ }
121
+ catch (error) {
122
+ if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) {
123
+ throw error;
124
+ }
125
+ }
126
+ finally {
127
+ await unlink(tempPath).catch(() => undefined);
128
+ }
129
+ const directoryHandle = await open(directory, 'r');
130
+ try {
131
+ await directoryHandle.sync();
132
+ }
133
+ finally {
134
+ await directoryHandle.close();
135
+ }
136
+ await this.readBlob(reference);
137
+ return reference;
138
+ }
139
+ async ensureDurableDirectory(directory, generation) {
140
+ const root = resolve(this.targetRoot);
141
+ const resolvedDirectory = resolve(directory);
142
+ if (resolvedDirectory !== root && !resolvedDirectory.startsWith(`${root}${sep}`)) {
143
+ throw new Error('Mission state directory is outside the target root.');
144
+ }
145
+ await this.ensureNoFollowDirectories(root, resolvedDirectory, true);
146
+ for (let current = resolvedDirectory === root ? root : dirname(resolvedDirectory);; current = dirname(current)) {
147
+ const handle = await open(current, 'r');
148
+ try {
149
+ await this.atomicOperations.syncDirectory(handle, generation);
150
+ }
151
+ finally {
152
+ await handle.close();
153
+ }
154
+ if (current === root)
155
+ break;
156
+ if (current === dirname(current)) {
157
+ throw new Error('Mission state directory is outside the target root.');
158
+ }
159
+ }
160
+ }
161
+ async ensureNoFollowDirectories(root, directory, create) {
162
+ const rootStat = await lstat(root);
163
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
164
+ throw new Error('Mission target root must be a directory.');
165
+ }
166
+ const canonicalRoot = await realpath(root);
167
+ let current = root;
168
+ for (const segment of relative(root, directory).split(sep).filter(Boolean)) {
169
+ current = join(current, segment);
170
+ try {
171
+ const existing = await lstat(current);
172
+ if (!existing.isDirectory() || existing.isSymbolicLink()) {
173
+ throw new Error(`Mission state path component is not a direct directory: ${current}.`);
174
+ }
175
+ }
176
+ catch (error) {
177
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT'))
178
+ throw error;
179
+ if (!create)
180
+ return false;
181
+ try {
182
+ await mkdir(current, { mode: 0o700 });
183
+ }
184
+ catch (mkdirError) {
185
+ if (!(mkdirError instanceof Error && 'code' in mkdirError && mkdirError.code === 'EEXIST')) {
186
+ throw mkdirError;
187
+ }
188
+ }
189
+ const created = await lstat(current);
190
+ if (!created.isDirectory() || created.isSymbolicLink()) {
191
+ throw new Error(`Mission state path component is not a direct directory: ${current}.`);
192
+ }
193
+ }
194
+ }
195
+ const expectedCanonicalDirectory = join(canonicalRoot, relative(root, directory));
196
+ if (await realpath(directory) !== expectedCanonicalDirectory) {
197
+ throw new Error('Mission state directory resolves through a symlink.');
198
+ }
199
+ return true;
200
+ }
201
+ async assertExistingStatePathSafe() {
202
+ const root = resolve(this.targetRoot);
203
+ const directory = this.stateDirectory();
204
+ await this.ensureNoFollowDirectories(root, directory, false);
205
+ }
206
+ async readBlob(reference) {
207
+ if (!/^[a-f0-9]{64}$/u.test(reference.sha256)
208
+ || !Number.isSafeInteger(reference.size) || reference.size < 0) {
209
+ throw new Error('Invalid Mission blob reference.');
210
+ }
211
+ await this.assertExistingStatePathSafe();
212
+ await this.ensureNoFollowDirectories(resolve(this.targetRoot), join(this.stateDirectory(), 'mission-blobs'), false);
213
+ const path = join(this.targetRoot, this.stateDir, 'mission-blobs', reference.sha256);
214
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
215
+ let content;
216
+ try {
217
+ const before = await file.stat({ bigint: true });
218
+ if (!before.isFile() || before.nlink !== 1n || before.size !== BigInt(reference.size)) {
219
+ throw new Error(`Invalid Mission blob ${reference.sha256}: unsafe file identity.`);
220
+ }
221
+ content = await file.readFile();
222
+ const after = await file.stat({ bigint: true });
223
+ if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size
224
+ || before.mtimeNs !== after.mtimeNs || after.nlink !== 1n) {
225
+ throw new Error(`Invalid Mission blob ${reference.sha256}: file changed during read.`);
226
+ }
227
+ }
228
+ finally {
229
+ await file.close();
230
+ }
231
+ const actualHash = createHash('sha256').update(content).digest('hex');
232
+ if (content.byteLength !== reference.size || actualHash !== reference.sha256) {
233
+ throw new Error(`Invalid Mission blob ${reference.sha256}: content does not match reference.`);
234
+ }
235
+ return content;
236
+ }
237
+ async pruneUnreferencedBlobs() {
238
+ return withMutationLock(this.statePath(), async () => {
239
+ const coordinator = await this.acquireCoordinator();
240
+ try {
241
+ const snapshot = await this.load();
242
+ const directory = join(this.targetRoot, this.stateDir, 'mission-blobs');
243
+ let entries;
244
+ try {
245
+ entries = await readdir(directory, { withFileTypes: true });
246
+ }
247
+ catch (error) {
248
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
249
+ return [];
250
+ }
251
+ throw error;
252
+ }
253
+ const removed = [];
254
+ for (const entry of entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0)) {
255
+ if (!entry.isFile() || !/^[a-f0-9]{64}$/u.test(entry.name) || snapshot.blobs[entry.name]) {
256
+ continue;
257
+ }
258
+ await unlink(join(directory, entry.name));
259
+ removed.push(entry.name);
260
+ }
261
+ if (removed.length > 0) {
262
+ const directoryHandle = await open(directory, 'r');
263
+ try {
264
+ await directoryHandle.sync();
265
+ }
266
+ finally {
267
+ await directoryHandle.close();
268
+ }
269
+ }
270
+ return removed;
271
+ }
272
+ finally {
273
+ await coordinator.release();
274
+ }
275
+ });
276
+ }
277
+ async acquireCoordinator() {
278
+ await this.ensureDurableDirectory(this.stateDirectory(), 0);
279
+ return acquireMissionCoordinatorLock({
280
+ targetRoot: this.targetRoot,
281
+ stateDir: this.stateDir,
282
+ hostId: hostname(),
283
+ bootNonce: processBootNonce,
284
+ waitTimeoutMs: 5_000,
285
+ });
286
+ }
287
+ async commitMutation(expectedGeneration, reducer) {
288
+ const current = await this.load();
289
+ if (current.generation !== expectedGeneration) {
290
+ throw new Error(`Mission state generation conflict: expected ${expectedGeneration}, current ${current.generation}`);
291
+ }
292
+ const draft = cloneDraft(current);
293
+ reducer(draft);
294
+ const next = createSnapshot(current.generation + 1, draft);
295
+ assertMissionStateSnapshot(next);
296
+ for (const reference of Object.values(next.blobs)) {
297
+ await this.readBlob(reference);
298
+ }
299
+ await this.atomicWrite(next);
300
+ return next;
301
+ }
302
+ async atomicWrite(snapshot) {
303
+ const path = this.statePath();
304
+ const directory = dirname(path);
305
+ const serialized = `${canonicalJson(snapshot)}\n`;
306
+ const size = Buffer.byteLength(serialized, 'utf8');
307
+ if (size > this.maxStateBytes) {
308
+ throw new Error(`Mission state size limit exceeded: ${size} > ${this.maxStateBytes} bytes.`);
309
+ }
310
+ await this.ensureDurableDirectory(directory, snapshot.generation);
311
+ const tempPath = join(directory, `.mission-state-v1.${process.pid}.${randomUUID()}.tmp`);
312
+ let tempCreated = false;
313
+ try {
314
+ const file = await open(tempPath, 'wx', 0o600);
315
+ tempCreated = true;
316
+ try {
317
+ await file.writeFile(serialized, 'utf8');
318
+ await this.atomicOperations.syncFile(file, snapshot.generation);
319
+ }
320
+ finally {
321
+ await file.close();
322
+ }
323
+ await this.atomicOperations.rename(tempPath, path, snapshot.generation);
324
+ tempCreated = false;
325
+ const directoryHandle = await open(directory, 'r');
326
+ try {
327
+ await this.atomicOperations.syncDirectory(directoryHandle, snapshot.generation);
328
+ }
329
+ finally {
330
+ await directoryHandle.close();
331
+ }
332
+ }
333
+ finally {
334
+ if (tempCreated) {
335
+ await unlink(tempPath).catch(() => undefined);
336
+ }
337
+ }
338
+ }
339
+ }
340
+ async function withMutationLock(key, operation) {
341
+ const previous = mutationTails.get(key) ?? Promise.resolve();
342
+ let release;
343
+ const gate = new Promise((resolve) => {
344
+ release = resolve;
345
+ });
346
+ const tail = previous.catch(() => undefined).then(() => gate);
347
+ mutationTails.set(key, tail);
348
+ await previous.catch(() => undefined);
349
+ try {
350
+ return await operation();
351
+ }
352
+ finally {
353
+ release();
354
+ void tail.finally(() => {
355
+ if (mutationTails.get(key) === tail) {
356
+ mutationTails.delete(key);
357
+ }
358
+ });
359
+ }
360
+ }
361
+ function emptyDraft() {
362
+ return {
363
+ missions: {},
364
+ planParents: {},
365
+ publications: {},
366
+ reservations: {},
367
+ nextEligibleAt: {},
368
+ tombstones: {},
369
+ blobs: {},
370
+ };
371
+ }
372
+ function cloneDraft(snapshot) {
373
+ return structuredClone({
374
+ missions: snapshot.missions,
375
+ planParents: snapshot.planParents,
376
+ publications: snapshot.publications,
377
+ reservations: snapshot.reservations,
378
+ nextEligibleAt: snapshot.nextEligibleAt,
379
+ tombstones: snapshot.tombstones,
380
+ blobs: snapshot.blobs,
381
+ });
382
+ }
383
+ function createSnapshot(generation, draft) {
384
+ const withoutChecksum = {
385
+ version: 1,
386
+ generation,
387
+ ...draft,
388
+ };
389
+ return {
390
+ ...withoutChecksum,
391
+ checksum: checksumFor(withoutChecksum),
392
+ };
393
+ }
394
+ function checksumFor(snapshot) {
395
+ const { checksum: _checksum, ...content } = snapshot;
396
+ return `sha256:${createHash('sha256').update(Buffer.from(canonicalJson(content), 'utf8')).digest('hex')}`;
397
+ }
398
+ function canonicalJson(value) {
399
+ return JSON.stringify(canonicalValue(value));
400
+ }
401
+ function canonicalValue(value) {
402
+ if (Array.isArray(value)) {
403
+ return value.map(canonicalValue);
404
+ }
405
+ if (typeof value === 'object' && value !== null) {
406
+ return Object.fromEntries(Object.entries(value)
407
+ .filter(([, child]) => child !== undefined)
408
+ .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
409
+ .map(([key, child]) => [key, canonicalValue(child)]));
410
+ }
411
+ return value;
412
+ }
413
+ function assertMissionStateSnapshot(value) {
414
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
415
+ throw new Error('Invalid Mission state: root must be an object.');
416
+ }
417
+ const record = value;
418
+ const allowedRootFields = new Set([
419
+ 'version',
420
+ 'generation',
421
+ 'checksum',
422
+ 'missions',
423
+ 'planParents',
424
+ 'publications',
425
+ 'reservations',
426
+ 'nextEligibleAt',
427
+ 'tombstones',
428
+ 'blobs',
429
+ ]);
430
+ for (const key of Object.keys(record)) {
431
+ if (!allowedRootFields.has(key)) {
432
+ throw new Error(`Invalid Mission state: unexpected field ${key}.`);
433
+ }
434
+ }
435
+ if (record.version !== 1) {
436
+ throw new Error('Invalid Mission state: version must be 1.');
437
+ }
438
+ if (!Number.isInteger(record.generation) || record.generation < 0) {
439
+ throw new Error('Invalid Mission state: generation must be a non-negative integer.');
440
+ }
441
+ if (typeof record.checksum !== 'string' || !/^sha256:[a-f0-9]{64}$/u.test(record.checksum)) {
442
+ throw new Error('Invalid Mission state: checksum must be sha256 hex.');
443
+ }
444
+ for (const key of [
445
+ 'missions',
446
+ 'planParents',
447
+ 'publications',
448
+ 'reservations',
449
+ 'nextEligibleAt',
450
+ 'tombstones',
451
+ 'blobs',
452
+ ]) {
453
+ const child = record[key];
454
+ if (typeof child !== 'object' || child === null || Array.isArray(child)) {
455
+ throw new Error(`Invalid Mission state: ${key} must be an object.`);
456
+ }
457
+ }
458
+ for (const [id, mission] of Object.entries(record.missions)) {
459
+ assertMissionRecord(mission, `missions.${id}`);
460
+ if (mission.id !== id) {
461
+ invalid(`missions.${id}.id must equal its map key`);
462
+ }
463
+ if (mission.authorizedPermit && (mission.authorizedPermit.missionId !== id
464
+ || mission.authorizedPermit.actionKey !== mission.actionKey
465
+ || mission.authorizedPermit.inputSnapshot !== mission.inputSnapshot
466
+ || mission.authorizedPermit.fencingEpoch !== mission.fencingEpoch)) {
467
+ invalid(`missions.${id}.authorizedPermit must match the aggregate identity`);
468
+ }
469
+ }
470
+ for (const [id, parent] of Object.entries(record.planParents)) {
471
+ assertPlanParentRecord(parent, `planParents.${id}`);
472
+ if (parent.id !== id)
473
+ invalid(`planParents.${id}.id must equal its map key`);
474
+ }
475
+ for (const collection of ['publications', 'reservations']) {
476
+ for (const [id, aggregate] of Object.entries(record[collection])) {
477
+ assertStoredAggregate(aggregate, `${collection}.${id}`);
478
+ }
479
+ }
480
+ for (const [id, nextEligibleAt] of Object.entries(record.nextEligibleAt)) {
481
+ assertNonEmptyString(nextEligibleAt, `nextEligibleAt.${id}`);
482
+ if (id.startsWith('plan-parent:')) {
483
+ const parentId = id.slice('plan-parent:'.length);
484
+ const parent = record.planParents[parentId];
485
+ const waiting = parent?.state === 'wave-waiting' && parent.nextEligibleAt === nextEligibleAt;
486
+ const claimed = parent?.claim?.leaseUntil === nextEligibleAt;
487
+ if (!parent || (!waiting && !claimed) || planParentScheduleKey(parent.id) !== id) {
488
+ invalid(`nextEligibleAt.${id} does not match a waiting Plan Parent`);
489
+ }
490
+ continue;
491
+ }
492
+ const mission = record.missions[id];
493
+ if (!mission)
494
+ invalid(`nextEligibleAt.${id} references a missing mission`);
495
+ if (mission.state === 'resumable') {
496
+ if (mission.nextEligibleAt !== nextEligibleAt || mission.claim !== undefined) {
497
+ invalid(`nextEligibleAt.${id} does not match resumable mission state`);
498
+ }
499
+ }
500
+ else if (!mission.claim || mission.claim.leaseUntil !== nextEligibleAt) {
501
+ invalid(`nextEligibleAt.${id} does not match an active claim lease`);
502
+ }
503
+ }
504
+ for (const [id, tombstone] of Object.entries(record.tombstones)) {
505
+ assertTombstone(tombstone, `tombstones.${id}`);
506
+ }
507
+ for (const [hash, blob] of Object.entries(record.blobs)) {
508
+ assertBlobReference(blob, `blobs.${hash}`, hash);
509
+ }
510
+ for (const [id, mission] of Object.entries(record.missions)) {
511
+ if (mission.state === 'resumable') {
512
+ if (!mission.resumeTarget || !mission.nextEligibleAt || !mission.resumableReason
513
+ || !mission.requiredPredicate
514
+ || record.nextEligibleAt[id] !== mission.nextEligibleAt) {
515
+ invalid(`missions.${id} resumable state requires complete indexed scheduling metadata`);
516
+ }
517
+ }
518
+ if (mission.claim
519
+ && record.nextEligibleAt[id] !== mission.claim.leaseUntil) {
520
+ invalid(`missions.${id}.claim requires matching nextEligibleAt index`);
521
+ }
522
+ if (mission.claim && terminalMissionState(mission.state)) {
523
+ invalid(`missions.${id}.claim is forbidden in terminal state`);
524
+ }
525
+ for (const [actionKey, execution] of Object.entries(mission.actionExecutions ?? {})) {
526
+ if (execution.status === 'completed'
527
+ && !record.blobs[execution.receiptSha256]) {
528
+ invalid(`missions.${id}.actionExecutions.${actionKey} references a missing receipt blob`);
529
+ }
530
+ }
531
+ }
532
+ for (const [id, parent] of Object.entries(record.planParents)) {
533
+ const scheduleKey = planParentScheduleKey(id);
534
+ if (parent.state === 'wave-waiting') {
535
+ if (record.nextEligibleAt[scheduleKey] !== parent.nextEligibleAt) {
536
+ invalid(`planParents.${id} requires matching nextEligibleAt index`);
537
+ }
538
+ }
539
+ else if (record.nextEligibleAt[scheduleKey] !== undefined
540
+ && (!parent.claim || parent.claim.leaseUntil
541
+ !== record.nextEligibleAt[scheduleKey])) {
542
+ invalid(`planParents.${id} has stale scheduling metadata`);
543
+ }
544
+ }
545
+ }
546
+ const missionStateSet = new Set(missionStates);
547
+ const resumeTargetSet = new Set(safeResumeTargets);
548
+ function assertMissionRecord(value, path) {
549
+ const record = assertObject(value, path);
550
+ assertExactFields(record, path, [
551
+ 'id',
552
+ 'revision',
553
+ 'state',
554
+ 'findingIds',
555
+ 'residualFindingIds',
556
+ 'resumeTarget',
557
+ 'nextEligibleAt',
558
+ 'resumableReason',
559
+ 'requiredPredicate',
560
+ 'actionKey',
561
+ 'inputSnapshot',
562
+ 'fencingEpoch',
563
+ 'authorizedPermit',
564
+ 'actionExecutions',
565
+ 'applyPermit',
566
+ 'applyIntent',
567
+ 'applyReceipt',
568
+ 'applyHistory',
569
+ 'claim',
570
+ 'cancellation',
571
+ ]);
572
+ assertNonEmptyString(record.id, `${path}.id`);
573
+ assertNonNegativeInteger(record.revision, `${path}.revision`);
574
+ if (typeof record.state !== 'string' || !missionStateSet.has(record.state)) {
575
+ invalid(`${path}.state must be a known Mission state`);
576
+ }
577
+ assertOptionalStringArray(record.findingIds, `${path}.findingIds`);
578
+ assertOptionalStringArray(record.residualFindingIds, `${path}.residualFindingIds`);
579
+ if (record.resumeTarget !== undefined
580
+ && (typeof record.resumeTarget !== 'string' || !resumeTargetSet.has(record.resumeTarget))) {
581
+ invalid(`${path}.resumeTarget must be a safe resume target`);
582
+ }
583
+ assertOptionalNonEmptyString(record.nextEligibleAt, `${path}.nextEligibleAt`);
584
+ assertOptionalNonEmptyString(record.resumableReason, `${path}.resumableReason`);
585
+ assertOptionalNonEmptyString(record.requiredPredicate, `${path}.requiredPredicate`);
586
+ if (record.nextEligibleAt !== undefined) {
587
+ assertExactTimestamp(record.nextEligibleAt, `${path}.nextEligibleAt`);
588
+ }
589
+ assertOptionalNonEmptyString(record.actionKey, `${path}.actionKey`);
590
+ assertOptionalNonEmptyString(record.inputSnapshot, `${path}.inputSnapshot`);
591
+ if (record.fencingEpoch !== undefined) {
592
+ assertPositiveInteger(record.fencingEpoch, `${path}.fencingEpoch`);
593
+ }
594
+ if (record.authorizedPermit !== undefined) {
595
+ assertAuthorizedPermit(record.authorizedPermit, `${path}.authorizedPermit`);
596
+ }
597
+ if (record.actionExecutions !== undefined) {
598
+ const executions = assertObject(record.actionExecutions, `${path}.actionExecutions`);
599
+ for (const [actionKey, execution] of Object.entries(executions)) {
600
+ assertNonEmptyString(actionKey, `${path}.actionExecutions key`);
601
+ const entry = assertObject(execution, `${path}.actionExecutions.${actionKey}`);
602
+ assertExactFields(entry, `${path}.actionExecutions.${actionKey}`, [
603
+ 'permitFingerprint', 'status', 'receiptSha256',
604
+ ]);
605
+ if (typeof entry.permitFingerprint !== 'string'
606
+ || !/^sha256:[a-f0-9]{64}$/u.test(entry.permitFingerprint)) {
607
+ invalid(`${path}.actionExecutions.${actionKey}.permitFingerprint must be sha256 hex`);
608
+ }
609
+ if (entry.status !== 'in-flight' && entry.status !== 'completed') {
610
+ invalid(`${path}.actionExecutions.${actionKey}.status must be in-flight or completed`);
611
+ }
612
+ assertOptionalNonEmptyString(entry.receiptSha256, `${path}.actionExecutions.${actionKey}.receiptSha256`);
613
+ if (entry.status === 'completed'
614
+ && (typeof entry.receiptSha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(entry.receiptSha256))) {
615
+ invalid(`${path}.actionExecutions.${actionKey}.receiptSha256 must be sha256 hex when completed`);
616
+ }
617
+ if (entry.status === 'in-flight' && entry.receiptSha256 !== undefined) {
618
+ invalid(`${path}.actionExecutions.${actionKey}.receiptSha256 is forbidden while in flight`);
619
+ }
620
+ }
621
+ }
622
+ if (record.applyPermit !== undefined) {
623
+ const permit = validateMissionApplyPermit(record.applyPermit);
624
+ if (permit.missionId !== record.id || permit.actionKey !== record.actionKey
625
+ || permit.fencingEpoch !== record.fencingEpoch) {
626
+ invalid(`${path}.applyPermit must match the aggregate identity`);
627
+ }
628
+ }
629
+ if (record.applyIntent !== undefined) {
630
+ assertMissionApplyIntent(record.applyIntent);
631
+ if (record.applyPermit === undefined
632
+ || record.applyIntent.permitFingerprint
633
+ !== missionApplyPermitFingerprint(validateMissionApplyPermit(record.applyPermit))) {
634
+ invalid(`${path}.applyIntent must match applyPermit`);
635
+ }
636
+ }
637
+ if (record.applyReceipt !== undefined) {
638
+ assertMissionApplyReceipt(record.applyReceipt);
639
+ if (record.applyIntent === undefined
640
+ || record.applyReceipt.permitFingerprint !== record.applyIntent.permitFingerprint) {
641
+ invalid(`${path}.applyReceipt must match applyIntent`);
642
+ }
643
+ const permit = record.applyPermit === undefined
644
+ ? undefined
645
+ : validateMissionApplyPermit(record.applyPermit);
646
+ if (!permit
647
+ || record.applyReceipt.targetRef !== permit.targetRef
648
+ || record.applyReceipt.oldCommitSha !== permit.expectedOldCommit
649
+ || record.applyReceipt.commitSha !== permit.expectedNewCommit
650
+ || record.applyReceipt.treeSha !== permit.expectedNewTree) {
651
+ invalid(`${path}.applyReceipt identity must match applyPermit`);
652
+ }
653
+ }
654
+ if (record.applyHistory !== undefined) {
655
+ if (!Array.isArray(record.applyHistory)) {
656
+ invalid(`${path}.applyHistory must be an array`);
657
+ }
658
+ for (const [index, receipt] of record.applyHistory.entries()) {
659
+ assertMissionApplyReceipt(receipt);
660
+ if (record.applyHistory.findIndex((candidate) => candidate.permitFingerprint === receipt.permitFingerprint) !== index) {
661
+ invalid(`${path}.applyHistory contains duplicate permit fingerprints`);
662
+ }
663
+ }
664
+ }
665
+ if (record.claim !== undefined) {
666
+ assertMissionClaim(record.claim, `${path}.claim`);
667
+ }
668
+ if (record.cancellation !== undefined) {
669
+ const cancellation = assertObject(record.cancellation, `${path}.cancellation`);
670
+ assertExactFields(cancellation, `${path}.cancellation`, ['requestedAt', 'requestedBy']);
671
+ assertExactTimestamp(cancellation.requestedAt, `${path}.cancellation.requestedAt`);
672
+ assertNonEmptyString(cancellation.requestedBy, `${path}.cancellation.requestedBy`);
673
+ if (record.state !== 'cancelling' && record.state !== 'cancelled'
674
+ && record.state !== 'safety-stop') {
675
+ invalid(`${path}.cancellation is allowed only while cancelling, cancelled, or safety-stop`);
676
+ }
677
+ }
678
+ const hasPermit = record.applyPermit !== undefined;
679
+ const hasIntent = record.applyIntent !== undefined;
680
+ const hasReceipt = record.applyReceipt !== undefined;
681
+ if (record.state === 'apply-authorizing' && (hasPermit || hasIntent || hasReceipt)) {
682
+ invalid(`${path} apply-authorizing state must not retain apply artifacts`);
683
+ }
684
+ if (record.state === 'apply-prepared' && (!hasPermit || hasIntent || hasReceipt)) {
685
+ invalid(`${path} apply-prepared state requires only an apply permit`);
686
+ }
687
+ if (record.state === 'applying' && (!hasPermit || !hasIntent || hasReceipt)) {
688
+ invalid(`${path} applying state requires permit and intent without receipt`);
689
+ }
690
+ if (record.state === 'reconciling' && (hasPermit || hasIntent || hasReceipt)
691
+ && (!hasPermit || !hasIntent || !hasReceipt)) {
692
+ invalid(`${path} apply reconciliation requires permit, intent, and receipt together`);
693
+ }
694
+ }
695
+ function assertMissionClaim(value, path) {
696
+ const record = assertObject(value, path);
697
+ assertExactFields(record, path, [
698
+ 'version', 'token', 'daemonId', 'hostId', 'bootNonce', 'fencingEpoch',
699
+ 'claimedAt', 'leaseUntil', 'processes',
700
+ ]);
701
+ if (record.version !== 1)
702
+ invalid(`${path}.version must be 1`);
703
+ assertNonEmptyString(record.token, `${path}.token`);
704
+ assertNonEmptyString(record.daemonId, `${path}.daemonId`);
705
+ assertNonEmptyString(record.hostId, `${path}.hostId`);
706
+ assertNonEmptyString(record.bootNonce, `${path}.bootNonce`);
707
+ assertPositiveInteger(record.fencingEpoch, `${path}.fencingEpoch`);
708
+ assertExactTimestamp(record.claimedAt, `${path}.claimedAt`);
709
+ assertExactTimestamp(record.leaseUntil, `${path}.leaseUntil`);
710
+ if (record.leaseUntil <= record.claimedAt) {
711
+ invalid(`${path}.leaseUntil must be after claimedAt`);
712
+ }
713
+ if (!Array.isArray(record.processes))
714
+ invalid(`${path}.processes must be an array`);
715
+ const identities = new Set();
716
+ record.processes.forEach((value, index) => {
717
+ const process = assertObject(value, `${path}.processes[${index}]`);
718
+ assertExactFields(process, `${path}.processes[${index}]`, [
719
+ 'actionKey', 'pid', 'hostId', 'bootNonce', 'startedAt',
720
+ ]);
721
+ assertNonEmptyString(process.actionKey, `${path}.processes[${index}].actionKey`);
722
+ assertPositiveInteger(process.pid, `${path}.processes[${index}].pid`);
723
+ assertNonEmptyString(process.hostId, `${path}.processes[${index}].hostId`);
724
+ assertNonEmptyString(process.bootNonce, `${path}.processes[${index}].bootNonce`);
725
+ assertExactTimestamp(process.startedAt, `${path}.processes[${index}].startedAt`);
726
+ if (process.hostId !== record.hostId || process.bootNonce !== record.bootNonce) {
727
+ invalid(`${path}.processes[${index}] must match claim host and boot identity`);
728
+ }
729
+ const identity = `${process.hostId}:${process.bootNonce}:${process.pid}:${process.actionKey}`;
730
+ if (identities.has(identity))
731
+ invalid(`${path}.processes contains duplicate ${identity}`);
732
+ identities.add(identity);
733
+ });
734
+ }
735
+ function assertAuthorizedPermit(value, path) {
736
+ const record = assertObject(value, path);
737
+ assertExactFields(record, path, [
738
+ 'missionId', 'actionKey', 'capability', 'argv', 'requestedPaths', 'grantedPaths',
739
+ 'inputSnapshot', 'fencingEpoch', 'expiresAt', 'network', 'workspace',
740
+ 'readPath', 'maxReadBytes',
741
+ ]);
742
+ if (record.network !== 'deny' || record.workspace !== 'read-only') {
743
+ invalid(`${path} must deny network and keep the workspace read-only`);
744
+ }
745
+ try {
746
+ authorizeMissionCapability(record);
747
+ }
748
+ catch (error) {
749
+ invalid(`${path} is invalid: ${error instanceof Error ? error.message : String(error)}`);
750
+ }
751
+ }
752
+ function assertStoredAggregate(value, path) {
753
+ const record = assertObject(value, path);
754
+ assertExactFields(record, path, ['revision', 'value']);
755
+ assertNonNegativeInteger(record.revision, `${path}.revision`);
756
+ assertJsonValue(record.value, `${path}.value`);
757
+ }
758
+ function assertTombstone(value, path) {
759
+ const record = assertObject(value, path);
760
+ assertExactFields(record, path, ['kind', 'terminalState', 'retainedAt']);
761
+ if (record.kind !== 'mission' && record.kind !== 'plan-parent' && record.kind !== 'publication') {
762
+ invalid(`${path}.kind must be a known aggregate kind`);
763
+ }
764
+ assertNonEmptyString(record.terminalState, `${path}.terminalState`);
765
+ assertNonEmptyString(record.retainedAt, `${path}.retainedAt`);
766
+ }
767
+ function assertBlobReference(value, path, expectedHash) {
768
+ const record = assertObject(value, path);
769
+ assertExactFields(record, path, ['sha256', 'size']);
770
+ if (typeof record.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(record.sha256)) {
771
+ invalid(`${path}.sha256 must be lowercase SHA-256 hex`);
772
+ }
773
+ if (expectedHash !== undefined && record.sha256 !== expectedHash) {
774
+ invalid(`${path}.sha256 must match its map key`);
775
+ }
776
+ assertNonNegativeInteger(record.size, `${path}.size`);
777
+ }
778
+ function assertJsonValue(value, path) {
779
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') {
780
+ return;
781
+ }
782
+ if (typeof value === 'number') {
783
+ if (!Number.isFinite(value)) {
784
+ invalid(`${path} must contain only finite JSON numbers`);
785
+ }
786
+ return;
787
+ }
788
+ if (Array.isArray(value)) {
789
+ value.forEach((child, index) => assertJsonValue(child, `${path}[${index}]`));
790
+ return;
791
+ }
792
+ if (typeof value === 'object' && value !== null) {
793
+ for (const [key, child] of Object.entries(value)) {
794
+ assertJsonValue(child, `${path}.${key}`);
795
+ }
796
+ return;
797
+ }
798
+ invalid(`${path} must be valid JSON`);
799
+ }
800
+ function assertObject(value, path) {
801
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
802
+ invalid(`${path} must be an object`);
803
+ }
804
+ return value;
805
+ }
806
+ function assertExactFields(record, path, fields) {
807
+ const allowed = new Set(fields);
808
+ for (const key of Object.keys(record)) {
809
+ if (!allowed.has(key)) {
810
+ invalid(`${path} has unexpected field ${key}`);
811
+ }
812
+ }
813
+ }
814
+ function assertNonNegativeInteger(value, path) {
815
+ if (!Number.isSafeInteger(value) || value < 0) {
816
+ invalid(`${path} must be a non-negative integer`);
817
+ }
818
+ }
819
+ function assertPositiveInteger(value, path) {
820
+ if (!Number.isSafeInteger(value) || value <= 0) {
821
+ invalid(`${path} must be a positive integer`);
822
+ }
823
+ }
824
+ function assertNonEmptyString(value, path) {
825
+ if (typeof value !== 'string' || value.trim().length === 0) {
826
+ invalid(`${path} must be a non-empty string`);
827
+ }
828
+ }
829
+ function assertOptionalNonEmptyString(value, path) {
830
+ if (value !== undefined) {
831
+ assertNonEmptyString(value, path);
832
+ }
833
+ }
834
+ function assertExactTimestamp(value, path) {
835
+ assertNonEmptyString(value, path);
836
+ const milliseconds = Date.parse(value);
837
+ if (!Number.isFinite(milliseconds) || new Date(milliseconds).toISOString() !== value) {
838
+ invalid(`${path} must be an exact UTC ISO timestamp`);
839
+ }
840
+ }
841
+ function terminalMissionState(value) {
842
+ return terminalMissionStates.has(value);
843
+ }
844
+ function assertOptionalStringArray(value, path) {
845
+ if (value === undefined) {
846
+ return;
847
+ }
848
+ if (!Array.isArray(value)) {
849
+ invalid(`${path} must be an array`);
850
+ }
851
+ value.forEach((item, index) => assertNonEmptyString(item, `${path}[${index}]`));
852
+ }
853
+ function invalid(reason) {
854
+ throw new Error(`Invalid Mission state: ${reason}.`);
855
+ }
856
+ //# sourceMappingURL=mission-state-store.js.map