@ashlr/hub 3.2.6 → 3.3.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 +290 -0
- package/README.md +4 -1
- package/dist/build-identity.json +2 -2
- package/dist/cli/activation.d.ts +41 -0
- package/dist/cli/activation.js +485 -0
- package/dist/cli/activation.js.map +1 -0
- package/dist/cli/audit.js +18 -0
- package/dist/cli/audit.js.map +1 -1
- package/dist/cli/backlog.d.ts +0 -5
- package/dist/cli/backlog.js +18 -0
- package/dist/cli/backlog.js.map +1 -1
- package/dist/cli/comms.js +20 -0
- package/dist/cli/comms.js.map +1 -1
- package/dist/cli/completions.d.ts +6 -1
- package/dist/cli/completions.js +37 -11
- package/dist/cli/completions.js.map +1 -1
- package/dist/cli/conductor-permit.d.ts +1 -0
- package/dist/cli/conductor-permit.js +122 -0
- package/dist/cli/conductor-permit.js.map +1 -0
- package/dist/cli/daemon.js +10 -3
- package/dist/cli/daemon.js.map +1 -1
- package/dist/cli/demo.d.ts +0 -27
- package/dist/cli/demo.js +17 -0
- package/dist/cli/demo.js.map +1 -1
- package/dist/cli/goals.d.ts +2 -0
- package/dist/cli/goals.js +90 -5
- package/dist/cli/goals.js.map +1 -1
- package/dist/cli/help.js +21 -3
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/inbox.d.ts +14 -2
- package/dist/cli/inbox.js +137 -23
- package/dist/cli/inbox.js.map +1 -1
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +16 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/loop.js +26 -5
- package/dist/cli/loop.js.map +1 -1
- package/dist/cli/onboard.d.ts +0 -13
- package/dist/cli/onboard.js +20 -0
- package/dist/cli/onboard.js.map +1 -1
- package/dist/cli/resources.js +15 -0
- package/dist/cli/resources.js.map +1 -1
- package/dist/cli/run.js +1 -1
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/sandbox.js +43 -0
- package/dist/cli/sandbox.js.map +1 -1
- package/dist/cli/tui.d.ts +0 -6
- package/dist/cli/tui.js +14 -0
- package/dist/cli/tui.js.map +1 -1
- package/dist/core/comms/elon-dialogue.js +16 -7
- package/dist/core/comms/elon-dialogue.js.map +1 -1
- package/dist/core/config.js +70 -0
- package/dist/core/config.js.map +1 -1
- package/dist/core/daemon/activation-permit.d.ts +357 -33
- package/dist/core/daemon/activation-permit.js +1585 -183
- package/dist/core/daemon/activation-permit.js.map +1 -1
- package/dist/core/daemon/goal-conductor-permit-operator.d.ts +75 -0
- package/dist/core/daemon/goal-conductor-permit-operator.js +842 -0
- package/dist/core/daemon/goal-conductor-permit-operator.js.map +1 -0
- package/dist/core/daemon/loop.d.ts +13 -1
- package/dist/core/daemon/loop.js +294 -29
- package/dist/core/daemon/loop.js.map +1 -1
- package/dist/core/daemon/runtime-activation-resident-start-protocol.d.ts +141 -0
- package/dist/core/daemon/runtime-activation-resident-start-protocol.js +462 -0
- package/dist/core/daemon/runtime-activation-resident-start-protocol.js.map +1 -0
- package/dist/core/daemon/runtime-activation-resident-start-runtime.d.ts +27 -0
- package/dist/core/daemon/runtime-activation-resident-start-runtime.js +28 -0
- package/dist/core/daemon/runtime-activation-resident-start-runtime.js.map +1 -0
- package/dist/core/daemon/runtime-activation-stopped-consumer.d.ts +139 -0
- package/dist/core/daemon/runtime-activation-stopped-consumer.js +995 -0
- package/dist/core/daemon/runtime-activation-stopped-consumer.js.map +1 -0
- package/dist/core/daemon/runtime-activation-stopped-runtime.d.ts +36 -0
- package/dist/core/daemon/runtime-activation-stopped-runtime.js +134 -0
- package/dist/core/daemon/runtime-activation-stopped-runtime.js.map +1 -0
- package/dist/core/daemon/runtime-activation-transaction.d.ts +41 -17
- package/dist/core/daemon/runtime-activation-transaction.js +80 -11
- package/dist/core/daemon/runtime-activation-transaction.js.map +1 -1
- package/dist/core/daemon/runtime-release-dependency-inventory.js +1 -0
- package/dist/core/daemon/runtime-release-dependency-inventory.js.map +1 -1
- package/dist/core/daemon/service-install-authority.d.ts +0 -4
- package/dist/core/daemon/service-install-authority.js +18 -3
- package/dist/core/daemon/service-install-authority.js.map +1 -1
- package/dist/core/dashboard.d.ts +10 -0
- package/dist/core/dashboard.js +103 -28
- package/dist/core/dashboard.js.map +1 -1
- package/dist/core/effective-config.js +7 -0
- package/dist/core/effective-config.js.map +1 -1
- package/dist/core/fleet/automerge-pass.js +33 -0
- package/dist/core/fleet/automerge-pass.js.map +1 -1
- package/dist/core/fleet/decisions-ledger.js +35 -12
- package/dist/core/fleet/decisions-ledger.js.map +1 -1
- package/dist/core/fleet/external-skill-git-capture.js +9 -1
- package/dist/core/fleet/external-skill-git-capture.js.map +1 -1
- package/dist/core/fleet/judge-decision-metadata.d.ts +8 -1
- package/dist/core/fleet/judge-decision-metadata.js +14 -1
- package/dist/core/fleet/judge-decision-metadata.js.map +1 -1
- package/dist/core/fleet/manager.d.ts +15 -1
- package/dist/core/fleet/manager.js +108 -30
- package/dist/core/fleet/manager.js.map +1 -1
- package/dist/core/fleet/post-merge-credit.d.ts +134 -9
- package/dist/core/fleet/post-merge-credit.js +350 -10
- package/dist/core/fleet/post-merge-credit.js.map +1 -1
- package/dist/core/fleet/quota.d.ts +74 -7
- package/dist/core/fleet/quota.js +688 -34
- package/dist/core/fleet/quota.js.map +1 -1
- package/dist/core/fleet/self-improve.d.ts +34 -1
- package/dist/core/fleet/self-improve.js +106 -1
- package/dist/core/fleet/self-improve.js.map +1 -1
- package/dist/core/fleet/skill-library.d.ts +21 -2
- package/dist/core/fleet/skill-library.js +54 -12
- package/dist/core/fleet/skill-library.js.map +1 -1
- package/dist/core/fleet/status.js +7 -5
- package/dist/core/fleet/status.js.map +1 -1
- package/dist/core/fleet/worked-ledger.d.ts +22 -0
- package/dist/core/fleet/worked-ledger.js +32 -0
- package/dist/core/fleet/worked-ledger.js.map +1 -1
- package/dist/core/foundry/automerge-scope-policy.d.ts +2 -2
- package/dist/core/foundry/automerge-scope-policy.js +9 -2
- package/dist/core/foundry/automerge-scope-policy.js.map +1 -1
- package/dist/core/goals/advance.js +34 -5
- package/dist/core/goals/advance.js.map +1 -1
- package/dist/core/goals/conductor-quota.d.ts +38 -0
- package/dist/core/goals/conductor-quota.js +110 -0
- package/dist/core/goals/conductor-quota.js.map +1 -0
- package/dist/core/goals/conductor-target.d.ts +24 -0
- package/dist/core/goals/conductor-target.js +35 -0
- package/dist/core/goals/conductor-target.js.map +1 -0
- package/dist/core/goals/conductor.d.ts +15 -0
- package/dist/core/goals/conductor.js +116 -9
- package/dist/core/goals/conductor.js.map +1 -1
- package/dist/core/goals/store.d.ts +29 -5
- package/dist/core/goals/store.js +197 -30
- package/dist/core/goals/store.js.map +1 -1
- package/dist/core/goals/timestamp-repair.d.ts +50 -0
- package/dist/core/goals/timestamp-repair.js +740 -0
- package/dist/core/goals/timestamp-repair.js.map +1 -0
- package/dist/core/inbox/apply.js +5 -2
- package/dist/core/inbox/apply.js.map +1 -1
- package/dist/core/inbox/merge.d.ts +94 -1
- package/dist/core/inbox/merge.js +349 -16
- package/dist/core/inbox/merge.js.map +1 -1
- package/dist/core/integrations/github.d.ts +2 -0
- package/dist/core/integrations/github.js +2 -1
- package/dist/core/integrations/github.js.map +1 -1
- package/dist/core/integrations/pulse-sync.js +13 -4
- package/dist/core/integrations/pulse-sync.js.map +1 -1
- package/dist/core/learn/reflect.d.ts +42 -4
- package/dist/core/learn/reflect.js +56 -5
- package/dist/core/learn/reflect.js.map +1 -1
- package/dist/core/run/best-of-n.d.ts +18 -0
- package/dist/core/run/best-of-n.js +163 -18
- package/dist/core/run/best-of-n.js.map +1 -1
- package/dist/core/run/engines.d.ts +8 -0
- package/dist/core/run/engines.js +9 -1
- package/dist/core/run/engines.js.map +1 -1
- package/dist/core/run/learned-router.js +28 -3
- package/dist/core/run/learned-router.js.map +1 -1
- package/dist/core/run/orchestrator.d.ts +7 -2
- package/dist/core/run/orchestrator.js +140 -60
- package/dist/core/run/orchestrator.js.map +1 -1
- package/dist/core/strategy/goal-planner.js +7 -1
- package/dist/core/strategy/goal-planner.js.map +1 -1
- package/dist/core/swarm/planner.d.ts +2 -2
- package/dist/core/swarm/planner.js +52 -5
- package/dist/core/swarm/planner.js.map +1 -1
- package/dist/core/swarm/rollback.d.ts +11 -0
- package/dist/core/swarm/rollback.js +54 -0
- package/dist/core/swarm/rollback.js.map +1 -1
- package/dist/core/swarm/runner.js +123 -11
- package/dist/core/swarm/runner.js.map +1 -1
- package/dist/core/types.d.ts +139 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/web/api.js +105 -7
- package/dist/core/web/api.js.map +1 -1
- package/dist/core/web/control.d.ts +20 -0
- package/dist/core/web/control.js +49 -34
- package/dist/core/web/control.js.map +1 -1
- package/dist/core/web/fleet-status-cache.d.ts +68 -0
- package/dist/core/web/fleet-status-cache.js +120 -0
- package/dist/core/web/fleet-status-cache.js.map +1 -0
- package/dist/core/web/public/next/assets/index-CY0U241s.css +1 -0
- package/dist/core/web/public/next/assets/index-Ct0mf8n4.js +69 -0
- package/dist/core/web/public/next/assets/index-Ct0mf8n4.js.map +1 -0
- package/dist/core/web/public/next/index.html +15 -0
- package/dist/release-dependency-inventory.json +1 -1
- package/dist/tui/app.js +2 -2
- package/dist/tui/app.js.map +1 -1
- package/dist/tui/render.d.ts +2 -0
- package/dist/tui/render.js +162 -4
- package/dist/tui/render.js.map +1 -1
- package/docs/RUNTIME_ACTIVATION_AUTHORITY.md +126 -21
- package/docs/contracts/CONTRACT-M521.md +102 -0
- package/package.json +20 -3
- package/schema/config.schema.json +800 -5
- package/node_modules/which/CHANGELOG.md +0 -166
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic, offline repair for stale Goal.updatedAt values.
|
|
3
|
+
*
|
|
4
|
+
* The repair never consults wall-clock or filesystem timestamps. A goal's
|
|
5
|
+
* repaired value is the maximum timestamp already present in that goal and its
|
|
6
|
+
* milestones. Dry-run is strictly read-only. Apply is bound to an exact plan,
|
|
7
|
+
* preserves immutable raw backups, and records deterministic receipts.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
10
|
+
import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, openSync, opendirSync, readSync, } from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { join, resolve } from 'node:path';
|
|
13
|
+
import { acquireLocalStoreLock, releaseLocalStoreLock } from '../fleet/local-store-lock.js';
|
|
14
|
+
import { readImmutablePrivateRecordPoint, recoverImmutablePrivateRecordStore, writeImmutablePrivateRecord, } from '../util/immutable-private-record-store.js';
|
|
15
|
+
import { writePrivateFileAtomically } from '../util/private-file-write.js';
|
|
16
|
+
import { assurePrivateStoragePath } from '../util/private-storage.js';
|
|
17
|
+
import { goalsDir, goalSnapshotDigest, isValidGoalRecord, isValidGoalRecordForTimestampRepair, } from './store.js';
|
|
18
|
+
const SCHEMA_VERSION = 1;
|
|
19
|
+
const MAX_GOAL_FILES = 200;
|
|
20
|
+
const MAX_DIRECTORY_ENTRIES = 512;
|
|
21
|
+
const MAX_GOAL_FILE_BYTES = 256 * 1024;
|
|
22
|
+
const LOCK_WAIT_MS = 2_000;
|
|
23
|
+
const REPAIR_ROOT = '.timestamp-repair-artifacts';
|
|
24
|
+
const MAX_ARTIFACT_BYTES = 512 * 1024;
|
|
25
|
+
function digest(value) {
|
|
26
|
+
return createHash('sha256').update(value).digest('hex');
|
|
27
|
+
}
|
|
28
|
+
function sameIdentity(left, right) {
|
|
29
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
30
|
+
}
|
|
31
|
+
function ownedByCurrentUser(stat) {
|
|
32
|
+
return typeof process.getuid !== 'function' || stat.uid === BigInt(process.getuid());
|
|
33
|
+
}
|
|
34
|
+
function assertSupportedCustodyPlatform() {
|
|
35
|
+
if (process.platform !== 'linux' && process.platform !== 'darwin') {
|
|
36
|
+
throw new Error(`goal timestamp repair is unsupported on platform ${process.platform}`);
|
|
37
|
+
}
|
|
38
|
+
if (typeof process.getuid !== 'function') {
|
|
39
|
+
throw new Error('goal timestamp repair requires POSIX uid custody');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function assureDarwinSourcePath(path, kind) {
|
|
43
|
+
if (process.platform !== 'darwin')
|
|
44
|
+
return;
|
|
45
|
+
const assurance = assurePrivateStoragePath(path, kind, 'inspect-existing', {
|
|
46
|
+
anchorPath: resolve(homedir()),
|
|
47
|
+
});
|
|
48
|
+
if (!assurance.ok)
|
|
49
|
+
throw new Error(`goal source ACL custody refused: ${path}: ${assurance.reason}`);
|
|
50
|
+
}
|
|
51
|
+
function stableRegularFile(stat) {
|
|
52
|
+
return stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1n && ownedByCurrentUser(stat) &&
|
|
53
|
+
(process.platform === 'win32' || (stat.mode & 18n) === 0n);
|
|
54
|
+
}
|
|
55
|
+
function readStableFile(path, maximumBytes) {
|
|
56
|
+
let fd;
|
|
57
|
+
try {
|
|
58
|
+
if (typeof fsConstants.O_NOFOLLOW !== 'number') {
|
|
59
|
+
throw new Error('goal timestamp repair requires O_NOFOLLOW file custody');
|
|
60
|
+
}
|
|
61
|
+
const noFollow = fsConstants.O_NOFOLLOW;
|
|
62
|
+
try {
|
|
63
|
+
fd = openSync(path, fsConstants.O_RDONLY | noFollow);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
throw new Error(`unsafe or unavailable file: ${path}`, { cause: error });
|
|
67
|
+
}
|
|
68
|
+
const openedBefore = fstatSync(fd, { bigint: true });
|
|
69
|
+
if (!stableRegularFile(openedBefore) || openedBefore.size < 1n ||
|
|
70
|
+
openedBefore.size > BigInt(maximumBytes)) {
|
|
71
|
+
throw new Error(`unsafe or oversized opened file: ${path}`);
|
|
72
|
+
}
|
|
73
|
+
assureDarwinSourcePath(path, 'file');
|
|
74
|
+
const namedBefore = lstatSync(path, { bigint: true });
|
|
75
|
+
if (!stableRegularFile(namedBefore) || !sameIdentity(openedBefore, namedBefore) ||
|
|
76
|
+
namedBefore.size < 1n || namedBefore.size > BigInt(maximumBytes) ||
|
|
77
|
+
namedBefore.size !== openedBefore.size || namedBefore.mtimeNs !== openedBefore.mtimeNs ||
|
|
78
|
+
namedBefore.ctimeNs !== openedBefore.ctimeNs)
|
|
79
|
+
throw new Error(`file changed while opening: ${path}`);
|
|
80
|
+
const raw = Buffer.alloc(Number(openedBefore.size));
|
|
81
|
+
let offset = 0;
|
|
82
|
+
while (offset < raw.length) {
|
|
83
|
+
const bytesRead = readSync(fd, raw, offset, raw.length - offset, offset);
|
|
84
|
+
if (bytesRead <= 0)
|
|
85
|
+
throw new Error(`short read: ${path}`);
|
|
86
|
+
offset += bytesRead;
|
|
87
|
+
}
|
|
88
|
+
const growthProbe = Buffer.allocUnsafe(1);
|
|
89
|
+
const grew = readSync(fd, growthProbe, 0, 1, raw.length) !== 0;
|
|
90
|
+
const openedAfter = fstatSync(fd, { bigint: true });
|
|
91
|
+
const namedAfter = lstatSync(path, { bigint: true });
|
|
92
|
+
assureDarwinSourcePath(path, 'file');
|
|
93
|
+
const namedAssured = lstatSync(path, { bigint: true });
|
|
94
|
+
if (grew || !stableRegularFile(openedAfter) || !stableRegularFile(namedAfter) ||
|
|
95
|
+
!stableRegularFile(namedAssured) ||
|
|
96
|
+
!sameIdentity(openedBefore, openedAfter) || !sameIdentity(openedBefore, namedAfter) ||
|
|
97
|
+
!sameIdentity(openedBefore, namedAssured) ||
|
|
98
|
+
openedAfter.size < 1n || openedAfter.size > BigInt(maximumBytes) ||
|
|
99
|
+
namedAfter.size < 1n || namedAfter.size > BigInt(maximumBytes) ||
|
|
100
|
+
namedAssured.size < 1n || namedAssured.size > BigInt(maximumBytes) ||
|
|
101
|
+
openedAfter.size !== openedBefore.size || namedAfter.size !== openedBefore.size ||
|
|
102
|
+
namedAssured.size !== openedBefore.size || openedAfter.size !== BigInt(raw.length) ||
|
|
103
|
+
openedAfter.mtimeNs !== openedBefore.mtimeNs || namedAfter.mtimeNs !== openedBefore.mtimeNs ||
|
|
104
|
+
namedAssured.mtimeNs !== openedBefore.mtimeNs || openedAfter.ctimeNs !== openedBefore.ctimeNs ||
|
|
105
|
+
namedAfter.ctimeNs !== openedBefore.ctimeNs || namedAssured.ctimeNs !== openedBefore.ctimeNs)
|
|
106
|
+
throw new Error(`file changed while reading: ${path}`);
|
|
107
|
+
return raw;
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
if (fd !== undefined)
|
|
111
|
+
closeSync(fd);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function derivedGoalUpdatedAt(goal) {
|
|
115
|
+
const timestamps = [
|
|
116
|
+
goal.createdAt,
|
|
117
|
+
goal.updatedAt,
|
|
118
|
+
...goal.milestones.flatMap((milestone) => [milestone.createdAt, milestone.updatedAt]),
|
|
119
|
+
];
|
|
120
|
+
let maximum = Number.NEGATIVE_INFINITY;
|
|
121
|
+
for (const timestamp of timestamps) {
|
|
122
|
+
const parsed = Date.parse(timestamp);
|
|
123
|
+
if (!Number.isFinite(parsed))
|
|
124
|
+
throw new Error(`goal ${goal.id} contains an invalid domain timestamp`);
|
|
125
|
+
maximum = Math.max(maximum, parsed);
|
|
126
|
+
}
|
|
127
|
+
if (!Number.isFinite(maximum))
|
|
128
|
+
throw new Error(`goal ${goal.id} has no domain timestamp`);
|
|
129
|
+
return new Date(maximum).toISOString();
|
|
130
|
+
}
|
|
131
|
+
function jsonStringTokenAt(raw, start) {
|
|
132
|
+
if (raw[start] !== 0x22)
|
|
133
|
+
throw new Error('JSON string token does not start with a quote');
|
|
134
|
+
let escaped = false;
|
|
135
|
+
for (let index = start + 1; index < raw.length; index += 1) {
|
|
136
|
+
const byte = raw[index];
|
|
137
|
+
if (escaped) {
|
|
138
|
+
escaped = false;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (byte === 0x5c) {
|
|
142
|
+
escaped = true;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (byte === 0x22)
|
|
146
|
+
return { start, end: index + 1 };
|
|
147
|
+
if (byte < 0x20)
|
|
148
|
+
throw new Error('JSON string contains an unescaped control byte');
|
|
149
|
+
}
|
|
150
|
+
throw new Error('unterminated JSON string token');
|
|
151
|
+
}
|
|
152
|
+
function skipJsonWhitespace(raw, start) {
|
|
153
|
+
let index = start;
|
|
154
|
+
while (index < raw.length && (raw[index] === 0x20 || raw[index] === 0x09 || raw[index] === 0x0a || raw[index] === 0x0d))
|
|
155
|
+
index += 1;
|
|
156
|
+
return index;
|
|
157
|
+
}
|
|
158
|
+
function topLevelUpdatedAtToken(raw, expectedValue) {
|
|
159
|
+
const matches = [];
|
|
160
|
+
let depth = 0;
|
|
161
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
162
|
+
const byte = raw[index];
|
|
163
|
+
if (byte === 0x22) {
|
|
164
|
+
const token = jsonStringTokenAt(raw, index);
|
|
165
|
+
if (depth === 1) {
|
|
166
|
+
const afterKey = skipJsonWhitespace(raw, token.end);
|
|
167
|
+
if (raw[afterKey] === 0x3a) {
|
|
168
|
+
let decodedKey;
|
|
169
|
+
try {
|
|
170
|
+
decodedKey = JSON.parse(raw.subarray(token.start, token.end).toString('utf8'));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
throw new Error('top-level JSON property key is invalid');
|
|
174
|
+
}
|
|
175
|
+
if (decodedKey === 'updatedAt') {
|
|
176
|
+
const valueStart = skipJsonWhitespace(raw, afterKey + 1);
|
|
177
|
+
const valueToken = jsonStringTokenAt(raw, valueStart);
|
|
178
|
+
let decodedValue;
|
|
179
|
+
try {
|
|
180
|
+
decodedValue = JSON.parse(raw.subarray(valueToken.start, valueToken.end).toString('utf8'));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
throw new Error('top-level updatedAt string token is invalid');
|
|
184
|
+
}
|
|
185
|
+
if (decodedValue !== expectedValue) {
|
|
186
|
+
throw new Error('top-level updatedAt token does not match the parsed record');
|
|
187
|
+
}
|
|
188
|
+
matches.push(valueToken);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
index = token.end - 1;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (byte === 0x7b || byte === 0x5b)
|
|
196
|
+
depth += 1;
|
|
197
|
+
else if (byte === 0x7d || byte === 0x5d)
|
|
198
|
+
depth -= 1;
|
|
199
|
+
if (depth < 0)
|
|
200
|
+
throw new Error('JSON nesting became negative');
|
|
201
|
+
}
|
|
202
|
+
if (depth !== 0)
|
|
203
|
+
throw new Error('JSON nesting is incomplete');
|
|
204
|
+
if (matches.length !== 1) {
|
|
205
|
+
throw new Error(`goal must contain exactly one top-level updatedAt string token; found ${matches.length}`);
|
|
206
|
+
}
|
|
207
|
+
return matches[0];
|
|
208
|
+
}
|
|
209
|
+
function repairedGoalRaw(raw, goal, updatedAt) {
|
|
210
|
+
const token = topLevelUpdatedAtToken(raw, goal.updatedAt);
|
|
211
|
+
const replacement = Buffer.from(JSON.stringify(updatedAt), 'utf8');
|
|
212
|
+
const repairedRaw = Buffer.concat([
|
|
213
|
+
raw.subarray(0, token.start),
|
|
214
|
+
replacement,
|
|
215
|
+
raw.subarray(token.end),
|
|
216
|
+
]);
|
|
217
|
+
let repaired;
|
|
218
|
+
try {
|
|
219
|
+
repaired = JSON.parse(repairedRaw.toString('utf8'));
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
throw new Error(`repaired goal is not valid JSON: ${goal.id}`);
|
|
223
|
+
}
|
|
224
|
+
if (!isValidGoalRecord(repaired)) {
|
|
225
|
+
throw new Error(`repaired goal failed strict validation: ${goal.id}`);
|
|
226
|
+
}
|
|
227
|
+
const expected = structuredClone(goal);
|
|
228
|
+
expected.updatedAt = updatedAt;
|
|
229
|
+
if (goalSnapshotDigest(repaired) !== goalSnapshotDigest(expected)) {
|
|
230
|
+
throw new Error(`repaired goal changed outside updatedAt: ${goal.id}`);
|
|
231
|
+
}
|
|
232
|
+
return repairedRaw;
|
|
233
|
+
}
|
|
234
|
+
function canonicalGoalAfterRepair(goal, updatedAt) {
|
|
235
|
+
const repaired = structuredClone(goal);
|
|
236
|
+
repaired.updatedAt = updatedAt;
|
|
237
|
+
if (!isValidGoalRecord(repaired)) {
|
|
238
|
+
throw new Error(`repaired goal failed strict validation: ${goal.id}`);
|
|
239
|
+
}
|
|
240
|
+
return repaired;
|
|
241
|
+
}
|
|
242
|
+
function readStableGoal(path, fileName) {
|
|
243
|
+
const raw = readStableFile(path, MAX_GOAL_FILE_BYTES);
|
|
244
|
+
const text = raw.toString('utf8');
|
|
245
|
+
if (!raw.equals(Buffer.from(text, 'utf8')))
|
|
246
|
+
throw new Error(`goal file is not valid UTF-8: ${fileName}`);
|
|
247
|
+
let parsed;
|
|
248
|
+
try {
|
|
249
|
+
parsed = JSON.parse(text);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
throw new Error(`goal file is not valid JSON: ${fileName}`);
|
|
253
|
+
}
|
|
254
|
+
if (!isValidGoalRecordForTimestampRepair(parsed)) {
|
|
255
|
+
throw new Error(`goal file is structurally invalid: ${fileName}`);
|
|
256
|
+
}
|
|
257
|
+
if (`${parsed.id}.json` !== fileName)
|
|
258
|
+
throw new Error(`goal filename/id mismatch: ${fileName}`);
|
|
259
|
+
const derivedUpdatedAt = derivedGoalUpdatedAt(parsed);
|
|
260
|
+
const repairedRaw = repairedGoalRaw(raw, parsed, derivedUpdatedAt);
|
|
261
|
+
return {
|
|
262
|
+
fileName,
|
|
263
|
+
raw,
|
|
264
|
+
rawDigest: digest(raw),
|
|
265
|
+
goal: parsed,
|
|
266
|
+
derivedUpdatedAt,
|
|
267
|
+
repairedRaw,
|
|
268
|
+
repairedDigest: digest(repairedRaw),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function safeSourceDirectory(stat) {
|
|
272
|
+
return stat.isDirectory() && !stat.isSymbolicLink() && ownedByCurrentUser(stat) &&
|
|
273
|
+
(process.platform === 'win32' || (stat.mode & 18n) === 0n);
|
|
274
|
+
}
|
|
275
|
+
function lstatPresentOrMissing(path) {
|
|
276
|
+
try {
|
|
277
|
+
return lstatSync(path, { bigint: true });
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
if (error.code === 'ENOENT')
|
|
281
|
+
return null;
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function pinSourceAncestry() {
|
|
286
|
+
const home = resolve(homedir());
|
|
287
|
+
const ashlr = join(home, '.ashlr');
|
|
288
|
+
const goals = resolve(goalsDir());
|
|
289
|
+
if (goals !== join(ashlr, 'goals'))
|
|
290
|
+
throw new Error('goal source path escaped its expected ancestry');
|
|
291
|
+
const present = [];
|
|
292
|
+
const absent = [];
|
|
293
|
+
for (const path of [home, ashlr, goals]) {
|
|
294
|
+
const stat = lstatPresentOrMissing(path);
|
|
295
|
+
if (stat === null) {
|
|
296
|
+
if (path === home)
|
|
297
|
+
throw new Error('goal source home directory is missing');
|
|
298
|
+
absent.push(path);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (!safeSourceDirectory(stat))
|
|
302
|
+
throw new Error(`goal source ancestry is unsafe: ${path}`);
|
|
303
|
+
assureDarwinSourcePath(path, 'directory');
|
|
304
|
+
present.push({
|
|
305
|
+
path,
|
|
306
|
+
dev: stat.dev,
|
|
307
|
+
ino: stat.ino,
|
|
308
|
+
trackMembership: path === goals,
|
|
309
|
+
mtimeNs: stat.mtimeNs,
|
|
310
|
+
ctimeNs: stat.ctimeNs,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
const ashlrPresent = present.some((identity) => identity.path === ashlr);
|
|
314
|
+
const goalsPresent = present.some((identity) => identity.path === goals);
|
|
315
|
+
if (goalsPresent && !ashlrPresent)
|
|
316
|
+
throw new Error('goal source ancestry changed while being pinned');
|
|
317
|
+
return { present, absent, goalsPresent };
|
|
318
|
+
}
|
|
319
|
+
function verifySourceAncestry(snapshot) {
|
|
320
|
+
for (const path of snapshot.absent) {
|
|
321
|
+
const stat = lstatPresentOrMissing(path);
|
|
322
|
+
if (stat !== null)
|
|
323
|
+
throw new Error(`absent goal source ancestry appeared during read: ${path}`);
|
|
324
|
+
}
|
|
325
|
+
for (const identity of snapshot.present) {
|
|
326
|
+
const stat = lstatSync(identity.path, { bigint: true });
|
|
327
|
+
if (!safeSourceDirectory(stat) || stat.dev !== identity.dev || stat.ino !== identity.ino ||
|
|
328
|
+
(identity.trackMembership && (stat.mtimeNs !== identity.mtimeNs || stat.ctimeNs !== identity.ctimeNs)))
|
|
329
|
+
throw new Error(`goal source ancestry changed during read: ${identity.path}`);
|
|
330
|
+
assureDarwinSourcePath(identity.path, 'directory');
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function readCompleteGoalSet() {
|
|
334
|
+
const dir = goalsDir();
|
|
335
|
+
const ancestry = pinSourceAncestry();
|
|
336
|
+
if (!ancestry.goalsPresent) {
|
|
337
|
+
verifySourceAncestry(ancestry);
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
const handle = opendirSync(dir);
|
|
341
|
+
const names = [];
|
|
342
|
+
let entries = 0;
|
|
343
|
+
try {
|
|
344
|
+
for (;;) {
|
|
345
|
+
const entry = handle.readSync();
|
|
346
|
+
if (entry === null)
|
|
347
|
+
break;
|
|
348
|
+
entries += 1;
|
|
349
|
+
if (entries > MAX_DIRECTORY_ENTRIES)
|
|
350
|
+
throw new Error('goal source directory entry limit exceeded');
|
|
351
|
+
if (!entry.name.endsWith('.json') || entry.name.endsWith('.tmp'))
|
|
352
|
+
continue;
|
|
353
|
+
names.push(entry.name);
|
|
354
|
+
if (names.length > MAX_GOAL_FILES)
|
|
355
|
+
throw new Error('goal source file limit exceeded');
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
finally {
|
|
359
|
+
handle.closeSync();
|
|
360
|
+
}
|
|
361
|
+
names.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
362
|
+
const identities = new Set();
|
|
363
|
+
const files = names.map((name) => {
|
|
364
|
+
const goal = readStableGoal(join(dir, name), name);
|
|
365
|
+
const identity = process.platform === 'win32' ? goal.goal.id.toLowerCase() : goal.goal.id;
|
|
366
|
+
if (identities.has(identity))
|
|
367
|
+
throw new Error(`duplicate goal identity: ${goal.goal.id}`);
|
|
368
|
+
identities.add(identity);
|
|
369
|
+
return goal;
|
|
370
|
+
});
|
|
371
|
+
verifySourceAncestry(ancestry);
|
|
372
|
+
return files;
|
|
373
|
+
}
|
|
374
|
+
function planBody(files) {
|
|
375
|
+
const entries = files.map((file) => ({
|
|
376
|
+
goalId: file.goal.id,
|
|
377
|
+
fileName: file.fileName,
|
|
378
|
+
beforeDigest: file.rawDigest,
|
|
379
|
+
afterDigest: file.goal.updatedAt === file.derivedUpdatedAt ? file.rawDigest : file.repairedDigest,
|
|
380
|
+
previousUpdatedAt: file.goal.updatedAt,
|
|
381
|
+
derivedUpdatedAt: file.derivedUpdatedAt,
|
|
382
|
+
repairRequired: file.goal.updatedAt !== file.derivedUpdatedAt,
|
|
383
|
+
}));
|
|
384
|
+
const sourceDigest = digest(JSON.stringify(entries.map((entry) => ({
|
|
385
|
+
fileName: entry.fileName,
|
|
386
|
+
beforeDigest: entry.beforeDigest,
|
|
387
|
+
}))));
|
|
388
|
+
return {
|
|
389
|
+
schemaVersion: SCHEMA_VERSION,
|
|
390
|
+
kind: 'goal-timestamp-repair',
|
|
391
|
+
sourceDigest,
|
|
392
|
+
scannedGoals: entries.length,
|
|
393
|
+
entries,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
function materializePlan(files) {
|
|
397
|
+
const body = planBody(files);
|
|
398
|
+
return { ...body, planId: digest(JSON.stringify(body)) };
|
|
399
|
+
}
|
|
400
|
+
function validatePlan(value, expectedPlanId) {
|
|
401
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
402
|
+
throw new Error('stored timestamp-repair plan is malformed');
|
|
403
|
+
}
|
|
404
|
+
const plan = value;
|
|
405
|
+
if (plan.schemaVersion !== SCHEMA_VERSION || plan.kind !== 'goal-timestamp-repair' ||
|
|
406
|
+
plan.planId !== expectedPlanId || !Array.isArray(plan.entries) ||
|
|
407
|
+
!Number.isSafeInteger(plan.scannedGoals) || plan.scannedGoals !== plan.entries.length)
|
|
408
|
+
throw new Error('stored timestamp-repair plan is malformed');
|
|
409
|
+
const seenFiles = new Set();
|
|
410
|
+
const seenGoals = new Set();
|
|
411
|
+
let previousFileName;
|
|
412
|
+
for (const entry of plan.entries) {
|
|
413
|
+
if (typeof entry !== 'object' || entry === null ||
|
|
414
|
+
typeof entry.goalId !== 'string' || !/^[\w.-]+$/.test(entry.goalId) ||
|
|
415
|
+
entry.fileName !== `${entry.goalId}.json` ||
|
|
416
|
+
typeof entry.beforeDigest !== 'string' || !/^[a-f0-9]{64}$/.test(entry.beforeDigest) ||
|
|
417
|
+
typeof entry.afterDigest !== 'string' || !/^[a-f0-9]{64}$/.test(entry.afterDigest) ||
|
|
418
|
+
typeof entry.previousUpdatedAt !== 'string' || !Number.isFinite(Date.parse(entry.previousUpdatedAt)) ||
|
|
419
|
+
typeof entry.derivedUpdatedAt !== 'string' || !Number.isFinite(Date.parse(entry.derivedUpdatedAt)) ||
|
|
420
|
+
typeof entry.repairRequired !== 'boolean' ||
|
|
421
|
+
(entry.repairRequired ? entry.beforeDigest === entry.afterDigest : entry.beforeDigest !== entry.afterDigest) ||
|
|
422
|
+
seenFiles.has(entry.fileName) || seenGoals.has(entry.goalId) ||
|
|
423
|
+
(previousFileName !== undefined && previousFileName >= entry.fileName))
|
|
424
|
+
throw new Error('stored timestamp-repair plan contains an invalid entry');
|
|
425
|
+
seenFiles.add(entry.fileName);
|
|
426
|
+
seenGoals.add(entry.goalId);
|
|
427
|
+
previousFileName = entry.fileName;
|
|
428
|
+
}
|
|
429
|
+
const expectedSourceDigest = digest(JSON.stringify(plan.entries.map((entry) => ({
|
|
430
|
+
fileName: entry.fileName,
|
|
431
|
+
beforeDigest: entry.beforeDigest,
|
|
432
|
+
}))));
|
|
433
|
+
if (plan.sourceDigest !== expectedSourceDigest)
|
|
434
|
+
throw new Error('stored timestamp-repair source digest mismatch');
|
|
435
|
+
const { planId: _planId, ...body } = plan;
|
|
436
|
+
if (digest(JSON.stringify(body)) !== expectedPlanId)
|
|
437
|
+
throw new Error('stored timestamp-repair plan digest mismatch');
|
|
438
|
+
return plan;
|
|
439
|
+
}
|
|
440
|
+
function repairRoot() {
|
|
441
|
+
return join(goalsDir(), REPAIR_ROOT);
|
|
442
|
+
}
|
|
443
|
+
function goalLockPath(goalId) {
|
|
444
|
+
return join(goalsDir(), `.${digest(goalId)}.lock`);
|
|
445
|
+
}
|
|
446
|
+
function planArtifactId(planId) {
|
|
447
|
+
return `plan-${planId}`;
|
|
448
|
+
}
|
|
449
|
+
function backupArtifactId(planId, goalId) {
|
|
450
|
+
return `backup-${planId}-${digest(goalId).slice(0, 32)}`;
|
|
451
|
+
}
|
|
452
|
+
function receiptArtifactId(planId, goalId) {
|
|
453
|
+
return `receipt-${planId}-${digest(goalId).slice(0, 32)}`;
|
|
454
|
+
}
|
|
455
|
+
function exactKeys(value, keys) {
|
|
456
|
+
const actual = Object.keys(value).sort();
|
|
457
|
+
const expected = [...keys].sort();
|
|
458
|
+
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
|
459
|
+
}
|
|
460
|
+
function isRecord(value) {
|
|
461
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
462
|
+
}
|
|
463
|
+
function parseArtifact(value) {
|
|
464
|
+
try {
|
|
465
|
+
if (!isRecord(value) || value['schemaVersion'] !== SCHEMA_VERSION ||
|
|
466
|
+
typeof value['kind'] !== 'string' || typeof value['artifactId'] !== 'string')
|
|
467
|
+
return null;
|
|
468
|
+
if (value['kind'] === 'goal-timestamp-repair-plan') {
|
|
469
|
+
if (!exactKeys(value, ['schemaVersion', 'kind', 'artifactId', 'plan']) ||
|
|
470
|
+
typeof value['artifactId'] !== 'string' || !value['artifactId'].startsWith('plan-'))
|
|
471
|
+
return null;
|
|
472
|
+
const planId = value['artifactId'].slice('plan-'.length);
|
|
473
|
+
const plan = validatePlan(value['plan'], planId);
|
|
474
|
+
return { schemaVersion: SCHEMA_VERSION, kind: value['kind'], artifactId: value['artifactId'], plan };
|
|
475
|
+
}
|
|
476
|
+
if (value['kind'] === 'goal-timestamp-repair-raw-backup') {
|
|
477
|
+
if (!exactKeys(value, [
|
|
478
|
+
'schemaVersion', 'kind', 'artifactId', 'planId', 'goalId', 'rawDigest', 'rawBase64',
|
|
479
|
+
]) ||
|
|
480
|
+
typeof value['planId'] !== 'string' || !/^[a-f0-9]{64}$/.test(value['planId']) ||
|
|
481
|
+
typeof value['goalId'] !== 'string' || !/^[\w.-]+$/.test(value['goalId']) ||
|
|
482
|
+
typeof value['rawDigest'] !== 'string' || !/^[a-f0-9]{64}$/.test(value['rawDigest']) ||
|
|
483
|
+
typeof value['rawBase64'] !== 'string' ||
|
|
484
|
+
value['artifactId'] !== backupArtifactId(value['planId'], value['goalId']))
|
|
485
|
+
return null;
|
|
486
|
+
const raw = Buffer.from(value['rawBase64'], 'base64');
|
|
487
|
+
if (raw.length < 1 || raw.length > MAX_GOAL_FILE_BYTES ||
|
|
488
|
+
raw.toString('base64') !== value['rawBase64'] || digest(raw) !== value['rawDigest'])
|
|
489
|
+
return null;
|
|
490
|
+
return value;
|
|
491
|
+
}
|
|
492
|
+
if (value['kind'] === 'goal-timestamp-repair-receipt') {
|
|
493
|
+
if (!exactKeys(value, [
|
|
494
|
+
'schemaVersion', 'kind', 'artifactId', 'planId', 'goalId', 'beforeDigest', 'afterDigest',
|
|
495
|
+
'derivedUpdatedAt',
|
|
496
|
+
]) ||
|
|
497
|
+
typeof value['planId'] !== 'string' || !/^[a-f0-9]{64}$/.test(value['planId']) ||
|
|
498
|
+
typeof value['goalId'] !== 'string' || !/^[\w.-]+$/.test(value['goalId']) ||
|
|
499
|
+
typeof value['beforeDigest'] !== 'string' || !/^[a-f0-9]{64}$/.test(value['beforeDigest']) ||
|
|
500
|
+
typeof value['afterDigest'] !== 'string' || !/^[a-f0-9]{64}$/.test(value['afterDigest']) ||
|
|
501
|
+
typeof value['derivedUpdatedAt'] !== 'string' ||
|
|
502
|
+
!Number.isFinite(Date.parse(value['derivedUpdatedAt'])) ||
|
|
503
|
+
value['artifactId'] !== receiptArtifactId(value['planId'], value['goalId']))
|
|
504
|
+
return null;
|
|
505
|
+
return value;
|
|
506
|
+
}
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
function artifactCodec() {
|
|
514
|
+
return {
|
|
515
|
+
parse: parseArtifact,
|
|
516
|
+
serialize: (artifact) => `${JSON.stringify(artifact)}\n`,
|
|
517
|
+
recordId: (artifact) => artifact.artifactId,
|
|
518
|
+
recordFileName: (artifact) => `${artifact.artifactId}.json`,
|
|
519
|
+
isRecordFileName: (fileName) => /^(?:plan|backup|receipt)-[a-f0-9-]+\.json$/.test(fileName),
|
|
520
|
+
stageToken: (artifact) => digest(`goal-timestamp-repair-stage:${JSON.stringify(artifact)}`).slice(0, 32),
|
|
521
|
+
equivalent: (left, right) => JSON.stringify(left) === JSON.stringify(right),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
function artifactStoreConfig() {
|
|
525
|
+
return {
|
|
526
|
+
label: 'goal timestamp repair artifact',
|
|
527
|
+
anchorPath: goalsDir(),
|
|
528
|
+
rootPath: repairRoot(),
|
|
529
|
+
lockFileName: '.goal-timestamp-repair-artifacts.lock',
|
|
530
|
+
maxRecordBytes: MAX_ARTIFACT_BYTES,
|
|
531
|
+
defaultMaxFiles: 512,
|
|
532
|
+
hardMaxFiles: 1_024,
|
|
533
|
+
defaultMaxBytes: 64 * 1024 * 1024,
|
|
534
|
+
hardMaxBytes: 256 * 1024 * 1024,
|
|
535
|
+
codecForWrite: artifactCodec,
|
|
536
|
+
codecForRead: artifactCodec,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
function readArtifact(artifactId) {
|
|
540
|
+
const result = readImmutablePrivateRecordPoint(artifactStoreConfig(), artifactId, `${artifactId}.json`);
|
|
541
|
+
if (result.sourceState === 'missing' ||
|
|
542
|
+
(result.sourceState === 'healthy' && result.exactReadComplete && result.record === null))
|
|
543
|
+
return null;
|
|
544
|
+
if (result.sourceState !== 'healthy' || !result.exactReadComplete || !result.record) {
|
|
545
|
+
throw new Error(`timestamp-repair artifact store is degraded: ${result.stopReasons.join(',')}`);
|
|
546
|
+
}
|
|
547
|
+
return result.record;
|
|
548
|
+
}
|
|
549
|
+
function writeArtifact(artifact) {
|
|
550
|
+
const disposition = writeImmutablePrivateRecord(artifactStoreConfig(), artifact);
|
|
551
|
+
if (disposition !== 'recorded' && disposition !== 'replayed') {
|
|
552
|
+
throw new Error(`timestamp-repair artifact persistence ${disposition}: ${artifact.artifactId}`);
|
|
553
|
+
}
|
|
554
|
+
const persisted = readArtifact(artifact.artifactId);
|
|
555
|
+
if (!persisted || JSON.stringify(persisted) !== JSON.stringify(artifact)) {
|
|
556
|
+
throw new Error(`timestamp-repair artifact verification failed: ${artifact.artifactId}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
function recoverArtifactStore() {
|
|
560
|
+
const disposition = recoverImmutablePrivateRecordStore(artifactStoreConfig());
|
|
561
|
+
if (disposition !== 'missing' && disposition !== 'clean' && disposition !== 'recovered') {
|
|
562
|
+
throw new Error(`timestamp-repair artifact recovery ${disposition}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
function storedPlan(planId) {
|
|
566
|
+
const artifact = readArtifact(planArtifactId(planId));
|
|
567
|
+
if (artifact === null)
|
|
568
|
+
return null;
|
|
569
|
+
if (artifact.kind !== 'goal-timestamp-repair-plan' || artifact.plan.planId !== planId) {
|
|
570
|
+
throw new Error('stored timestamp-repair plan artifact mismatch');
|
|
571
|
+
}
|
|
572
|
+
return artifact.plan;
|
|
573
|
+
}
|
|
574
|
+
function planRecord(plan) {
|
|
575
|
+
return {
|
|
576
|
+
schemaVersion: SCHEMA_VERSION,
|
|
577
|
+
kind: 'goal-timestamp-repair-plan',
|
|
578
|
+
artifactId: planArtifactId(plan.planId),
|
|
579
|
+
plan,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
function backupRecord(planId, goalId, raw) {
|
|
583
|
+
return {
|
|
584
|
+
schemaVersion: SCHEMA_VERSION,
|
|
585
|
+
kind: 'goal-timestamp-repair-raw-backup',
|
|
586
|
+
artifactId: backupArtifactId(planId, goalId),
|
|
587
|
+
planId,
|
|
588
|
+
goalId,
|
|
589
|
+
rawDigest: digest(raw),
|
|
590
|
+
rawBase64: raw.toString('base64'),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
function readBackup(planId, goalId) {
|
|
594
|
+
const artifact = readArtifact(backupArtifactId(planId, goalId));
|
|
595
|
+
if (artifact === null)
|
|
596
|
+
return null;
|
|
597
|
+
if (artifact.kind !== 'goal-timestamp-repair-raw-backup' ||
|
|
598
|
+
artifact.planId !== planId || artifact.goalId !== goalId) {
|
|
599
|
+
throw new Error(`timestamp-repair backup artifact mismatch: ${goalId}`);
|
|
600
|
+
}
|
|
601
|
+
return artifact;
|
|
602
|
+
}
|
|
603
|
+
function filesByName(files) {
|
|
604
|
+
return new Map(files.map((file) => [file.fileName, file]));
|
|
605
|
+
}
|
|
606
|
+
function preflightStoredPlan(plan, files) {
|
|
607
|
+
if (files.length !== plan.entries.length)
|
|
608
|
+
throw new Error('goal source set changed after timestamp-repair planning');
|
|
609
|
+
const current = filesByName(files);
|
|
610
|
+
for (const entry of plan.entries) {
|
|
611
|
+
const file = current.get(entry.fileName);
|
|
612
|
+
if (!file || file.goal.id !== entry.goalId) {
|
|
613
|
+
throw new Error(`goal source membership changed: ${entry.fileName}`);
|
|
614
|
+
}
|
|
615
|
+
const before = file.rawDigest === entry.beforeDigest;
|
|
616
|
+
const after = entry.repairRequired && file.rawDigest === entry.afterDigest;
|
|
617
|
+
if (!before && !after)
|
|
618
|
+
throw new Error(`goal changed outside repair plan: ${entry.goalId}`);
|
|
619
|
+
if (!entry.repairRequired && !before)
|
|
620
|
+
throw new Error(`non-target goal changed: ${entry.goalId}`);
|
|
621
|
+
if (after) {
|
|
622
|
+
const backup = readBackup(plan.planId, entry.goalId);
|
|
623
|
+
if (!backup || backup.rawDigest !== entry.beforeDigest) {
|
|
624
|
+
throw new Error(`repaired goal lacks its exact raw backup: ${entry.goalId}`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function receiptFor(plan, entry) {
|
|
630
|
+
return {
|
|
631
|
+
schemaVersion: SCHEMA_VERSION,
|
|
632
|
+
kind: 'goal-timestamp-repair-receipt',
|
|
633
|
+
artifactId: receiptArtifactId(plan.planId, entry.goalId),
|
|
634
|
+
planId: plan.planId,
|
|
635
|
+
goalId: entry.goalId,
|
|
636
|
+
beforeDigest: entry.beforeDigest,
|
|
637
|
+
afterDigest: entry.afterDigest,
|
|
638
|
+
derivedUpdatedAt: entry.derivedUpdatedAt,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
/** Build an exact deterministic repair plan without changing the filesystem. */
|
|
642
|
+
export function dryRunGoalTimestampRepair() {
|
|
643
|
+
assertSupportedCustodyPlatform();
|
|
644
|
+
const files = readCompleteGoalSet();
|
|
645
|
+
const plan = materializePlan(files);
|
|
646
|
+
return {
|
|
647
|
+
mode: 'dry-run',
|
|
648
|
+
planId: plan.planId,
|
|
649
|
+
scannedGoals: plan.scannedGoals,
|
|
650
|
+
repairableGoals: plan.entries.filter((entry) => entry.repairRequired).length,
|
|
651
|
+
entries: plan.entries,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
/** Apply or resume the exact plan identified by `planId`. */
|
|
655
|
+
export function applyGoalTimestampRepair(planId) {
|
|
656
|
+
assertSupportedCustodyPlatform();
|
|
657
|
+
if (!/^[a-f0-9]{64}$/.test(planId))
|
|
658
|
+
throw new Error('timestamp-repair plan id must be 64 lowercase hex characters');
|
|
659
|
+
let files = readCompleteGoalSet();
|
|
660
|
+
if (existsSync(repairRoot()))
|
|
661
|
+
recoverArtifactStore();
|
|
662
|
+
let plan = storedPlan(planId);
|
|
663
|
+
files = readCompleteGoalSet();
|
|
664
|
+
if (!plan) {
|
|
665
|
+
const candidate = materializePlan(files);
|
|
666
|
+
if (candidate.planId !== planId)
|
|
667
|
+
throw new Error('timestamp-repair plan id does not match the current complete goal set');
|
|
668
|
+
writeArtifact(planRecord(candidate));
|
|
669
|
+
plan = candidate;
|
|
670
|
+
}
|
|
671
|
+
files = readCompleteGoalSet();
|
|
672
|
+
preflightStoredPlan(plan, files);
|
|
673
|
+
let repairedGoals = 0;
|
|
674
|
+
let alreadyAppliedGoals = 0;
|
|
675
|
+
let receiptCount = 0;
|
|
676
|
+
for (const entry of plan.entries.filter((candidate) => candidate.repairRequired)) {
|
|
677
|
+
const lock = acquireLocalStoreLock(goalLockPath(entry.goalId), LOCK_WAIT_MS);
|
|
678
|
+
if (!lock)
|
|
679
|
+
throw new Error(`could not acquire goal repair lock: ${entry.goalId}`);
|
|
680
|
+
let operationFailure;
|
|
681
|
+
let released = false;
|
|
682
|
+
try {
|
|
683
|
+
const target = join(goalsDir(), entry.fileName);
|
|
684
|
+
const current = readStableGoal(target, entry.fileName);
|
|
685
|
+
if (current.rawDigest === entry.afterDigest) {
|
|
686
|
+
const backup = readBackup(planId, entry.goalId);
|
|
687
|
+
if (!backup || backup.rawDigest !== entry.beforeDigest) {
|
|
688
|
+
throw new Error(`repaired goal lacks its exact raw backup: ${entry.goalId}`);
|
|
689
|
+
}
|
|
690
|
+
writeArtifact(receiptFor(plan, entry));
|
|
691
|
+
alreadyAppliedGoals += 1;
|
|
692
|
+
receiptCount += 1;
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
if (current.rawDigest !== entry.beforeDigest || current.derivedUpdatedAt !== entry.derivedUpdatedAt) {
|
|
696
|
+
throw new Error(`goal changed after timestamp-repair preflight: ${entry.goalId}`);
|
|
697
|
+
}
|
|
698
|
+
writeArtifact(backupRecord(planId, entry.goalId, current.raw));
|
|
699
|
+
const repaired = canonicalGoalAfterRepair(current.goal, entry.derivedUpdatedAt);
|
|
700
|
+
const repairedRaw = repairedGoalRaw(current.raw, current.goal, entry.derivedUpdatedAt);
|
|
701
|
+
if (digest(repairedRaw) !== entry.afterDigest)
|
|
702
|
+
throw new Error(`repair output digest mismatch: ${entry.goalId}`);
|
|
703
|
+
const temporary = `${target}.${process.pid}.${randomBytes(12).toString('hex')}.repair.tmp`;
|
|
704
|
+
writePrivateFileAtomically(temporary, target, repairedRaw, {
|
|
705
|
+
anchorPath: goalsDir(),
|
|
706
|
+
label: `goal timestamp repair ${entry.goalId}`,
|
|
707
|
+
});
|
|
708
|
+
const installed = readStableGoal(target, entry.fileName);
|
|
709
|
+
if (installed.rawDigest !== entry.afterDigest || goalSnapshotDigest(installed.goal) !== goalSnapshotDigest(repaired)) {
|
|
710
|
+
throw new Error(`installed timestamp repair failed verification: ${entry.goalId}`);
|
|
711
|
+
}
|
|
712
|
+
writeArtifact(receiptFor(plan, entry));
|
|
713
|
+
repairedGoals += 1;
|
|
714
|
+
receiptCount += 1;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
catch (error) {
|
|
718
|
+
operationFailure = error;
|
|
719
|
+
}
|
|
720
|
+
finally {
|
|
721
|
+
released = releaseLocalStoreLock(lock);
|
|
722
|
+
}
|
|
723
|
+
if (operationFailure !== undefined)
|
|
724
|
+
throw operationFailure;
|
|
725
|
+
if (!released)
|
|
726
|
+
throw new Error(`goal repair lock release could not be verified: ${entry.goalId}`);
|
|
727
|
+
}
|
|
728
|
+
preflightStoredPlan(plan, readCompleteGoalSet());
|
|
729
|
+
return {
|
|
730
|
+
mode: 'apply',
|
|
731
|
+
planId,
|
|
732
|
+
scannedGoals: plan.scannedGoals,
|
|
733
|
+
repairableGoals: plan.entries.filter((entry) => entry.repairRequired).length,
|
|
734
|
+
repairedGoals,
|
|
735
|
+
alreadyAppliedGoals,
|
|
736
|
+
receiptCount,
|
|
737
|
+
entries: plan.entries,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
//# sourceMappingURL=timestamp-repair.js.map
|