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.
- package/CHANGELOG.md +9 -0
- package/README.md +4 -4
- package/api/README.md +51 -8
- package/dist/cli/cli.js +35 -3
- package/dist/cli/utilities.d.ts +3 -0
- package/dist/cli/utilities.js +30 -0
- package/dist/contracts/index.d.ts +2 -2
- package/dist/contracts/index.js +1 -1
- package/dist/contracts/types.d.ts +102 -0
- package/dist/contracts/types.js +24 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/plans/authority.d.ts +2 -0
- package/dist/plans/authority.js +8 -0
- package/dist/plans/documents.d.ts +17 -0
- package/dist/plans/documents.js +24 -2
- package/dist/plans/files.js +3 -1
- package/dist/plans/plans.js +3 -1
- package/dist/plans/types.d.ts +5 -0
- package/dist/providers/claude/claude.js +2 -0
- package/dist/providers/claude/history/types.d.ts +23 -0
- package/dist/providers/claude/transformers.js +14 -1
- package/dist/providers/codex/codex.js +4 -0
- package/dist/providers/codex/transformers.js +30 -3
- package/dist/providers/codex/types.d.ts +34 -1
- package/dist/providers/codex/types.js +11 -2
- package/dist/runtime/runtime.d.ts +2 -0
- package/dist/runtime/runtime.js +2 -0
- package/dist/runtime/state/index.d.ts +1 -1
- package/dist/runtime/state/index.js +1 -1
- package/dist/runtime/state/state.d.ts +16 -1
- package/dist/runtime/state/state.js +94 -3
- package/dist/snapshots/capture.d.ts +14 -0
- package/dist/snapshots/capture.js +142 -0
- package/dist/snapshots/database.d.ts +2 -0
- package/dist/snapshots/database.js +15 -51
- package/dist/snapshots/fences.d.ts +15 -0
- package/dist/snapshots/fences.js +64 -0
- package/dist/snapshots/index.d.ts +2 -0
- package/dist/snapshots/index.js +3 -0
- package/dist/snapshots/snapshots.d.ts +4 -1
- package/dist/snapshots/snapshots.js +27 -113
- package/dist/snapshots/snapshots.worker.js +5 -1
- package/dist/snapshots/types.d.ts +11 -0
- package/dist/snapshots/types.js +5 -1
- package/dist/storage/index.d.ts +2 -1
- package/dist/storage/index.js +2 -1
- package/dist/storage/schema/constants.d.ts +2 -0
- package/dist/storage/schema/constants.js +3 -0
- package/dist/storage/schema/index.d.ts +4 -0
- package/dist/storage/schema/index.js +5 -0
- package/dist/storage/schema/schema.d.ts +16 -0
- package/dist/storage/schema/schema.js +73 -0
- package/dist/storage/schema/types.d.ts +17 -0
- package/dist/storage/schema/types.js +8 -0
- package/dist/storage/schema/v1-to-v2.sql +1 -0
- package/dist/storage/schema/v1.sql +538 -0
- package/dist/storage/types.d.ts +0 -5
- package/dist/storage/types.js +0 -6
- package/dist/storage/upgrade/index.d.ts +2 -0
- package/dist/storage/upgrade/index.js +2 -0
- package/dist/storage/upgrade/types.d.ts +61 -0
- package/dist/storage/upgrade/types.js +42 -0
- package/dist/storage/upgrade/upgrade.d.ts +33 -0
- package/dist/storage/upgrade/upgrade.js +196 -0
- package/dist/storage/upgrade/upgrade.worker.d.ts +1 -0
- package/dist/storage/upgrade/upgrade.worker.js +220 -0
- package/dist/store/schema.d.ts +2 -0
- package/dist/store/schema.js +16 -25
- package/dist/store/schema.sql +2 -1
- package/dist/store/store.d.ts +2 -0
- package/dist/store/store.js +2 -0
- package/docs/recovery.md +15 -1
- package/errors/README.md +34 -28
- 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,
|
|
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 {
|
|
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'
|
|
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
|
|
72
|
-
|
|
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 =
|
|
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) &&
|
|
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
|
-
|
|
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';
|
package/dist/snapshots/index.js
CHANGED
|
@@ -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,
|
|
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 {
|
|
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 {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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
|
-
: '
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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,
|
|
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
|
-
[
|
|
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',
|