jg-agent-hq 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +4 -4
  3. package/api/README.md +51 -8
  4. package/dist/cli/cli.js +35 -3
  5. package/dist/cli/utilities.d.ts +3 -0
  6. package/dist/cli/utilities.js +30 -0
  7. package/dist/contracts/index.d.ts +2 -2
  8. package/dist/contracts/index.js +1 -1
  9. package/dist/contracts/types.d.ts +102 -0
  10. package/dist/contracts/types.js +24 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +2 -0
  13. package/dist/plans/authority.d.ts +2 -0
  14. package/dist/plans/authority.js +8 -0
  15. package/dist/plans/documents.d.ts +17 -0
  16. package/dist/plans/documents.js +24 -2
  17. package/dist/plans/files.js +3 -1
  18. package/dist/plans/plans.js +3 -1
  19. package/dist/plans/types.d.ts +5 -0
  20. package/dist/providers/claude/claude.js +2 -0
  21. package/dist/providers/claude/history/types.d.ts +23 -0
  22. package/dist/providers/claude/transformers.js +14 -1
  23. package/dist/providers/codex/codex.js +4 -0
  24. package/dist/providers/codex/transformers.js +30 -3
  25. package/dist/providers/codex/types.d.ts +34 -1
  26. package/dist/providers/codex/types.js +11 -2
  27. package/dist/runtime/runtime.d.ts +2 -0
  28. package/dist/runtime/runtime.js +2 -0
  29. package/dist/runtime/state/index.d.ts +1 -1
  30. package/dist/runtime/state/index.js +1 -1
  31. package/dist/runtime/state/state.d.ts +16 -1
  32. package/dist/runtime/state/state.js +94 -3
  33. package/dist/snapshots/capture.d.ts +14 -0
  34. package/dist/snapshots/capture.js +142 -0
  35. package/dist/snapshots/database.d.ts +2 -0
  36. package/dist/snapshots/database.js +15 -51
  37. package/dist/snapshots/fences.d.ts +15 -0
  38. package/dist/snapshots/fences.js +64 -0
  39. package/dist/snapshots/index.d.ts +2 -0
  40. package/dist/snapshots/index.js +3 -0
  41. package/dist/snapshots/snapshots.d.ts +4 -1
  42. package/dist/snapshots/snapshots.js +27 -113
  43. package/dist/snapshots/snapshots.worker.js +5 -1
  44. package/dist/snapshots/types.d.ts +11 -0
  45. package/dist/snapshots/types.js +5 -1
  46. package/dist/storage/index.d.ts +2 -1
  47. package/dist/storage/index.js +2 -1
  48. package/dist/storage/schema/constants.d.ts +2 -0
  49. package/dist/storage/schema/constants.js +3 -0
  50. package/dist/storage/schema/index.d.ts +4 -0
  51. package/dist/storage/schema/index.js +5 -0
  52. package/dist/storage/schema/schema.d.ts +16 -0
  53. package/dist/storage/schema/schema.js +73 -0
  54. package/dist/storage/schema/types.d.ts +17 -0
  55. package/dist/storage/schema/types.js +8 -0
  56. package/dist/storage/schema/v1-to-v2.sql +1 -0
  57. package/dist/storage/schema/v1.sql +538 -0
  58. package/dist/storage/types.d.ts +0 -5
  59. package/dist/storage/types.js +0 -6
  60. package/dist/storage/upgrade/index.d.ts +2 -0
  61. package/dist/storage/upgrade/index.js +2 -0
  62. package/dist/storage/upgrade/types.d.ts +61 -0
  63. package/dist/storage/upgrade/types.js +42 -0
  64. package/dist/storage/upgrade/upgrade.d.ts +33 -0
  65. package/dist/storage/upgrade/upgrade.js +196 -0
  66. package/dist/storage/upgrade/upgrade.worker.d.ts +1 -0
  67. package/dist/storage/upgrade/upgrade.worker.js +220 -0
  68. package/dist/store/schema.d.ts +2 -0
  69. package/dist/store/schema.js +16 -25
  70. package/dist/store/schema.sql +2 -1
  71. package/dist/store/store.d.ts +2 -0
  72. package/dist/store/store.js +2 -0
  73. package/docs/recovery.md +15 -1
  74. package/errors/README.md +34 -28
  75. package/package.json +2 -2
@@ -0,0 +1,142 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, mkdir, open, realpath, rm } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { hashValue } from 'jg-crypto';
5
+ import { hasErrorCode } from 'jg-exception';
6
+ import { writePrivateFile } from 'jg-fs';
7
+ import { withPlanDocumentLocks } from 'jg-plans';
8
+ import { isWithinWorkspace } from '../runtime/state/index.js';
9
+ import { observeSnapshotPlan } from './artifacts.js';
10
+ import { SNAPSHOT_COMPLETE, SNAPSHOT_COMPANIONS, SNAPSHOT_LINE_BYTES, SNAPSHOT_MANIFEST, } from './constants.js';
11
+ import { copySnapshotFile, invalidSnapshot, reserveSnapshotDirectory, syncSnapshotDirectory, } from './files.js';
12
+ import { SnapshotHeaderSchema, SnapshotRecordSchema, } from './types.js';
13
+ /**
14
+ * Copies one plan under its shared document locks; corrupt or missing source evidence stays explicit.
15
+ * @returns One fixed-size inventory record, with no repaired or invented authority.
16
+ * @throws
17
+ * - JG_PLANS_LOCK_BUSY: The plan document is locked
18
+ * - JG_PLANS_LOCK_FAILED: The plan document lock operation failed
19
+ * - JG_AGENT_HQ_SNAPSHOT_INVALID: The HQ snapshot is invalid
20
+ * - JG_AGENT_HQ_SNAPSHOT_FAILED: The HQ snapshot operation failed
21
+ */
22
+ const capturePlan = async (context, directory, snapshot) => {
23
+ const record = {
24
+ kind: 'plan',
25
+ key: hashValue(directory),
26
+ sourceDirectory: directory,
27
+ planId: null,
28
+ revision: null,
29
+ plan: null,
30
+ milestones: null,
31
+ coordination: null,
32
+ diagnostics: [],
33
+ };
34
+ if (!context.allowedRoots.some((root) => isWithinWorkspace(root, directory))) {
35
+ record.diagnostics.push('plan-unavailable');
36
+ return record;
37
+ }
38
+ let exists = true;
39
+ try {
40
+ if ((await realpath(directory)) !== directory || !(await lstat(directory)).isDirectory())
41
+ invalidSnapshot();
42
+ }
43
+ catch (cause) {
44
+ if (hasErrorCode(cause, 'ENOENT'))
45
+ exists = false;
46
+ else
47
+ throw cause;
48
+ }
49
+ const output = join(snapshot, 'plans', record.key);
50
+ await mkdir(output, { mode: 0o700 });
51
+ if (exists)
52
+ await withPlanDocumentLocks(directory, ['plan', 'milestones'], async () => {
53
+ for (const [field, filename, maximumBytes] of SNAPSHOT_COMPANIONS)
54
+ record[field] = await copySnapshotFile(join(directory, filename), join(output, filename), maximumBytes, false, true);
55
+ });
56
+ Object.assign(record, await observeSnapshotPlan(output, record));
57
+ await syncSnapshotDirectory(output);
58
+ return record;
59
+ };
60
+ /**
61
+ * Streams the shared format-1 capture and verifies it before transferring destination ownership.
62
+ * The caller must hold its database/metadata checkpoint; no cache or full inventory is retained.
63
+ * @returns The fully verified snapshot, retained by the caller after this resolves.
64
+ * @throws
65
+ * - JG_AGENT_HQ_STORE_FAILED: The HQ store failed
66
+ * - JG_PLANS_LOCK_BUSY: The plan document is locked
67
+ * - JG_PLANS_LOCK_FAILED: The plan document lock operation failed
68
+ * - JG_AGENT_HQ_SNAPSHOT_INVALID: The HQ snapshot is invalid
69
+ * - JG_AGENT_HQ_SNAPSHOT_FAILED: The HQ snapshot operation failed
70
+ * - JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED: The HQ storage version is unsupported
71
+ */
72
+ export const captureSnapshot = async (context, destination) => {
73
+ let owned;
74
+ try {
75
+ owned = await reserveSnapshotDirectory(destination);
76
+ await mkdir(join(owned, 'plans'), { mode: 0o700 });
77
+ const manifest = await open(join(owned, SNAPSHOT_MANIFEST), 'wx', 0o600);
78
+ const hash = createHash('sha256');
79
+ let records = 0;
80
+ const append = async (record) => {
81
+ const bytes = Buffer.from(JSON.stringify(SnapshotRecordSchema.parse(record)) + '\n');
82
+ if (bytes.length > SNAPSHOT_LINE_BYTES)
83
+ invalidSnapshot();
84
+ await manifest.writeFile(bytes);
85
+ hash.update(bytes);
86
+ records++;
87
+ };
88
+ try {
89
+ await append(SnapshotHeaderSchema.parse({
90
+ kind: 'header',
91
+ format: 1,
92
+ createdAt: new Date().toISOString(),
93
+ schemaHash: context.schemaHash,
94
+ sourceStateRoot: context.stateRoot,
95
+ sourceCoordinationRoot: context.coordinationRoot,
96
+ sourceAllowedRoots: context.allowedRoots,
97
+ }));
98
+ const database = await open(join(owned, 'hq.sqlite'), 'wx', 0o600);
99
+ await database.close();
100
+ await context.backupDatabase(join(owned, 'hq.sqlite'));
101
+ const capturedDatabase = await copySnapshotFile(join(owned, 'hq.sqlite'), null);
102
+ if (!capturedDatabase)
103
+ invalidSnapshot();
104
+ await append({ kind: 'database', file: capturedDatabase });
105
+ const key = await copySnapshotFile(join(context.stateRoot, 'action.key'), join(owned, 'action.key'), 32);
106
+ if (key?.bytes !== 32 || key.sha256 !== context.actionKeyHash)
107
+ invalidSnapshot();
108
+ await append({ kind: 'action-key', file: key });
109
+ let after = '';
110
+ for (;;) {
111
+ const page = await context.readPlans(after);
112
+ for (const directory of page) {
113
+ await append(await capturePlan(context, directory, owned));
114
+ after = directory;
115
+ }
116
+ if (page.length < 128)
117
+ break;
118
+ }
119
+ await manifest.sync();
120
+ await syncSnapshotDirectory(join(owned, 'plans'));
121
+ await writePrivateFile(join(owned, SNAPSHOT_COMPLETE), JSON.stringify({ manifestSha256: hash.digest('hex'), records }) + '\n', { rootPath: owned, isExclusive: true });
122
+ await syncSnapshotDirectory(owned);
123
+ await syncSnapshotDirectory(dirname(owned));
124
+ }
125
+ finally {
126
+ await manifest.close();
127
+ }
128
+ return await context.verify(owned);
129
+ }
130
+ catch (cause) {
131
+ if (owned) {
132
+ try {
133
+ await rm(owned, { recursive: true });
134
+ await syncSnapshotDirectory(dirname(owned));
135
+ }
136
+ catch (cleanup) {
137
+ throw new AggregateError([cause, cleanup], 'Snapshot cleanup failed');
138
+ }
139
+ }
140
+ throw cause;
141
+ }
142
+ };
@@ -4,6 +4,7 @@ import { type IHqSnapshotInspection } from './types.js';
4
4
  * Memory stays bounded by one manifest line, one plan page and one report payload.
5
5
  * @returns Validated metadata without loading the growing inventory into the caller.
6
6
  * @throws
7
+ * - JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED: The HQ storage version is unsupported
7
8
  * - JG_AGENT_HQ_SNAPSHOT_INVALID: The HQ snapshot is invalid
8
9
  */
9
10
  export declare const verifySnapshot: (directory: string) => Promise<IHqSnapshotInspection>;
@@ -12,6 +13,7 @@ export declare const verifySnapshot: (directory: string) => Promise<IHqSnapshotI
12
13
  * Only this operation's exclusively created destination is removed on a caught failure.
13
14
  * @returns The source inspection; the caller derives the fixed state/plans output paths.
14
15
  * @throws
16
+ * - JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED: The HQ storage version is unsupported
15
17
  * - JG_AGENT_HQ_SNAPSHOT_INVALID: The HQ snapshot is invalid
16
18
  * - JG_AGENT_HQ_SNAPSHOT_FAILED: The HQ snapshot operation failed
17
19
  */
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { lstat, mkdir, readFile, rename, rm } from 'node:fs/promises';
2
+ import { lstat, mkdir, rename, rm } from 'node:fs/promises';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { DatabaseSync } from 'node:sqlite';
5
5
  import { hashValue } from 'jg-crypto';
@@ -7,7 +7,8 @@ import { Exception, hasErrorCode, isException, extractMessage } from 'jg-excepti
7
7
  import { readPrivateFile, writePrivateFile } from 'jg-fs';
8
8
  import { z } from 'zod';
9
9
  import { isWithinWorkspace } from '../runtime/state/index.js';
10
- import { StorageStampSchema } from '../storage/index.js';
10
+ import { readStorageSchema } from '../storage/schema/index.js';
11
+ import { applyRestoreFences, requireRestoreFence } from './fences.js';
11
12
  import { observeSnapshotPlan } from './artifacts.js';
12
13
  import { SNAPSHOT_COMPLETE, SNAPSHOT_COMPANIONS, SNAPSHOT_MANIFEST } from './constants.js';
13
14
  import { copySnapshotFile, invalidSnapshot, readSnapshotRecords, reserveSnapshotDirectory, syncSnapshotDirectory, } from './files.js';
@@ -17,6 +18,7 @@ import { SnapshotCompletionSchema, } from './types.js';
17
18
  * Memory stays bounded by one manifest line, one plan page and one report payload.
18
19
  * @returns Validated metadata without loading the growing inventory into the caller.
19
20
  * @throws
21
+ * - JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED: The HQ storage version is unsupported
20
22
  * - JG_AGENT_HQ_SNAPSHOT_INVALID: The HQ snapshot is invalid
21
23
  */
22
24
  export const verifySnapshot = async (directory) => {
@@ -26,7 +28,6 @@ export const verifySnapshot = async (directory) => {
26
28
  maxBytes: 4096,
27
29
  rootPath: directory,
28
30
  })).toString('utf8')));
29
- const schemaHash = hashValue(await readFile(new URL('../store/schema.sql', import.meta.url), 'utf8'));
30
31
  const hash = createHash('sha256');
31
32
  let records = 0;
32
33
  let header;
@@ -45,7 +46,7 @@ export const verifySnapshot = async (directory) => {
45
46
  hash.update(bytes);
46
47
  records++;
47
48
  if (records === 1) {
48
- if (record.kind !== 'header' || record.schemaHash !== schemaHash)
49
+ if (record.kind !== 'header')
49
50
  invalidSnapshot();
50
51
  header = record;
51
52
  continue;
@@ -68,13 +69,10 @@ export const verifySnapshot = async (directory) => {
68
69
  readOnly: true,
69
70
  enableForeignKeyConstraints: true,
70
71
  });
71
- const stamps = database
72
- .prepare('SELECT id, schema_hash, action_key_hash FROM hq_schema LIMIT 2')
73
- .all();
74
- const stamp = StorageStampSchema.safeParse(stamps.length === 1 ? stamps[0] : null);
75
- if (!stamp.success || stamp.data.schema_hash !== schemaHash)
72
+ const recognized = readStorageSchema(database);
73
+ if (!recognized || recognized.schema.hash !== header?.schemaHash)
76
74
  invalidSnapshot();
77
- actionKeyHash = stamp.data.action_key_hash;
75
+ actionKeyHash = recognized.actionKeyHash;
78
76
  const integrity = database.prepare('PRAGMA integrity_check').all();
79
77
  if (integrity.length !== 1 ||
80
78
  integrity[0].integrity_check !== 'ok' ||
@@ -167,7 +165,8 @@ export const verifySnapshot = async (directory) => {
167
165
  };
168
166
  }
169
167
  catch (cause) {
170
- if (isException(cause) && cause.code === 'JG_AGENT_HQ_SNAPSHOT_INVALID')
168
+ if (isException(cause) &&
169
+ ['JG_AGENT_HQ_SNAPSHOT_INVALID', 'JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED'].includes(cause.code))
171
170
  throw cause;
172
171
  throw new Exception('The HQ snapshot is invalid', {
173
172
  code: 'JG_AGENT_HQ_SNAPSHOT_INVALID',
@@ -179,54 +178,18 @@ export const verifySnapshot = async (directory) => {
179
178
  database?.close();
180
179
  }
181
180
  };
182
- /**
183
- * Invalidates current execution authority while retaining immutable receipts, IDs and write intents.
184
- * Source paths remain evidence and require explicit host root selection on startup.
185
- */
186
- const pauseRestoredDatabase = (filename) => {
187
- const database = new DatabaseSync(filename, { enableForeignKeyConstraints: true });
188
- try {
189
- database.exec('PRAGMA synchronous = FULL; BEGIN IMMEDIATE');
190
- try {
191
- const now = Date.now();
192
- database
193
- .prepare("UPDATE hq_collaboration_task SET state = CASE WHEN state = 'stopped' THEN state ELSE 'paused' END, lifecycle_revision = lifecycle_revision + 1, policy_revision = policy_revision + 1, policy = json_set(policy, '$.automaticDispatch', json('false'))")
194
- .run();
195
- database
196
- .prepare("UPDATE hq_task_agent SET state = CASE WHEN state = 'stopped' THEN state ELSE 'paused' END, revision = revision + 1")
197
- .run();
198
- database.prepare('UPDATE hq_skill_grant SET revoked_at = COALESCE(revoked_at, ?)').run(now);
199
- database
200
- .prepare("UPDATE hq_thread SET ownership = 'uncertain', state = 'unknown', updated_at = ?")
201
- .run(now);
202
- database
203
- .prepare("UPDATE hq_action SET delivery = 'uncertain', updated_at = ? WHERE delivery = 'submitted'")
204
- .run(now);
205
- database.exec('UPDATE hq_action SET queue_ready = 0');
206
- database
207
- .prepare("UPDATE hq_native_approval SET state = 'expired', updated_at = ? WHERE state = 'pending'")
208
- .run(now);
209
- database.exec('COMMIT');
210
- }
211
- catch (cause) {
212
- database.exec('ROLLBACK');
213
- throw cause;
214
- }
215
- }
216
- finally {
217
- database.close();
218
- }
219
- };
220
181
  /**
221
182
  * Validates before reserving a new recovery workspace, then validates the staged copy again.
222
183
  * Only this operation's exclusively created destination is removed on a caught failure.
223
184
  * @returns The source inspection; the caller derives the fixed state/plans output paths.
224
185
  * @throws
186
+ * - JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED: The HQ storage version is unsupported
225
187
  * - JG_AGENT_HQ_SNAPSHOT_INVALID: The HQ snapshot is invalid
226
188
  * - JG_AGENT_HQ_SNAPSHOT_FAILED: The HQ snapshot operation failed
227
189
  */
228
190
  export const restoreSnapshot = async (directory, destination) => {
229
191
  const inspection = await verifySnapshot(directory);
192
+ requireRestoreFence(inspection.header.schemaHash);
230
193
  if (isWithinWorkspace(directory, destination))
231
194
  invalidSnapshot();
232
195
  let owned;
@@ -259,7 +222,7 @@ export const restoreSnapshot = async (directory, destination) => {
259
222
  if (staged.manifestSha256 !== inspection.manifestSha256)
260
223
  invalidSnapshot();
261
224
  // the copied manifest remains source evidence; the recovered database deliberately has new fences
262
- pauseRestoredDatabase(join(staging, 'hq.sqlite'));
225
+ applyRestoreFences(join(staging, 'hq.sqlite'), staged.header.schemaHash);
263
226
  await mkdir(join(owned, 'state'), { mode: 0o700 });
264
227
  for (const name of ['hq.sqlite', 'action.key'])
265
228
  await rename(join(staging, name), join(owned, 'state', name));
@@ -287,7 +250,8 @@ export const restoreSnapshot = async (directory, destination) => {
287
250
  }
288
251
  }
289
252
  if (hasErrorCode(cause, 'JG_AGENT_HQ_SNAPSHOT_INVALID') ||
290
- hasErrorCode(cause, 'JG_AGENT_HQ_SNAPSHOT_FAILED'))
253
+ hasErrorCode(cause, 'JG_AGENT_HQ_SNAPSHOT_FAILED') ||
254
+ hasErrorCode(cause, 'JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED'))
291
255
  throw cause;
292
256
  throw new Exception(`The HQ snapshot operation failed: ${extractMessage(cause)}`, {
293
257
  code: 'JG_AGENT_HQ_SNAPSHOT_FAILED',
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Requires a fence implementation explicitly selected by this exact registered schema.
3
+ * @throws
4
+ * - JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED: The HQ storage version is unsupported
5
+ */
6
+ export declare const requireRestoreFence: (schemaHash: string) => void;
7
+ /**
8
+ * Invalidates current execution authority while retaining immutable receipts, IDs and write intents.
9
+ * Source paths remain evidence and require explicit host root selection on startup.
10
+ * @throws
11
+ * - JG_AGENT_HQ_STORAGE_INVALID: The HQ storage is invalid
12
+ * - JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED: The HQ storage version is unsupported
13
+ * - JG_AGENT_HQ_SNAPSHOT_INVALID: The HQ snapshot is invalid
14
+ */
15
+ export declare const applyRestoreFences: (filename: string, schemaHash: string) => void;
@@ -0,0 +1,64 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { Exception } from 'jg-exception';
3
+ import { STORAGE_SCHEMAS, readStorageSchema } from '../storage/schema/index.js';
4
+ import { invalidSnapshot } from './files.js';
5
+ /**
6
+ * Requires a fence implementation explicitly selected by this exact registered schema.
7
+ * @throws
8
+ * - JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED: The HQ storage version is unsupported
9
+ */
10
+ export const requireRestoreFence = (schemaHash) => {
11
+ const schema = STORAGE_SCHEMAS.find((entry) => entry.hash === schemaHash);
12
+ if (schema?.restoreFenceVersion !== 1)
13
+ throw new Exception('The HQ storage version is unsupported', {
14
+ code: 'JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED',
15
+ data: { kind: 'state-conflict' },
16
+ });
17
+ };
18
+ /**
19
+ * Invalidates current execution authority while retaining immutable receipts, IDs and write intents.
20
+ * Source paths remain evidence and require explicit host root selection on startup.
21
+ * @throws
22
+ * - JG_AGENT_HQ_STORAGE_INVALID: The HQ storage is invalid
23
+ * - JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED: The HQ storage version is unsupported
24
+ * - JG_AGENT_HQ_SNAPSHOT_INVALID: The HQ snapshot is invalid
25
+ */
26
+ export const applyRestoreFences = (filename, schemaHash) => {
27
+ requireRestoreFence(schemaHash);
28
+ const database = new DatabaseSync(filename, { enableForeignKeyConstraints: true });
29
+ try {
30
+ const observed = readStorageSchema(database);
31
+ if (!observed || observed.schema.hash !== schemaHash)
32
+ invalidSnapshot();
33
+ requireRestoreFence(observed.schema.hash);
34
+ database.exec('PRAGMA synchronous = FULL; BEGIN IMMEDIATE');
35
+ try {
36
+ const now = Date.now();
37
+ database
38
+ .prepare("UPDATE hq_collaboration_task SET state = CASE WHEN state = 'stopped' THEN state ELSE 'paused' END, lifecycle_revision = lifecycle_revision + 1, policy_revision = policy_revision + 1, policy = json_set(policy, '$.automaticDispatch', json('false'))")
39
+ .run();
40
+ database
41
+ .prepare("UPDATE hq_task_agent SET state = CASE WHEN state = 'stopped' THEN state ELSE 'paused' END, revision = revision + 1")
42
+ .run();
43
+ database.prepare('UPDATE hq_skill_grant SET revoked_at = COALESCE(revoked_at, ?)').run(now);
44
+ database
45
+ .prepare("UPDATE hq_thread SET ownership = 'uncertain', state = 'unknown', updated_at = ?")
46
+ .run(now);
47
+ database
48
+ .prepare("UPDATE hq_action SET delivery = 'uncertain', updated_at = ? WHERE delivery = 'submitted'")
49
+ .run(now);
50
+ database.exec('UPDATE hq_action SET queue_ready = 0');
51
+ database
52
+ .prepare("UPDATE hq_native_approval SET state = 'expired', updated_at = ? WHERE state = 'pending'")
53
+ .run(now);
54
+ database.exec('COMMIT');
55
+ }
56
+ catch (cause) {
57
+ database.exec('ROLLBACK');
58
+ throw cause;
59
+ }
60
+ }
61
+ finally {
62
+ database.close();
63
+ }
64
+ };
@@ -1,2 +1,4 @@
1
1
  export { createHqSnapshot, inspectHqSnapshot, restoreHqSnapshot, registerSnapshotContext, } from './snapshots.js';
2
2
  export type { IHqSnapshotInspection, IHqRestoreOptions, IHqRestoreResult, ISnapshotContext, } from './types.js';
3
+ export { captureSnapshot } from './capture.js';
4
+ export { verifySnapshot } from './database.js';
@@ -1,2 +1,5 @@
1
1
  // trusted Node snapshot operations
2
2
  export { createHqSnapshot, inspectHqSnapshot, restoreHqSnapshot, registerSnapshotContext, } from './snapshots.js';
3
+ // shared capture and offline verification
4
+ export { captureSnapshot } from './capture.js';
5
+ export { verifySnapshot } from './database.js';
@@ -8,15 +8,17 @@ export declare const registerSnapshotContext: (context: ISnapshotContext) => voi
8
8
  * The destination must be absent under an existing canonical parent.
9
9
  * @returns A fully verified local snapshot; remote durability remains the host's responsibility.
10
10
  * @throws
11
+ * - JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED: The HQ storage version is unsupported
11
12
  * - JG_AGENT_HQ_SNAPSHOT_INVALID: The HQ snapshot is invalid
12
13
  * - JG_AGENT_HQ_SNAPSHOT_FAILED: The HQ snapshot operation failed
13
14
  * - JG_AGENT_HQ_ACTION_CONFLICT: The HQ action conflicts with its recorded state
14
15
  */
15
16
  export declare const createHqSnapshot: (runtime: IAgentHq, destination: string) => Promise<IHqSnapshotInspection>;
16
17
  /**
17
- * Validates private files, manifest hashes, current schema, SQLite integrity and exact report payloads.
18
+ * Validates private files, manifest hashes, supported schema, SQLite integrity and exact report payloads.
18
19
  * @returns Bounded metadata; recovery.jsonl retains the streamed plan mapping after restore.
19
20
  * @throws
21
+ * - JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED: The HQ storage version is unsupported
20
22
  * - JG_AGENT_HQ_SNAPSHOT_INVALID: The HQ snapshot is invalid
21
23
  * - JG_AGENT_HQ_SNAPSHOT_FAILED: The HQ snapshot operation failed
22
24
  */
@@ -27,6 +29,7 @@ export declare const inspectHqSnapshot: (directory: string) => Promise<IHqSnapsh
27
29
  * createAgentHq requires the host's explicit allowedRoots and a fresh native ownership decision.
28
30
  * @returns The new state root and separate plan recovery directory, with source snapshot metadata.
29
31
  * @throws
32
+ * - JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED: The HQ storage version is unsupported
30
33
  * - JG_AGENT_HQ_SNAPSHOT_INVALID: The HQ snapshot is invalid
31
34
  * - JG_AGENT_HQ_SNAPSHOT_FAILED: The HQ snapshot operation failed
32
35
  */
@@ -1,17 +1,11 @@
1
- import { createHash } from 'node:crypto';
2
- import { lstat, mkdir, open, realpath, rm } from 'node:fs/promises';
1
+ import { rm } from 'node:fs/promises';
3
2
  import { dirname, isAbsolute, join, resolve } from 'node:path';
4
3
  import { Worker } from 'node:worker_threads';
5
- import { hashValue } from 'jg-crypto';
6
4
  import { Exception, extractMessage, hasErrorCode } from 'jg-exception';
7
- import { writePrivateFile } from 'jg-fs';
8
- import { withPlanDocumentLocks } from 'jg-plans';
9
- import { isWithinWorkspace } from '../runtime/state/index.js';
10
5
  import { acquireHqLock } from '../locks/index.js';
11
- import { observeSnapshotPlan } from './artifacts.js';
12
- import { SNAPSHOT_COMPLETE, SNAPSHOT_COMPANIONS, SNAPSHOT_LINE_BYTES, SNAPSHOT_MANIFEST, } from './constants.js';
13
- import { copySnapshotFile, invalidSnapshot, reserveSnapshotDirectory, syncSnapshotDirectory, } from './files.js';
14
- import { RestoreOptionsSchema, SnapshotHeaderSchema, SnapshotRecordSchema, SnapshotWorkerResponseSchema, } from './types.js';
6
+ import { captureSnapshot } from './capture.js';
7
+ import { invalidSnapshot, syncSnapshotDirectory } from './files.js';
8
+ import { RestoreOptionsSchema, SnapshotWorkerResponseSchema, } from './types.js';
15
9
  const contexts = new WeakMap();
16
10
  /** Binds trusted Node runtime state without adding worker handles or backup commands to RPC. */
17
11
  export const registerSnapshotContext = (context) => {
@@ -21,6 +15,7 @@ export const registerSnapshotContext = (context) => {
21
15
  * Runs full SQLite verification/restoration off the caller's event loop and confirms worker exit.
22
16
  * @returns The validated snapshot metadata.
23
17
  * @throws
18
+ * - JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED: The HQ storage version is unsupported
24
19
  * - JG_AGENT_HQ_SNAPSHOT_INVALID: The HQ snapshot is invalid
25
20
  * - JG_AGENT_HQ_SNAPSHOT_FAILED: The HQ snapshot operation failed
26
21
  */
@@ -60,7 +55,9 @@ const runSnapshotWorker = async (input) => {
60
55
  data: {
61
56
  kind: response.code === 'JG_AGENT_HQ_SNAPSHOT_INVALID'
62
57
  ? 'invalid-request'
63
- : 'operation-failed',
58
+ : response.code === 'JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED'
59
+ ? 'state-conflict'
60
+ : 'operation-failed',
64
61
  },
65
62
  }));
66
63
  else
@@ -68,57 +65,13 @@ const runSnapshotWorker = async (input) => {
68
65
  });
69
66
  });
70
67
  };
71
- /**
72
- * Copies one plan under its shared document locks; corrupt or missing source evidence stays explicit.
73
- * @returns One fixed-size inventory record, with no repaired or invented authority.
74
- * @throws
75
- * - JG_AGENT_HQ_SNAPSHOT_INVALID: The HQ snapshot is invalid
76
- * - JG_AGENT_HQ_SNAPSHOT_FAILED: The HQ snapshot operation failed
77
- */
78
- const capturePlan = async (context, directory, snapshot) => {
79
- const record = {
80
- kind: 'plan',
81
- key: hashValue(directory),
82
- sourceDirectory: directory,
83
- planId: null,
84
- revision: null,
85
- plan: null,
86
- milestones: null,
87
- coordination: null,
88
- diagnostics: [],
89
- };
90
- if (!context.allowedRoots.some((root) => isWithinWorkspace(root, directory))) {
91
- record.diagnostics.push('plan-unavailable');
92
- return record;
93
- }
94
- let exists = true;
95
- try {
96
- if ((await realpath(directory)) !== directory || !(await lstat(directory)).isDirectory())
97
- invalidSnapshot();
98
- }
99
- catch (cause) {
100
- if (hasErrorCode(cause, 'ENOENT'))
101
- exists = false;
102
- else
103
- throw cause;
104
- }
105
- const output = join(snapshot, 'plans', record.key);
106
- await mkdir(output, { mode: 0o700 });
107
- if (exists)
108
- await withPlanDocumentLocks(directory, ['plan', 'milestones'], async () => {
109
- for (const [field, filename, maximumBytes] of SNAPSHOT_COMPANIONS)
110
- record[field] = await copySnapshotFile(join(directory, filename), join(output, filename), maximumBytes, false, true);
111
- });
112
- Object.assign(record, await observeSnapshotPlan(output, record));
113
- await syncSnapshotDirectory(output);
114
- return record;
115
- };
116
68
  /**
117
69
  * Captures a quiescent running HQ, host tables, private key and registered plan companions.
118
70
  * The host must pause all of its writers. Busy checkpoints never interrupt native work.
119
71
  * The destination must be absent under an existing canonical parent.
120
72
  * @returns A fully verified local snapshot; remote durability remains the host's responsibility.
121
73
  * @throws
74
+ * - JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED: The HQ storage version is unsupported
122
75
  * - JG_AGENT_HQ_SNAPSHOT_INVALID: The HQ snapshot is invalid
123
76
  * - JG_AGENT_HQ_SNAPSHOT_FAILED: The HQ snapshot operation failed
124
77
  * - JG_AGENT_HQ_ACTION_CONFLICT: The HQ action conflicts with its recorded state
@@ -133,64 +86,23 @@ export const createHqSnapshot = async (runtime, destination) => {
133
86
  invalidSnapshot();
134
87
  let owned;
135
88
  try {
136
- await context.maintenance(async (storage) => {
89
+ return await context.maintenance(async (storage) => {
137
90
  let release;
138
91
  let failure;
139
92
  try {
140
93
  release = await acquireHqLock(context.coordinationRoot, 'snapshot', { waitTimeoutMs: 0 });
141
- owned = await reserveSnapshotDirectory(destination);
142
- await mkdir(join(owned, 'plans'), { mode: 0o700 });
143
- const manifest = await open(join(owned, SNAPSHOT_MANIFEST), 'wx', 0o600);
144
- const hash = createHash('sha256');
145
- let records = 0;
146
- const append = async (record) => {
147
- const bytes = Buffer.from(JSON.stringify(SnapshotRecordSchema.parse(record)) + '\n');
148
- if (bytes.length > SNAPSHOT_LINE_BYTES)
149
- invalidSnapshot();
150
- await manifest.writeFile(bytes);
151
- hash.update(bytes);
152
- records++;
153
- };
154
- try {
155
- await append(SnapshotHeaderSchema.parse({
156
- kind: 'header',
157
- format: 1,
158
- createdAt: new Date().toISOString(),
159
- schemaHash: storage.schemaHash,
160
- sourceStateRoot: context.stateRoot,
161
- sourceCoordinationRoot: context.coordinationRoot,
162
- sourceAllowedRoots: context.allowedRoots,
163
- }));
164
- const database = await open(join(owned, 'hq.sqlite'), 'wx', 0o600);
165
- await database.close();
166
- await context.store.snapshotBackup(join(owned, 'hq.sqlite'));
167
- const capturedDatabase = await copySnapshotFile(join(owned, 'hq.sqlite'), null);
168
- if (!capturedDatabase)
169
- invalidSnapshot();
170
- await append({ kind: 'database', file: capturedDatabase });
171
- const key = await copySnapshotFile(join(context.stateRoot, 'action.key'), join(owned, 'action.key'), 32);
172
- if (key?.bytes !== 32 || key.sha256 !== context.actionKeyHash)
173
- invalidSnapshot();
174
- await append({ kind: 'action-key', file: key });
175
- let after = '';
176
- for (;;) {
177
- const page = await context.store.snapshotPlans(after);
178
- for (const directory of page) {
179
- await append(await capturePlan(context, directory, owned));
180
- after = directory;
181
- }
182
- if (page.length < 128)
183
- break;
184
- }
185
- await manifest.sync();
186
- await syncSnapshotDirectory(join(owned, 'plans'));
187
- await writePrivateFile(join(owned, SNAPSHOT_COMPLETE), JSON.stringify({ manifestSha256: hash.digest('hex'), records }) + '\n', { rootPath: owned, isExclusive: true });
188
- await syncSnapshotDirectory(owned);
189
- await syncSnapshotDirectory(dirname(owned));
190
- }
191
- finally {
192
- await manifest.close();
193
- }
94
+ const result = await captureSnapshot({
95
+ stateRoot: context.stateRoot,
96
+ coordinationRoot: context.coordinationRoot,
97
+ allowedRoots: context.allowedRoots,
98
+ actionKeyHash: context.actionKeyHash,
99
+ schemaHash: storage.schemaHash,
100
+ backupDatabase: (path) => context.store.snapshotBackup(path),
101
+ readPlans: (after) => context.store.snapshotPlans(after),
102
+ verify: inspectHqSnapshot,
103
+ }, destination);
104
+ owned = result.directory;
105
+ return result;
194
106
  }
195
107
  catch (cause) {
196
108
  failure = cause;
@@ -207,7 +119,6 @@ export const createHqSnapshot = async (runtime, destination) => {
207
119
  }
208
120
  }
209
121
  }, true);
210
- return await inspectHqSnapshot(owned);
211
122
  }
212
123
  catch (cause) {
213
124
  if (owned) {
@@ -221,7 +132,8 @@ export const createHqSnapshot = async (runtime, destination) => {
221
132
  }
222
133
  if (hasErrorCode(cause, 'JG_AGENT_HQ_ACTION_CONFLICT') ||
223
134
  hasErrorCode(cause, 'JG_AGENT_HQ_SNAPSHOT_INVALID') ||
224
- hasErrorCode(cause, 'JG_AGENT_HQ_SNAPSHOT_FAILED'))
135
+ hasErrorCode(cause, 'JG_AGENT_HQ_SNAPSHOT_FAILED') ||
136
+ hasErrorCode(cause, 'JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED'))
225
137
  throw cause;
226
138
  if (hasErrorCode(cause, 'JG_PLANS_LOCK_BUSY') ||
227
139
  hasErrorCode(cause, 'JG_AGENT_HQ_LOCK_TIMEOUT'))
@@ -238,9 +150,10 @@ export const createHqSnapshot = async (runtime, destination) => {
238
150
  }
239
151
  };
240
152
  /**
241
- * Validates private files, manifest hashes, current schema, SQLite integrity and exact report payloads.
153
+ * Validates private files, manifest hashes, supported schema, SQLite integrity and exact report payloads.
242
154
  * @returns Bounded metadata; recovery.jsonl retains the streamed plan mapping after restore.
243
155
  * @throws
156
+ * - JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED: The HQ storage version is unsupported
244
157
  * - JG_AGENT_HQ_SNAPSHOT_INVALID: The HQ snapshot is invalid
245
158
  * - JG_AGENT_HQ_SNAPSHOT_FAILED: The HQ snapshot operation failed
246
159
  */
@@ -255,6 +168,7 @@ export const inspectHqSnapshot = async (directory) => {
255
168
  * createAgentHq requires the host's explicit allowedRoots and a fresh native ownership decision.
256
169
  * @returns The new state root and separate plan recovery directory, with source snapshot metadata.
257
170
  * @throws
171
+ * - JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED: The HQ storage version is unsupported
258
172
  * - JG_AGENT_HQ_SNAPSHOT_INVALID: The HQ snapshot is invalid
259
173
  * - JG_AGENT_HQ_SNAPSHOT_FAILED: The HQ snapshot operation failed
260
174
  */
@@ -14,7 +14,11 @@ try {
14
14
  }
15
15
  catch (cause) {
16
16
  const error = isException(cause) &&
17
- ['JG_AGENT_HQ_SNAPSHOT_INVALID', 'JG_AGENT_HQ_SNAPSHOT_FAILED'].includes(cause.code)
17
+ [
18
+ 'JG_AGENT_HQ_SNAPSHOT_INVALID',
19
+ 'JG_AGENT_HQ_SNAPSHOT_FAILED',
20
+ 'JG_AGENT_HQ_STORAGE_VERSION_UNSUPPORTED',
21
+ ].includes(cause.code)
18
22
  ? cause
19
23
  : new Exception(`The HQ snapshot operation failed: ${extractMessage(cause)}`, {
20
24
  code: 'JG_AGENT_HQ_SNAPSHOT_FAILED',