@dsh-enhanced/assistant-recovery 0.1.12

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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +178 -0
  3. package/cordis.patch.yml +14 -0
  4. package/lib/attestation.d.ts +18 -0
  5. package/lib/attestation.d.ts.map +1 -0
  6. package/lib/attestation.js +80 -0
  7. package/lib/attestation.js.map +1 -0
  8. package/lib/automation-executor.d.ts +21 -0
  9. package/lib/automation-executor.d.ts.map +1 -0
  10. package/lib/automation-executor.js +141 -0
  11. package/lib/automation-executor.js.map +1 -0
  12. package/lib/catalog.d.ts +16 -0
  13. package/lib/catalog.d.ts.map +1 -0
  14. package/lib/catalog.js +49 -0
  15. package/lib/catalog.js.map +1 -0
  16. package/lib/config.d.ts +34 -0
  17. package/lib/config.d.ts.map +1 -0
  18. package/lib/config.js +101 -0
  19. package/lib/config.js.map +1 -0
  20. package/lib/executor.d.ts +50 -0
  21. package/lib/executor.d.ts.map +1 -0
  22. package/lib/executor.js +413 -0
  23. package/lib/executor.js.map +1 -0
  24. package/lib/index.d.ts +21 -0
  25. package/lib/index.d.ts.map +1 -0
  26. package/lib/index.js +26 -0
  27. package/lib/index.js.map +1 -0
  28. package/lib/port.d.ts +125 -0
  29. package/lib/port.d.ts.map +1 -0
  30. package/lib/port.js +706 -0
  31. package/lib/port.js.map +1 -0
  32. package/lib/service.d.ts +46 -0
  33. package/lib/service.d.ts.map +1 -0
  34. package/lib/service.js +549 -0
  35. package/lib/service.js.map +1 -0
  36. package/lib/sqlite.d.ts +9 -0
  37. package/lib/sqlite.d.ts.map +1 -0
  38. package/lib/sqlite.js +247 -0
  39. package/lib/sqlite.js.map +1 -0
  40. package/lib/store.d.ts +79 -0
  41. package/lib/store.d.ts.map +1 -0
  42. package/lib/store.js +806 -0
  43. package/lib/store.js.map +1 -0
  44. package/lib/types.d.ts +157 -0
  45. package/lib/types.d.ts.map +1 -0
  46. package/lib/types.js +3 -0
  47. package/lib/types.js.map +1 -0
  48. package/lib/version.d.ts +2 -0
  49. package/lib/version.d.ts.map +1 -0
  50. package/lib/version.js +2 -0
  51. package/lib/version.js.map +1 -0
  52. package/package.json +96 -0
package/lib/store.js ADDED
@@ -0,0 +1,806 @@
1
+ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { isAbsolute, resolve } from 'node:path';
3
+ import { RECOVERY_CATALOG_DIGEST, recoveryStepIndex } from './catalog.js';
4
+ import { canonicalRecoveryBootstrapAttestationSet, EMPTY_BOOTSTRAP_ATTESTATION_SET_DIGEST, RecoveryBootstrapAttestationError, recoveryBootstrapAttestationSetDigest, } from './attestation.js';
5
+ import { openRecoveryDatabase } from './sqlite.js';
6
+ import { RECOVERY_RUNBOOK_VERSION, } from './types.js';
7
+ export class RecoveryStoreError extends Error {
8
+ code;
9
+ constructor(code, message) {
10
+ super(message);
11
+ this.code = code;
12
+ this.name = 'RecoveryStoreError';
13
+ }
14
+ }
15
+ export const RECOVERY_DEADLINE_GRACE_MS = 10_000;
16
+ const DIGEST = /^[a-f\d]{64}$/u;
17
+ const CODE = /^[a-z\d][a-z\d.-]{0,63}$/u;
18
+ function hasControlCharacter(value) {
19
+ return [...value].some((character) => {
20
+ const point = character.codePointAt(0);
21
+ return point <= 0x1f || point === 0x7f;
22
+ });
23
+ }
24
+ function boundedText(value, field, maximumBytes = 500) {
25
+ if (typeof value !== 'string')
26
+ throw new RecoveryStoreError('invalid-input', `${field} must be a string`);
27
+ const normalized = value.normalize('NFC').trim();
28
+ if (normalized === '' || Buffer.byteLength(normalized, 'utf8') > maximumBytes || hasControlCharacter(normalized)) {
29
+ throw new RecoveryStoreError('invalid-input', `${field} must contain bounded printable text`);
30
+ }
31
+ return normalized;
32
+ }
33
+ function digestText(value, field) {
34
+ const normalized = boundedText(value, field, 64).toLowerCase();
35
+ if (!DIGEST.test(normalized))
36
+ throw new RecoveryStoreError('invalid-input', `${field} must be a SHA-256 digest`);
37
+ return normalized;
38
+ }
39
+ function resultCode(value) {
40
+ const normalized = boundedText(value, 'resultCode', 64).toLowerCase();
41
+ if (!CODE.test(normalized))
42
+ throw new RecoveryStoreError('invalid-input', 'resultCode must be a stable low-cardinality code');
43
+ return normalized;
44
+ }
45
+ function safeVersion(value, field) {
46
+ if (!Number.isSafeInteger(value) || value < 1) {
47
+ throw new RecoveryStoreError('invalid-input', `${field} must be a positive safe integer`);
48
+ }
49
+ return value;
50
+ }
51
+ function safeGeneration(value, field) {
52
+ if (!Number.isSafeInteger(value) || value < 1) {
53
+ throw new RecoveryStoreError('invalid-input', `${field} must be a positive safe integer`);
54
+ }
55
+ return value;
56
+ }
57
+ function ownerLineage(value) {
58
+ if (typeof value !== 'object' || value === null) {
59
+ throw new RecoveryStoreError('invalid-input', 'action.principalLineage must be an object');
60
+ }
61
+ const raw = value;
62
+ if (typeof raw.principalRecordId !== 'string' || typeof raw.principalVersion !== 'number') {
63
+ throw new RecoveryStoreError('invalid-input', 'action.principalLineage is invalid');
64
+ }
65
+ return Object.freeze({
66
+ principalRecordId: boundedText(raw.principalRecordId, 'action.principalLineage.principalRecordId', 500),
67
+ principalVersion: safeVersion(raw.principalVersion, 'action.principalLineage.principalVersion'),
68
+ });
69
+ }
70
+ function normalizeBootstrapAttestations(raw, stored = false) {
71
+ try {
72
+ const attestations = canonicalRecoveryBootstrapAttestationSet(raw);
73
+ const json = JSON.stringify(attestations);
74
+ return {
75
+ attestations,
76
+ json,
77
+ digest: recoveryBootstrapAttestationSetDigest(attestations),
78
+ };
79
+ }
80
+ catch (error) {
81
+ if (!stored) {
82
+ if (error instanceof RecoveryBootstrapAttestationError) {
83
+ throw new RecoveryStoreError('invalid-input', error.message);
84
+ }
85
+ throw error;
86
+ }
87
+ if (error instanceof RecoveryStoreError && error.code === 'invalid-state')
88
+ throw error;
89
+ throw new RecoveryStoreError('invalid-state', 'stored bootstrap attestation is corrupt');
90
+ }
91
+ }
92
+ function storedBootstrapState(row) {
93
+ try {
94
+ if (!Number.isSafeInteger(row.bootstrap_generation) || row.bootstrap_generation < 0
95
+ || !Number.isSafeInteger(row.updated_at) || row.updated_at < 0
96
+ || ![0, 1].includes(row.bootstrap_attestation_valid)) {
97
+ throw new RecoveryStoreError('invalid-state', 'stored bootstrap state is invalid');
98
+ }
99
+ if (row.bootstrap_status !== 'idle' && row.bootstrap_generation < 1) {
100
+ throw new RecoveryStoreError('invalid-state', 'stored bootstrap generation is invalid');
101
+ }
102
+ const raw = JSON.parse(row.bootstrap_attestations_json);
103
+ const normalized = normalizeBootstrapAttestations(raw, true);
104
+ const persistedDigest = digestText(row.bootstrap_attestation_set_digest, 'bootstrapAttestationSetDigest');
105
+ if (normalized.json !== row.bootstrap_attestations_json
106
+ || !timingSafeEqual(Buffer.from(normalized.digest), Buffer.from(persistedDigest))) {
107
+ throw new RecoveryStoreError('invalid-state', 'stored bootstrap attestation digest is corrupt');
108
+ }
109
+ const attestationValid = row.bootstrap_attestation_valid === 1;
110
+ if ((!attestationValid && normalized.attestations.length !== 0)
111
+ || (row.bootstrap_status === 'succeeded' && !attestationValid)
112
+ || ((row.bootstrap_status === 'failed') !== (row.bootstrap_failure_code !== null))) {
113
+ throw new RecoveryStoreError('invalid-state', 'stored bootstrap attestation state is inconsistent');
114
+ }
115
+ const failureCode = row.bootstrap_failure_code === null
116
+ ? undefined
117
+ : resultCode(row.bootstrap_failure_code);
118
+ return Object.freeze({
119
+ status: row.bootstrap_status,
120
+ ...(failureCode === undefined ? {} : { failureCode }),
121
+ generation: row.bootstrap_generation,
122
+ attestationValid,
123
+ attestationSetDigest: persistedDigest,
124
+ attestations: normalized.attestations,
125
+ updatedAt: row.updated_at,
126
+ });
127
+ }
128
+ catch (error) {
129
+ if (error instanceof RecoveryStoreError && error.code === 'invalid-state')
130
+ throw error;
131
+ throw new RecoveryStoreError('invalid-state', 'stored bootstrap state is corrupt');
132
+ }
133
+ }
134
+ function safeDuration(value, field, minimum, maximum) {
135
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
136
+ throw new RecoveryStoreError('invalid-input', `${field} is outside its safe bound`);
137
+ }
138
+ return value;
139
+ }
140
+ function canonicalJson(value) {
141
+ if (value === null || typeof value !== 'object')
142
+ return JSON.stringify(value);
143
+ if (Array.isArray(value))
144
+ return `[${value.map(canonicalJson).join(',')}]`;
145
+ const record = value;
146
+ return `{${Object.keys(record).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
147
+ }
148
+ function normalizedAction(raw, allowLegacyMaintenance = false) {
149
+ if (typeof raw !== 'object' || raw === null || typeof raw.kind !== 'string') {
150
+ throw new RecoveryStoreError('invalid-input', 'step action must be a fixed catalog action');
151
+ }
152
+ let action;
153
+ switch (raw.kind) {
154
+ case 'verify-authority':
155
+ case 'verify-health':
156
+ action = Object.freeze({ kind: raw.kind });
157
+ break;
158
+ case 'project-evaluation':
159
+ action = Object.freeze({
160
+ kind: raw.kind,
161
+ evaluationId: boundedText(raw.evaluationId, 'action.evaluationId', 200),
162
+ });
163
+ break;
164
+ case 'maintain-preferences':
165
+ if (raw.limit !== 1)
166
+ throw new RecoveryStoreError('invalid-input', 'preference maintenance is fixed to one item');
167
+ if (raw.ownerGeneration === undefined && raw.principalLineage === undefined) {
168
+ if (!allowLegacyMaintenance) {
169
+ throw new RecoveryStoreError('invalid-input', 'preference maintenance requires an exact durable owner fence');
170
+ }
171
+ action = Object.freeze({ kind: raw.kind, limit: 1 });
172
+ break;
173
+ }
174
+ if (raw.ownerGeneration === undefined || raw.principalLineage === undefined) {
175
+ throw new RecoveryStoreError('invalid-input', 'preference maintenance owner fence is incomplete');
176
+ }
177
+ action = Object.freeze({
178
+ kind: raw.kind,
179
+ limit: 1,
180
+ ownerGeneration: safeGeneration(raw.ownerGeneration, 'action.ownerGeneration'),
181
+ principalLineage: ownerLineage(raw.principalLineage),
182
+ });
183
+ break;
184
+ case 'activate-preference':
185
+ action = Object.freeze({
186
+ kind: raw.kind,
187
+ hypothesisId: boundedText(raw.hypothesisId, 'action.hypothesisId', 200),
188
+ expectedVersion: safeVersion(raw.expectedVersion, 'action.expectedVersion'),
189
+ ownerGeneration: safeGeneration(raw.ownerGeneration, 'action.ownerGeneration'),
190
+ principalLineage: ownerLineage(raw.principalLineage),
191
+ });
192
+ break;
193
+ case 'rollback-evolution':
194
+ action = Object.freeze({
195
+ kind: raw.kind,
196
+ ruleId: boundedText(raw.ruleId, 'action.ruleId', 200),
197
+ expectedVersion: safeVersion(raw.expectedVersion, 'action.expectedVersion'),
198
+ });
199
+ break;
200
+ case 'probe-automation-circuit':
201
+ action = Object.freeze({
202
+ kind: raw.kind,
203
+ automationId: boundedText(raw.automationId, 'action.automationId', 200),
204
+ definitionHash: digestText(raw.definitionHash, 'action.definitionHash'),
205
+ expectedVersion: safeVersion(raw.expectedVersion, 'action.expectedVersion'),
206
+ });
207
+ break;
208
+ case 'noop':
209
+ action = Object.freeze({ kind: raw.kind, reasonCode: resultCode(raw.reasonCode) });
210
+ break;
211
+ default:
212
+ throw new RecoveryStoreError('invalid-input', 'step action is not in the fixed catalog');
213
+ }
214
+ const json = canonicalJson(action);
215
+ if (Buffer.byteLength(json, 'utf8') > 2_048) {
216
+ throw new RecoveryStoreError('invalid-input', 'step action exceeds the durable byte limit');
217
+ }
218
+ return { action, json, digest: createHash('sha256').update(json).digest('hex') };
219
+ }
220
+ function storedAction(value, expectedDigest) {
221
+ try {
222
+ if (!DIGEST.test(expectedDigest)) {
223
+ throw new RecoveryStoreError('invalid-state', 'stored step action digest is malformed');
224
+ }
225
+ const normalized = normalizedAction(JSON.parse(value), true);
226
+ const actual = Buffer.from(normalized.digest, 'hex');
227
+ const expected = Buffer.from(expectedDigest, 'hex');
228
+ if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
229
+ throw new RecoveryStoreError('invalid-state', 'stored step action digest does not match its canonical action');
230
+ }
231
+ return normalized.action;
232
+ }
233
+ catch (error) {
234
+ if (error instanceof RecoveryStoreError && error.code === 'invalid-state')
235
+ throw error;
236
+ throw new RecoveryStoreError('invalid-state', 'stored step action is corrupt');
237
+ }
238
+ }
239
+ function assertActionMatchesStep(stepId, action) {
240
+ const expected = {
241
+ 'authority-admission': ['verify-authority', 'noop'],
242
+ 'ledger-reconcile': ['project-evaluation', 'noop'],
243
+ 'retention-maintenance': ['maintain-preferences', 'noop'],
244
+ 't1-effects': ['activate-preference', 'noop'],
245
+ 'regression-rollback': ['rollback-evolution', 'noop'],
246
+ 'incident-review': ['probe-automation-circuit', 'noop'],
247
+ verification: ['verify-health', 'noop'],
248
+ };
249
+ if (!expected[stepId].includes(action.kind)) {
250
+ throw new RecoveryStoreError('invalid-input', `${action.kind} is not valid for ${stepId}`);
251
+ }
252
+ }
253
+ function scopeKey(workspace, preset) {
254
+ return createHash('sha256').update(JSON.stringify([workspace, preset])).digest('hex');
255
+ }
256
+ function storedRun(row) {
257
+ return Object.freeze({
258
+ id: row.id,
259
+ occurrenceId: row.occurrence_id,
260
+ automationId: row.automation_id,
261
+ definitionHash: row.definition_hash,
262
+ executionMode: row.execution_mode,
263
+ targetScope: Object.freeze({ workspace: row.target_workspace, preset: row.target_preset }),
264
+ principal: row.principal,
265
+ ownerRouteId: row.owner_route_id,
266
+ activationNonce: row.activation_nonce,
267
+ activationPlanDigest: row.activation_plan_digest,
268
+ catalogDigest: row.catalog_digest,
269
+ status: row.status,
270
+ startedAt: row.started_at,
271
+ deadlineAt: row.deadline_at,
272
+ ...(row.finished_at === null ? {} : { finishedAt: row.finished_at }),
273
+ ...(row.result_code === null ? {} : { resultCode: row.result_code }),
274
+ version: row.version,
275
+ });
276
+ }
277
+ function storedStep(row) {
278
+ const action = storedAction(row.action_json, row.action_digest);
279
+ assertActionMatchesStep(row.step_id, action);
280
+ return Object.freeze({
281
+ runId: row.run_id,
282
+ stepId: row.step_id,
283
+ idempotencyKey: row.idempotency_key,
284
+ action,
285
+ actionDigest: row.action_digest,
286
+ status: row.status,
287
+ beforeDigest: row.before_digest,
288
+ ...(row.after_digest === null ? {} : { afterDigest: row.after_digest }),
289
+ ...(row.result_code === null ? {} : { resultCode: row.result_code }),
290
+ startedAt: row.started_at,
291
+ deadlineAt: row.deadline_at,
292
+ ...(row.finished_at === null ? {} : { finishedAt: row.finished_at }),
293
+ version: row.version,
294
+ });
295
+ }
296
+ function sameRun(row, input) {
297
+ return row.automation_id === input.automationId
298
+ && row.definition_hash === input.definitionHash
299
+ && row.execution_mode === input.executionMode
300
+ && row.target_workspace === input.targetScope.workspace
301
+ && row.target_preset === input.targetScope.preset
302
+ && row.principal === input.principal
303
+ && row.owner_route_id === input.ownerRouteId
304
+ && row.activation_nonce === input.activationNonce
305
+ && row.activation_plan_digest === input.activationPlanDigest
306
+ && row.catalog_digest === input.catalogDigest;
307
+ }
308
+ export class RecoveryStore {
309
+ database;
310
+ now;
311
+ maxStepDurationMs;
312
+ deadlineGraceMs;
313
+ constructor(options) {
314
+ this.database = openRecoveryDatabase(options.path);
315
+ this.now = options.now ?? Date.now;
316
+ this.maxStepDurationMs = safeDuration(options.maxStepDurationMs ?? 10_000, 'maxStepDurationMs', 100, 60_000);
317
+ this.deadlineGraceMs = safeDuration(options.deadlineGraceMs ?? RECOVERY_DEADLINE_GRACE_MS, 'deadlineGraceMs', 0, 60_000);
318
+ }
319
+ beginRun(raw) {
320
+ const workspace = boundedText(raw.targetScope.workspace, 'targetScope.workspace', 4_096);
321
+ if (!isAbsolute(workspace)) {
322
+ throw new RecoveryStoreError('invalid-input', 'targetScope.workspace must be absolute');
323
+ }
324
+ const input = {
325
+ occurrenceId: boundedText(raw.occurrenceId, 'occurrenceId', 200),
326
+ automationId: boundedText(raw.automationId, 'automationId', 200),
327
+ definitionHash: digestText(raw.definitionHash, 'definitionHash'),
328
+ executionMode: raw.executionMode,
329
+ targetScope: {
330
+ workspace: resolve(workspace),
331
+ preset: boundedText(raw.targetScope.preset, 'targetScope.preset', 200),
332
+ },
333
+ principal: boundedText(raw.principal, 'principal', 500),
334
+ ownerRouteId: boundedText(raw.ownerRouteId, 'ownerRouteId', 200),
335
+ activationNonce: boundedText(raw.activationNonce, 'activationNonce', 200),
336
+ activationPlanDigest: digestText(raw.activationPlanDigest, 'activationPlanDigest'),
337
+ catalogDigest: digestText(raw.catalogDigest, 'catalogDigest'),
338
+ };
339
+ if (input.catalogDigest !== RECOVERY_CATALOG_DIGEST) {
340
+ throw new RecoveryStoreError('invalid-input', 'catalogDigest does not match the compiled runbook');
341
+ }
342
+ if (input.executionMode !== 'preview' && input.executionMode !== 'production') {
343
+ throw new RecoveryStoreError('invalid-input', 'executionMode must be preview or production');
344
+ }
345
+ this.database.exec('BEGIN IMMEDIATE');
346
+ try {
347
+ const existing = this.database.prepare('SELECT * FROM recovery_runs WHERE occurrence_id = ?').get(input.occurrenceId);
348
+ if (existing !== undefined) {
349
+ if (!sameRun(existing, input)) {
350
+ throw new RecoveryStoreError('idempotency-conflict', 'occurrenceId was already used with different immutable input');
351
+ }
352
+ this.database.exec('COMMIT');
353
+ return { run: storedRun(existing), replayed: true };
354
+ }
355
+ const id = randomUUID();
356
+ const now = this.now();
357
+ const deadlineAt = now + this.maxStepDurationMs * 14 + this.deadlineGraceMs;
358
+ if (!Number.isSafeInteger(deadlineAt)) {
359
+ throw new RecoveryStoreError('invalid-state', 'recovery run deadline overflowed');
360
+ }
361
+ this.database.prepare(`
362
+ INSERT INTO recovery_runs (
363
+ id, occurrence_id, automation_id, definition_hash, execution_mode,
364
+ target_workspace, target_preset, principal, owner_route_id, activation_nonce,
365
+ activation_plan_digest, catalog_digest, status, started_at, deadline_at, version
366
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, 1)
367
+ `).run(id, input.occurrenceId, input.automationId, input.definitionHash, input.executionMode, input.targetScope.workspace, input.targetScope.preset, input.principal, input.ownerRouteId, input.activationNonce, input.activationPlanDigest, input.catalogDigest, now, deadlineAt);
368
+ const row = this.database.prepare('SELECT * FROM recovery_runs WHERE id = ?').get(id);
369
+ this.database.exec('COMMIT');
370
+ return { run: storedRun(row), replayed: false };
371
+ }
372
+ catch (error) {
373
+ this.database.exec('ROLLBACK');
374
+ throw error;
375
+ }
376
+ }
377
+ beginStep(raw) {
378
+ const runId = boundedText(raw.runId, 'runId', 200);
379
+ const { action, json: actionJson, digest: actionDigest } = normalizedAction(raw.action);
380
+ assertActionMatchesStep(raw.stepId, action);
381
+ const beforeDigest = digestText(raw.beforeDigest, 'beforeDigest');
382
+ const index = recoveryStepIndex(raw.stepId);
383
+ this.database.exec('BEGIN IMMEDIATE');
384
+ try {
385
+ const run = this.database.prepare('SELECT * FROM recovery_runs WHERE id = ?').get(runId);
386
+ if (run === undefined)
387
+ throw new RecoveryStoreError('not-found', 'recovery run was not found');
388
+ if (run.status !== 'running')
389
+ throw new RecoveryStoreError('invalid-state', 'recovery run is already terminal');
390
+ const existing = this.database.prepare('SELECT * FROM recovery_steps WHERE run_id = ? AND step_id = ?').get(runId, raw.stepId);
391
+ if (existing !== undefined) {
392
+ const persisted = storedStep(existing);
393
+ if (existing.action_digest !== actionDigest) {
394
+ throw new RecoveryStoreError('idempotency-conflict', 'step was already started with a different action digest');
395
+ }
396
+ if (existing.before_digest !== beforeDigest) {
397
+ throw new RecoveryStoreError('idempotency-conflict', 'step was already started from a different state digest');
398
+ }
399
+ this.database.exec('COMMIT');
400
+ return { step: persisted, replayed: true };
401
+ }
402
+ const now = this.now();
403
+ if (run.deadline_at <= now) {
404
+ throw new RecoveryStoreError('deadline-expired', 'the persisted recovery run deadline has expired');
405
+ }
406
+ const blocking = this.database.prepare(`
407
+ SELECT step_id FROM recovery_steps
408
+ WHERE run_id = ? AND (step_index >= ? OR status IN ('started', 'failed', 'unknown'))
409
+ LIMIT 1
410
+ `).get(runId, index);
411
+ if (blocking !== undefined) {
412
+ throw new RecoveryStoreError('invalid-state', 'catalog steps must start once and in order');
413
+ }
414
+ const priorCount = this.database.prepare('SELECT count(*) AS count FROM recovery_steps WHERE run_id = ? AND step_index < ?').get(runId, index).count;
415
+ if (priorCount !== index) {
416
+ throw new RecoveryStoreError('invalid-state', 'a prior catalog step has not been durably completed');
417
+ }
418
+ const deadlineAt = Math.min(run.deadline_at, now + this.maxStepDurationMs + this.deadlineGraceMs);
419
+ if (!Number.isSafeInteger(deadlineAt)) {
420
+ throw new RecoveryStoreError('invalid-state', 'recovery step deadline overflowed');
421
+ }
422
+ const idempotencyKey = `recovery:${RECOVERY_RUNBOOK_VERSION}:${run.occurrence_id}:${raw.stepId}`;
423
+ this.database.prepare(`
424
+ INSERT INTO recovery_steps (
425
+ run_id, step_id, step_index, idempotency_key, action_json, action_digest,
426
+ status, before_digest, started_at, deadline_at, version
427
+ ) VALUES (?, ?, ?, ?, ?, ?, 'started', ?, ?, ?, 1)
428
+ `).run(runId, raw.stepId, index, idempotencyKey, actionJson, actionDigest, beforeDigest, now, deadlineAt);
429
+ const row = this.database.prepare('SELECT * FROM recovery_steps WHERE run_id = ? AND step_id = ?').get(runId, raw.stepId);
430
+ this.database.exec('COMMIT');
431
+ return { step: storedStep(row), replayed: false };
432
+ }
433
+ catch (error) {
434
+ this.database.exec('ROLLBACK');
435
+ throw error;
436
+ }
437
+ }
438
+ completeStep(raw) {
439
+ const runId = boundedText(raw.runId, 'runId', 200);
440
+ const beforeDigest = digestText(raw.beforeDigest, 'beforeDigest');
441
+ const afterDigest = digestText(raw.afterDigest, 'afterDigest');
442
+ const code = resultCode(raw.resultCode);
443
+ this.database.exec('BEGIN IMMEDIATE');
444
+ try {
445
+ const current = this.database.prepare('SELECT * FROM recovery_steps WHERE run_id = ? AND step_id = ?').get(runId, raw.stepId);
446
+ if (current === undefined)
447
+ throw new RecoveryStoreError('not-found', 'recovery step was not found');
448
+ const persisted = storedStep(current);
449
+ if (current.status !== 'started') {
450
+ const replayed = current.status === raw.status
451
+ && current.before_digest === beforeDigest
452
+ && current.after_digest === afterDigest
453
+ && current.result_code === code;
454
+ if (!replayed)
455
+ throw new RecoveryStoreError('idempotency-conflict', 'step already has a different terminal receipt');
456
+ this.database.exec('COMMIT');
457
+ return persisted;
458
+ }
459
+ if (current.version !== raw.expectedVersion) {
460
+ throw new RecoveryStoreError('version-conflict', 'recovery step version changed');
461
+ }
462
+ if (current.before_digest !== beforeDigest) {
463
+ throw new RecoveryStoreError('idempotency-conflict', 'terminal receipt does not match the durable before digest');
464
+ }
465
+ const result = this.database.prepare(`
466
+ UPDATE recovery_steps
467
+ SET status = ?, after_digest = ?, result_code = ?,
468
+ finished_at = ?, version = version + 1
469
+ WHERE run_id = ? AND step_id = ? AND status = 'started' AND version = ?
470
+ `).run(raw.status, afterDigest, code, this.now(), runId, raw.stepId, raw.expectedVersion);
471
+ if (result.changes !== 1)
472
+ throw new RecoveryStoreError('version-conflict', 'recovery step changed concurrently');
473
+ const row = this.database.prepare('SELECT * FROM recovery_steps WHERE run_id = ? AND step_id = ?').get(runId, raw.stepId);
474
+ this.database.exec('COMMIT');
475
+ return storedStep(row);
476
+ }
477
+ catch (error) {
478
+ this.database.exec('ROLLBACK');
479
+ throw error;
480
+ }
481
+ }
482
+ completeRun(input) {
483
+ const runId = boundedText(input.runId, 'runId', 200);
484
+ const code = resultCode(input.resultCode);
485
+ this.database.exec('BEGIN IMMEDIATE');
486
+ try {
487
+ const current = this.database.prepare('SELECT * FROM recovery_runs WHERE id = ?').get(runId);
488
+ if (current === undefined)
489
+ throw new RecoveryStoreError('not-found', 'recovery run was not found');
490
+ if (current.status !== 'running') {
491
+ if (current.status !== input.status || current.result_code !== code) {
492
+ throw new RecoveryStoreError('idempotency-conflict', 'run already has a different terminal receipt');
493
+ }
494
+ this.database.exec('COMMIT');
495
+ return storedRun(current);
496
+ }
497
+ if (current.version !== input.expectedVersion) {
498
+ throw new RecoveryStoreError('version-conflict', 'recovery run version changed');
499
+ }
500
+ const started = this.database.prepare("SELECT count(*) AS count FROM recovery_steps WHERE run_id = ? AND status = 'started'").get(runId).count;
501
+ if (started !== 0)
502
+ throw new RecoveryStoreError('invalid-state', 'a started step has no terminal receipt');
503
+ if (input.status === 'succeeded') {
504
+ const rows = this.database.prepare(`
505
+ SELECT run_id, step_id, idempotency_key, action_json, action_digest, status,
506
+ before_digest, after_digest, result_code, started_at, deadline_at, finished_at, version
507
+ FROM recovery_steps WHERE run_id = ? ORDER BY step_index
508
+ `).all(runId);
509
+ if (rows.length !== 7
510
+ || rows.some(row => !['noop', 'succeeded'].includes(row.status))) {
511
+ throw new RecoveryStoreError('invalid-state', 'a successful run requires every catalog step to be complete');
512
+ }
513
+ const first = storedStep(rows[0]);
514
+ const last = storedStep(rows[rows.length - 1]);
515
+ if (first.status !== 'succeeded' || first.action.kind !== 'verify-authority'
516
+ || last.status !== 'succeeded' || last.action.kind !== 'verify-health') {
517
+ throw new RecoveryStoreError('invalid-state', 'a successful run requires exact authority and health verification');
518
+ }
519
+ }
520
+ const result = this.database.prepare(`
521
+ UPDATE recovery_runs
522
+ SET status = ?, result_code = ?, finished_at = ?, version = version + 1
523
+ WHERE id = ? AND status = 'running' AND version = ?
524
+ `).run(input.status, code, this.now(), runId, input.expectedVersion);
525
+ if (result.changes !== 1)
526
+ throw new RecoveryStoreError('version-conflict', 'recovery run changed concurrently');
527
+ const row = this.database.prepare('SELECT * FROM recovery_runs WHERE id = ?').get(runId);
528
+ this.database.exec('COMMIT');
529
+ return storedRun(row);
530
+ }
531
+ catch (error) {
532
+ this.database.exec('ROLLBACK');
533
+ throw error;
534
+ }
535
+ }
536
+ getRunByOccurrence(occurrenceId) {
537
+ const row = this.database.prepare('SELECT * FROM recovery_runs WHERE occurrence_id = ?').get(boundedText(occurrenceId, 'occurrenceId', 200));
538
+ return row === undefined ? undefined : storedRun(row);
539
+ }
540
+ /**
541
+ * Exact activation attestation used before a production schedule is enabled.
542
+ * The preview definition hash may differ only because its schedule is pinned
543
+ * to a far-future one-shot; all authority-bearing fields must match exactly.
544
+ */
545
+ findSuccessfulPreview(input) {
546
+ const workspace = boundedText(input.targetScope.workspace, 'targetScope.workspace', 4_096);
547
+ if (!isAbsolute(workspace)) {
548
+ throw new RecoveryStoreError('invalid-input', 'targetScope.workspace must be absolute');
549
+ }
550
+ const row = this.database.prepare(`
551
+ SELECT * FROM recovery_runs
552
+ WHERE automation_id = ?
553
+ AND execution_mode = 'preview'
554
+ AND status = 'succeeded'
555
+ AND result_code = 'preview-verified'
556
+ AND target_workspace = ?
557
+ AND target_preset = ?
558
+ AND principal = ?
559
+ AND owner_route_id = ?
560
+ AND activation_nonce = ?
561
+ AND activation_plan_digest = ?
562
+ AND catalog_digest = ?
563
+ ORDER BY finished_at DESC, id DESC LIMIT 1
564
+ `).get(boundedText(input.automationId, 'automationId', 200), resolve(workspace), boundedText(input.targetScope.preset, 'targetScope.preset', 200), boundedText(input.principal, 'principal', 500), boundedText(input.ownerRouteId, 'ownerRouteId', 200), boundedText(input.activationNonce, 'activationNonce', 200), digestText(input.activationPlanDigest, 'activationPlanDigest'), digestText(input.catalogDigest, 'catalogDigest'));
565
+ return row === undefined ? undefined : storedRun(row);
566
+ }
567
+ getStep(runId, stepId) {
568
+ const row = this.database.prepare(`
569
+ SELECT run_id, step_id, idempotency_key, action_json, action_digest, status,
570
+ before_digest, after_digest, result_code, started_at, deadline_at, finished_at, version
571
+ FROM recovery_steps WHERE run_id = ? AND step_id = ?
572
+ `).get(boundedText(runId, 'runId', 200), stepId);
573
+ return row === undefined ? undefined : storedStep(row);
574
+ }
575
+ listSteps(runId) {
576
+ const rows = this.database.prepare(`
577
+ SELECT run_id, step_id, idempotency_key, action_json, action_digest, status,
578
+ before_digest, after_digest, result_code, started_at, deadline_at, finished_at, version
579
+ FROM recovery_steps WHERE run_id = ? ORDER BY step_index
580
+ `).all(boundedText(runId, 'runId', 200));
581
+ return rows.map(storedStep);
582
+ }
583
+ /** Remaining time from the Store clock to an immutable persisted deadline. */
584
+ deadlineRemainingMs(deadlineAt) {
585
+ if (!Number.isSafeInteger(deadlineAt) || deadlineAt < 0) {
586
+ throw new RecoveryStoreError('invalid-state', 'persisted recovery deadline is invalid');
587
+ }
588
+ const now = this.now();
589
+ if (!Number.isSafeInteger(now) || now < 0) {
590
+ throw new RecoveryStoreError('invalid-state', 'recovery Store clock is invalid');
591
+ }
592
+ return deadlineAt - now;
593
+ }
594
+ /** Start a new durable bootstrap generation and invalidate all prior proof. */
595
+ beginBootstrap(input) {
596
+ if (typeof input.attestationValid !== 'boolean') {
597
+ throw new RecoveryStoreError('invalid-input', 'bootstrap attestation validity is invalid');
598
+ }
599
+ const normalized = normalizeBootstrapAttestations(input.attestations);
600
+ if (!input.attestationValid && normalized.attestations.length !== 0) {
601
+ throw new RecoveryStoreError('invalid-input', 'an invalid bootstrap attestation must be explicitly empty');
602
+ }
603
+ if (!input.attestationValid && normalized.digest !== EMPTY_BOOTSTRAP_ATTESTATION_SET_DIGEST) {
604
+ throw new RecoveryStoreError('invalid-state', 'empty bootstrap attestation digest is inconsistent');
605
+ }
606
+ const now = this.bootstrapNow();
607
+ this.database.exec('BEGIN IMMEDIATE');
608
+ try {
609
+ const current = this.readBootstrapState();
610
+ if (!Number.isSafeInteger(current.generation + 1)) {
611
+ throw new RecoveryStoreError('invalid-state', 'bootstrap generation overflowed');
612
+ }
613
+ const generation = current.generation + 1;
614
+ const changed = this.database.prepare(`
615
+ UPDATE recovery_runtime_state
616
+ SET bootstrap_status = 'running', bootstrap_failure_code = NULL,
617
+ bootstrap_generation = ?, bootstrap_attestation_valid = ?,
618
+ bootstrap_attestations_json = ?, bootstrap_attestation_set_digest = ?,
619
+ updated_at = ?
620
+ WHERE singleton = 1 AND bootstrap_generation = ?
621
+ `).run(generation, input.attestationValid ? 1 : 0, normalized.json, normalized.digest, now, current.generation);
622
+ if (changed.changes !== 1) {
623
+ throw new RecoveryStoreError('version-conflict', 'bootstrap generation changed concurrently');
624
+ }
625
+ const state = this.readBootstrapState();
626
+ this.database.exec('COMMIT');
627
+ return state;
628
+ }
629
+ catch (error) {
630
+ this.database.exec('ROLLBACK');
631
+ throw error;
632
+ }
633
+ }
634
+ /** Bind the exact plan set to the still-running bootstrap generation. */
635
+ attestBootstrap(input) {
636
+ const expectedGeneration = safeGeneration(input.expectedGeneration, 'expectedGeneration');
637
+ const normalized = normalizeBootstrapAttestations(input.attestations);
638
+ const now = this.bootstrapNow();
639
+ this.database.exec('BEGIN IMMEDIATE');
640
+ try {
641
+ const current = this.readBootstrapState();
642
+ if (current.generation !== expectedGeneration) {
643
+ throw new RecoveryStoreError('version-conflict', 'bootstrap generation changed concurrently');
644
+ }
645
+ if (current.status !== 'running') {
646
+ if (current.attestationValid
647
+ && current.attestationSetDigest === normalized.digest) {
648
+ this.database.exec('COMMIT');
649
+ return current;
650
+ }
651
+ throw new RecoveryStoreError('invalid-state', 'bootstrap generation is already terminal');
652
+ }
653
+ const changed = this.database.prepare(`
654
+ UPDATE recovery_runtime_state
655
+ SET bootstrap_attestation_valid = 1, bootstrap_attestations_json = ?,
656
+ bootstrap_attestation_set_digest = ?, updated_at = ?
657
+ WHERE singleton = 1 AND bootstrap_generation = ? AND bootstrap_status = 'running'
658
+ `).run(normalized.json, normalized.digest, now, expectedGeneration);
659
+ if (changed.changes !== 1) {
660
+ throw new RecoveryStoreError('version-conflict', 'bootstrap generation changed concurrently');
661
+ }
662
+ const state = this.readBootstrapState();
663
+ this.database.exec('COMMIT');
664
+ return state;
665
+ }
666
+ catch (error) {
667
+ this.database.exec('ROLLBACK');
668
+ throw error;
669
+ }
670
+ }
671
+ /** Complete exactly one generation; stale async completions fail closed. */
672
+ completeBootstrap(input) {
673
+ const expectedGeneration = safeGeneration(input.expectedGeneration, 'expectedGeneration');
674
+ if (input.status !== 'failed' && input.status !== 'succeeded') {
675
+ throw new RecoveryStoreError('invalid-input', 'bootstrap terminal status is invalid');
676
+ }
677
+ if (input.status !== 'failed' && input.failureCode !== undefined) {
678
+ throw new RecoveryStoreError('invalid-input', 'only failed bootstrap may carry a failure code');
679
+ }
680
+ const failureCode = input.status === 'failed'
681
+ ? resultCode(input.failureCode ?? 'bootstrap-failed')
682
+ : undefined;
683
+ const now = this.bootstrapNow();
684
+ this.database.exec('BEGIN IMMEDIATE');
685
+ try {
686
+ const current = this.readBootstrapState();
687
+ if (current.generation !== expectedGeneration) {
688
+ throw new RecoveryStoreError('version-conflict', 'bootstrap generation changed concurrently');
689
+ }
690
+ if (current.status !== 'running') {
691
+ const replayed = current.status === input.status
692
+ && current.failureCode === failureCode;
693
+ if (!replayed) {
694
+ throw new RecoveryStoreError('idempotency-conflict', 'bootstrap already has a different terminal receipt');
695
+ }
696
+ this.database.exec('COMMIT');
697
+ return current;
698
+ }
699
+ if (input.status === 'succeeded' && !current.attestationValid) {
700
+ throw new RecoveryStoreError('invalid-state', 'bootstrap success requires an exact attestation set');
701
+ }
702
+ const changed = this.database.prepare(`
703
+ UPDATE recovery_runtime_state
704
+ SET bootstrap_status = ?, bootstrap_failure_code = ?, updated_at = ?
705
+ WHERE singleton = 1 AND bootstrap_generation = ? AND bootstrap_status = 'running'
706
+ `).run(input.status, failureCode ?? null, now, expectedGeneration);
707
+ if (changed.changes !== 1) {
708
+ throw new RecoveryStoreError('version-conflict', 'bootstrap generation changed concurrently');
709
+ }
710
+ const state = this.readBootstrapState();
711
+ this.database.exec('COMMIT');
712
+ return state;
713
+ }
714
+ catch (error) {
715
+ this.database.exec('ROLLBACK');
716
+ throw error;
717
+ }
718
+ }
719
+ health() {
720
+ const now = this.now();
721
+ const counts = this.database.prepare(`
722
+ SELECT
723
+ sum(status = 'running') AS running_runs,
724
+ sum(status = 'running' AND deadline_at <= ?) AS stale_runs,
725
+ sum(status = 'failed') AS failed_runs,
726
+ sum(status = 'unknown') AS unknown_runs,
727
+ coalesce(max(CASE WHEN status = 'succeeded' THEN finished_at ELSE 0 END), 0) AS last_succeeded_at,
728
+ coalesce(max(CASE WHEN status IN ('failed', 'unknown') THEN finished_at ELSE 0 END), 0) AS last_failed_at
729
+ FROM recovery_runs
730
+ `).get(now);
731
+ const steps = this.database.prepare(`
732
+ SELECT count(*) AS incomplete_steps,
733
+ sum(status = 'started' AND deadline_at <= ?) AS stale_steps
734
+ FROM recovery_steps WHERE status = 'started'
735
+ `).get(now);
736
+ const production = this.database.prepare(`
737
+ SELECT status, started_at FROM recovery_runs
738
+ WHERE execution_mode = 'production'
739
+ ORDER BY started_at DESC, rowid DESC LIMIT 1
740
+ `).get();
741
+ const productionStatuses = this.database.prepare(`
742
+ SELECT status FROM recovery_runs
743
+ WHERE execution_mode = 'production'
744
+ ORDER BY started_at DESC, rowid DESC
745
+ `).all();
746
+ let consecutiveProductionFailures = 0;
747
+ for (const row of productionStatuses) {
748
+ if (row.status !== 'failed' && row.status !== 'unknown')
749
+ break;
750
+ consecutiveProductionFailures += 1;
751
+ }
752
+ const bootstrap = this.readBootstrapState();
753
+ return Object.freeze({
754
+ runningRuns: counts.running_runs ?? 0,
755
+ failedRuns: counts.failed_runs ?? 0,
756
+ unknownRuns: counts.unknown_runs ?? 0,
757
+ incompleteSteps: steps.incomplete_steps,
758
+ staleRuns: counts.stale_runs ?? 0,
759
+ staleSteps: steps.stale_steps ?? 0,
760
+ lastSucceededAt: counts.last_succeeded_at,
761
+ lastFailedAt: counts.last_failed_at,
762
+ latestProductionStatus: production?.status ?? 'none',
763
+ consecutiveProductionFailures,
764
+ lastProductionRunAt: production?.started_at ?? 0,
765
+ bootstrapStatus: bootstrap.status,
766
+ ...(bootstrap.failureCode === undefined
767
+ ? {}
768
+ : { bootstrapFailureCode: bootstrap.failureCode }),
769
+ bootstrapGeneration: bootstrap.generation,
770
+ bootstrapAttestationValid: bootstrap.attestationValid,
771
+ bootstrapAttestationSetDigest: bootstrap.attestationSetDigest,
772
+ bootstrapAttestations: bootstrap.attestations,
773
+ bootstrapUpdatedAt: bootstrap.updatedAt,
774
+ });
775
+ }
776
+ bootstrapNow() {
777
+ const now = this.now();
778
+ if (!Number.isSafeInteger(now) || now < 0) {
779
+ throw new RecoveryStoreError('invalid-state', 'recovery Store clock is invalid');
780
+ }
781
+ return now;
782
+ }
783
+ readBootstrapState() {
784
+ const row = this.database.prepare(`
785
+ SELECT bootstrap_status, bootstrap_failure_code, bootstrap_generation,
786
+ bootstrap_attestation_valid, bootstrap_attestations_json,
787
+ bootstrap_attestation_set_digest, updated_at
788
+ FROM recovery_runtime_state WHERE singleton = 1
789
+ `).get();
790
+ if (row === undefined) {
791
+ throw new RecoveryStoreError('invalid-state', 'bootstrap state singleton is missing');
792
+ }
793
+ return storedBootstrapState(row);
794
+ }
795
+ targetScopeDigest(scope) {
796
+ const workspace = boundedText(scope.workspace, 'targetScope.workspace', 4_096);
797
+ if (!isAbsolute(workspace)) {
798
+ throw new RecoveryStoreError('invalid-input', 'targetScope.workspace must be absolute');
799
+ }
800
+ return scopeKey(resolve(workspace), boundedText(scope.preset, 'targetScope.preset', 200));
801
+ }
802
+ close() {
803
+ this.database.close();
804
+ }
805
+ }
806
+ //# sourceMappingURL=store.js.map