@fitlab-ai/agent-infra 0.9.6 → 0.9.7
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/dist/bin/internal-cli.js +5 -0
- package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +45 -4
- package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +162 -0
- package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +244 -0
- package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +370 -0
- package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +21 -5
- package/dist/lib/defaults.json +10 -2
- package/dist/lib/git/workflow.js +30 -3
- package/dist/lib/internal/codex-lifecycle.js +69 -1
- package/dist/lib/internal/codex-sandbox-controller.js +106 -0
- package/dist/lib/internal/task-orchestration.js +37 -5
- package/dist/lib/platform/context.js +6 -4
- package/dist/lib/platform/github-client.js +55 -2
- package/dist/lib/process-data/checkpoint.js +319 -0
- package/dist/lib/process-data/index.js +128 -60
- package/dist/lib/process-data/materialize.js +84 -0
- package/dist/lib/process-data/normalize.js +34 -1
- package/dist/lib/process-data/reconcile.js +55 -1
- package/dist/lib/process-data/sources.js +422 -1
- package/dist/lib/process-data/store.js +341 -11
- package/dist/lib/sandbox/commands/create.js +103 -51
- package/dist/lib/sandbox/commands/rm.js +64 -15
- package/dist/lib/sandbox/commands/start.js +4 -3
- package/dist/lib/sandbox/control/container-identity.js +64 -0
- package/dist/lib/sandbox/control/executor.js +13 -7
- package/dist/lib/sandbox/control/lifecycle.js +238 -29
- package/dist/lib/sandbox/control/protocol.js +7 -0
- package/dist/lib/sandbox/control/server.js +202 -26
- package/dist/lib/sandbox/control/state.js +25 -15
- package/dist/lib/sandbox/control/timing.js +8 -0
- package/dist/lib/sandbox/mounts.js +5 -13
- package/dist/lib/sandbox/recovery.js +59 -42
- package/dist/lib/sandbox/workspace-view.js +57 -40
- package/dist/lib/server/daemon.js +1 -1
- package/dist/lib/server/process-control.js +11 -1
- package/dist/lib/server/process-state.js +57 -20
- package/dist/lib/task/artifact-lifecycle.js +82 -11
- package/dist/lib/task/artifact-receipts.js +128 -0
- package/dist/lib/task/codex-orchestration.js +81 -6
- package/dist/lib/task/commands/validate.js +1 -1
- package/dist/lib/task/create.js +10 -2
- package/dist/lib/task/delegation-receipts.js +84 -2
- package/dist/lib/task/events.js +139 -2
- package/dist/lib/task/ledger.js +13 -1
- package/dist/lib/task/orchestration.js +235 -14
- package/dist/lib/task/sections.js +5 -1
- package/dist/lib/task/task-execution-lock.js +5 -4
- package/dist/lib/task/verification-engine.js +40 -8
- package/dist/lifecycle-build-manifest.json +6 -0
- package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +60 -3
- package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +227 -0
- package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +321 -0
- package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +26 -0
- package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +453 -0
- package/lib/agent-clients/adapters/codex-lifecycle/store.ts +33 -4
- package/lib/defaults.json +10 -2
- package/lib/git/workflow.ts +25 -2
- package/lib/internal/codex-lifecycle.ts +68 -1
- package/lib/internal/codex-sandbox-controller.ts +109 -0
- package/lib/internal/task-orchestration.ts +31 -4
- package/lib/platform/context.ts +8 -5
- package/lib/platform/github-client.ts +61 -3
- package/lib/process-data/checkpoint.ts +335 -0
- package/lib/process-data/index.ts +112 -60
- package/lib/process-data/materialize.ts +83 -0
- package/lib/process-data/normalize.ts +29 -1
- package/lib/process-data/reconcile.ts +64 -2
- package/lib/process-data/sources.ts +449 -2
- package/lib/process-data/store.ts +304 -11
- package/lib/process-data/types.ts +124 -2
- package/lib/sandbox/commands/create.ts +56 -7
- package/lib/sandbox/commands/rm.ts +64 -14
- package/lib/sandbox/commands/start.ts +4 -3
- package/lib/sandbox/control/container-identity.ts +93 -0
- package/lib/sandbox/control/executor.ts +14 -10
- package/lib/sandbox/control/lifecycle.ts +264 -33
- package/lib/sandbox/control/protocol.ts +31 -5
- package/lib/sandbox/control/server.ts +184 -24
- package/lib/sandbox/control/state.ts +34 -17
- package/lib/sandbox/control/timing.ts +12 -0
- package/lib/sandbox/mounts.ts +5 -15
- package/lib/sandbox/recovery.ts +71 -42
- package/lib/sandbox/workspace-view.ts +61 -40
- package/lib/server/daemon.ts +1 -1
- package/lib/server/process-control.ts +13 -3
- package/lib/server/process-state.ts +82 -27
- package/lib/task/artifact-lifecycle.ts +73 -10
- package/lib/task/artifact-receipts.ts +161 -0
- package/lib/task/codex-orchestration.ts +91 -5
- package/lib/task/commands/validate.ts +1 -1
- package/lib/task/create.ts +11 -2
- package/lib/task/delegation-receipts.ts +160 -8
- package/lib/task/events.ts +122 -1
- package/lib/task/ledger.ts +16 -1
- package/lib/task/orchestration.ts +356 -17
- package/lib/task/sections.ts +6 -2
- package/lib/task/task-execution-lock.ts +11 -9
- package/lib/task/verification-engine.ts +51 -16
- package/package.json +1 -1
- package/templates/.agents/README.en.md +7 -0
- package/templates/.agents/README.zh-CN.md +5 -0
- package/templates/.agents/hooks/lifecycle-delegation.js +25 -1
- package/templates/.agents/rules/lifecycle-orchestration.en.md +8 -2
- package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +8 -2
- package/templates/.agents/rules/pr-checks-commands.github.en.md +1 -1
- package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +1 -1
- package/templates/.agents/rules/review-handshake.en.md +3 -1
- package/templates/.agents/rules/review-handshake.zh-CN.md +3 -1
- package/templates/.agents/skills/code-task/reference/dual-mode.en.md +3 -1
- package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +3 -1
- package/templates/.agents/skills/complete-task/SKILL.en.md +4 -0
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +4 -0
- package/templates/.agents/skills/run-manual-validation/SKILL.en.md +6 -6
- package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +6 -6
- package/templates/.agents/skills/run-manual-validation/config/verify.en.json +14 -3
- package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +14 -3
- package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.en.md +67 -0
- package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.zh-CN.md +67 -0
- package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +25 -0
- package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +25 -0
- package/templates/.agents/skills/run-task/SKILL.en.md +9 -7
- package/templates/.agents/skills/run-task/SKILL.zh-CN.md +9 -7
- package/templates/.agents/skills/run-task/reference/host-validation.en.md +2 -0
- package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +2 -0
- package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +75 -3
- package/templates/.agents/workspace/README.en.md +0 -26
- package/templates/.agents/workspace/README.zh-CN.md +0 -26
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { verifySnapshot } from './store.ts';
|
|
7
|
+
import type { GitHubCheckpoint, CheckpointOwner, ProcessResult, RecoveryReport } from './types.ts';
|
|
8
|
+
|
|
9
|
+
type CheckpointPaths = { directory: string; checkpoint: string; lock: string };
|
|
10
|
+
type CheckpointCandidate = {
|
|
11
|
+
schema: 'github-checkpoint/v1';
|
|
12
|
+
candidateSchema: 'github-checkpoint-candidate/v1';
|
|
13
|
+
ownerId: string;
|
|
14
|
+
expectedOld: { snapshotId: string | null; manifestSha256: string | null };
|
|
15
|
+
repository: string;
|
|
16
|
+
snapshotId: string;
|
|
17
|
+
watermark: string;
|
|
18
|
+
manifestSha256: string;
|
|
19
|
+
committedAt: string;
|
|
20
|
+
};
|
|
21
|
+
type CheckpointLease = {
|
|
22
|
+
paths: CheckpointPaths;
|
|
23
|
+
repository: string;
|
|
24
|
+
owner: CheckpointOwner;
|
|
25
|
+
recovery: RecoveryReport;
|
|
26
|
+
expectedOld: GitHubCheckpoint | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function repositoryKey(repository: string): string {
|
|
30
|
+
return crypto.createHash('sha256').update(repository).digest('hex').slice(0, 32);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function checkpointPaths(root: string, repository: string): CheckpointPaths {
|
|
34
|
+
const directory = path.join(root, 'checkpoints', 'github');
|
|
35
|
+
const base = path.join(directory, repositoryKey(repository));
|
|
36
|
+
return { directory, checkpoint: `${base}.json`, lock: `${base}.lock` };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function processStartToken(pid: number): string | null {
|
|
40
|
+
if (process.platform === 'linux') {
|
|
41
|
+
try {
|
|
42
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
43
|
+
const closing = stat.lastIndexOf(')');
|
|
44
|
+
const fields = stat.slice(closing + 2).split(' ');
|
|
45
|
+
return fields[19] ?? null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return pid === process.pid ? `self:${process.pid}` : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fsyncDirectory(directory: string): void {
|
|
54
|
+
if (process.platform === 'win32') return;
|
|
55
|
+
const fd = fs.openSync(directory, 'r');
|
|
56
|
+
try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ensurePrivateDirectory(directory: string): void {
|
|
60
|
+
const missing: string[] = [];
|
|
61
|
+
let current = path.resolve(directory);
|
|
62
|
+
while (!fs.existsSync(current)) {
|
|
63
|
+
missing.push(current);
|
|
64
|
+
const parent = path.dirname(current);
|
|
65
|
+
if (parent === current) throw new Error(`cannot create directory tree: ${directory}`);
|
|
66
|
+
current = parent;
|
|
67
|
+
}
|
|
68
|
+
for (const target of missing.reverse()) {
|
|
69
|
+
fs.mkdirSync(target, { mode: 0o700 });
|
|
70
|
+
if (process.platform !== 'win32') fs.chmodSync(target, 0o700);
|
|
71
|
+
fsyncDirectory(path.dirname(target));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function writeTemporary(filePath: string, value: unknown): string {
|
|
76
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
77
|
+
const temporary = `${filePath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
78
|
+
const fd = fs.openSync(temporary, 'wx', 0o600);
|
|
79
|
+
try {
|
|
80
|
+
fs.writeFileSync(fd, `${JSON.stringify(value)}\n`);
|
|
81
|
+
fs.fsyncSync(fd);
|
|
82
|
+
} finally {
|
|
83
|
+
fs.closeSync(fd);
|
|
84
|
+
}
|
|
85
|
+
return temporary;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validCheckpoint(value: unknown, repository: string): value is GitHubCheckpoint {
|
|
89
|
+
if (!value || typeof value !== 'object') return false;
|
|
90
|
+
const checkpoint = value as GitHubCheckpoint;
|
|
91
|
+
return checkpoint.schema === 'github-checkpoint/v1'
|
|
92
|
+
&& checkpoint.repository === repository
|
|
93
|
+
&& Boolean(checkpoint.snapshotId)
|
|
94
|
+
&& Boolean(checkpoint.watermark)
|
|
95
|
+
&& Boolean(checkpoint.committedAt)
|
|
96
|
+
&& /^[a-f0-9]{64}$/.test(checkpoint.manifestSha256);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function checkpointMatchesSnapshot(root: string, value: GitHubCheckpoint): boolean {
|
|
100
|
+
const verified = verifySnapshot(root, value.snapshotId);
|
|
101
|
+
return verified.ok
|
|
102
|
+
&& verified.manifest?.schema === 'raw-manifest/v2'
|
|
103
|
+
&& verified.manifest.snapshotId === value.snapshotId
|
|
104
|
+
&& verified.manifest.repository === value.repository
|
|
105
|
+
&& verified.manifest.manifestSha256 === value.manifestSha256
|
|
106
|
+
&& verified.manifest.watermark === value.watermark;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function checkpointState(value: GitHubCheckpoint | null): { snapshotId: string | null; manifestSha256: string | null } {
|
|
110
|
+
return { snapshotId: value?.snapshotId ?? null, manifestSha256: value?.manifestSha256 ?? null };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readGitHubCheckpoint(root: string, repository: string): ProcessResult<GitHubCheckpoint | null> {
|
|
114
|
+
const target = checkpointPaths(root, repository).checkpoint;
|
|
115
|
+
if (!fs.existsSync(target)) return { ok: true, value: null };
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(fs.readFileSync(target, 'utf8')) as GitHubCheckpoint;
|
|
118
|
+
const value: GitHubCheckpoint = {
|
|
119
|
+
schema: parsed.schema,
|
|
120
|
+
repository: parsed.repository,
|
|
121
|
+
snapshotId: parsed.snapshotId,
|
|
122
|
+
watermark: parsed.watermark,
|
|
123
|
+
manifestSha256: parsed.manifestSha256,
|
|
124
|
+
committedAt: parsed.committedAt
|
|
125
|
+
};
|
|
126
|
+
if (!validCheckpoint(value, repository)) {
|
|
127
|
+
return { ok: false, error: { code: 'CHECKPOINT_INVALID', message: 'GitHub checkpoint schema or identity is invalid' } };
|
|
128
|
+
}
|
|
129
|
+
return { ok: true, value };
|
|
130
|
+
} catch {
|
|
131
|
+
return { ok: false, error: { code: 'CHECKPOINT_INVALID', message: 'GitHub checkpoint is not valid JSON' } };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function quarantine(lockPath: string): string {
|
|
136
|
+
const target = `${lockPath}.orphaned-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
|
137
|
+
fs.renameSync(lockPath, target);
|
|
138
|
+
return target;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function acquireGitHubCheckpoint(root: string, repository: string): ProcessResult<CheckpointLease> {
|
|
142
|
+
const paths = checkpointPaths(root, repository);
|
|
143
|
+
ensurePrivateDirectory(paths.directory);
|
|
144
|
+
const recovery: RecoveryReport = { recovered: false, quarantined: [] };
|
|
145
|
+
const owner: CheckpointOwner = {
|
|
146
|
+
schema: 'checkpoint-owner/v1',
|
|
147
|
+
ownerId: crypto.randomUUID(),
|
|
148
|
+
pid: process.pid,
|
|
149
|
+
host: os.hostname(),
|
|
150
|
+
processStartToken: processStartToken(process.pid) ?? '',
|
|
151
|
+
createdAt: new Date().toISOString()
|
|
152
|
+
};
|
|
153
|
+
if (!owner.processStartToken) {
|
|
154
|
+
return { ok: false, error: { code: 'CHECKPOINT_TOPOLOGY_UNSUPPORTED', message: 'Process liveness cannot be verified on this host' } };
|
|
155
|
+
}
|
|
156
|
+
let recoveredOwnerId: string | null = null;
|
|
157
|
+
if (fs.existsSync(paths.lock)) {
|
|
158
|
+
let previous: CheckpointOwner;
|
|
159
|
+
try {
|
|
160
|
+
previous = JSON.parse(fs.readFileSync(paths.lock, 'utf8')) as CheckpointOwner;
|
|
161
|
+
} catch {
|
|
162
|
+
return { ok: false, error: { code: 'CHECKPOINT_RECOVERY_REQUIRED', message: 'Checkpoint lock is unreadable' } };
|
|
163
|
+
}
|
|
164
|
+
if (previous.host !== owner.host || !previous.ownerId || !previous.processStartToken) {
|
|
165
|
+
return { ok: false, error: { code: 'CHECKPOINT_RECOVERY_REQUIRED', message: 'Checkpoint lock owner topology is unknown' } };
|
|
166
|
+
}
|
|
167
|
+
const currentToken = processStartToken(previous.pid);
|
|
168
|
+
if (currentToken && currentToken === previous.processStartToken) {
|
|
169
|
+
return { ok: false, error: { code: 'CHECKPOINT_BUSY', message: 'Checkpoint is held by a live process' } };
|
|
170
|
+
}
|
|
171
|
+
if (process.platform !== 'linux') {
|
|
172
|
+
return { ok: false, error: { code: 'CHECKPOINT_RECOVERY_REQUIRED', message: 'Checkpoint owner liveness cannot be proven on this host' } };
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
recoveredOwnerId = previous.ownerId;
|
|
176
|
+
recovery.quarantined.push(quarantine(paths.lock));
|
|
177
|
+
recovery.recovered = true;
|
|
178
|
+
} catch {
|
|
179
|
+
return { ok: false, error: { code: 'CHECKPOINT_RECOVERY_REQUIRED', message: 'Unable to quarantine an orphaned checkpoint lock' } };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const fd = fs.openSync(paths.lock, 'wx', 0o600);
|
|
184
|
+
try { fs.writeFileSync(fd, `${JSON.stringify(owner)}\n`); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
185
|
+
fsyncDirectory(paths.directory);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
|
188
|
+
return { ok: false, error: { code: 'CHECKPOINT_BUSY', message: 'Checkpoint lock was acquired by another process' } };
|
|
189
|
+
}
|
|
190
|
+
return { ok: false, error: { code: 'CHECKPOINT_LOCK_FAILED', message: String(error) } };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const releaseOwnedLock = () => {
|
|
194
|
+
try {
|
|
195
|
+
fs.unlinkSync(paths.lock);
|
|
196
|
+
fsyncDirectory(paths.directory);
|
|
197
|
+
} catch {
|
|
198
|
+
// Preserve the original recovery error; a remaining lock will fail closed next time.
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
const beforeResult = readGitHubCheckpoint(root, repository);
|
|
202
|
+
if (!beforeResult.ok) {
|
|
203
|
+
releaseOwnedLock();
|
|
204
|
+
return beforeResult;
|
|
205
|
+
}
|
|
206
|
+
let checkpointBefore = beforeResult.value;
|
|
207
|
+
const temporaryCandidates = fs.readdirSync(paths.directory)
|
|
208
|
+
.filter((entry) => entry.startsWith(path.basename(paths.checkpoint) + '.') && entry.endsWith('.tmp'))
|
|
209
|
+
.map((entry) => path.join(paths.directory, entry));
|
|
210
|
+
if (temporaryCandidates.length > 1) {
|
|
211
|
+
releaseOwnedLock();
|
|
212
|
+
return { ok: false, error: { code: 'CHECKPOINT_RECOVERY_REQUIRED', message: 'Multiple checkpoint recovery candidates exist' } };
|
|
213
|
+
}
|
|
214
|
+
if (temporaryCandidates.length === 1) {
|
|
215
|
+
const candidate = temporaryCandidates[0]!;
|
|
216
|
+
try {
|
|
217
|
+
const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8')) as CheckpointCandidate | GitHubCheckpoint;
|
|
218
|
+
const isCandidate = parsed && typeof parsed === 'object' && 'candidateSchema' in parsed
|
|
219
|
+
&& parsed.candidateSchema === 'github-checkpoint-candidate/v1';
|
|
220
|
+
if (isCandidate) {
|
|
221
|
+
const candidateValue = parsed as CheckpointCandidate;
|
|
222
|
+
const value: GitHubCheckpoint = candidateValue;
|
|
223
|
+
const expected = checkpointState(checkpointBefore);
|
|
224
|
+
const candidateExpected = candidateValue.expectedOld;
|
|
225
|
+
if (!recoveredOwnerId || candidateValue.ownerId !== recoveredOwnerId) {
|
|
226
|
+
recovery.quarantined.push(quarantine(candidate));
|
|
227
|
+
releaseOwnedLock();
|
|
228
|
+
return { ok: false, error: { code: 'CHECKPOINT_RECOVERY_REQUIRED', message: 'Checkpoint candidate owner proof does not match the recovered lock owner' } };
|
|
229
|
+
}
|
|
230
|
+
if (!validCheckpoint(value, repository)
|
|
231
|
+
|| candidateExpected?.snapshotId !== expected.snapshotId
|
|
232
|
+
|| candidateExpected?.manifestSha256 !== expected.manifestSha256) {
|
|
233
|
+
releaseOwnedLock();
|
|
234
|
+
return { ok: false, error: { code: 'CHECKPOINT_RECOVERY_REQUIRED', message: 'Checkpoint candidate does not match the current head' } };
|
|
235
|
+
}
|
|
236
|
+
if (!checkpointMatchesSnapshot(root, value)) {
|
|
237
|
+
recovery.quarantined.push(quarantine(candidate));
|
|
238
|
+
releaseOwnedLock();
|
|
239
|
+
return { ok: false, error: { code: 'CHECKPOINT_RECOVERY_REQUIRED', message: 'Checkpoint candidate snapshot failed full verification' } };
|
|
240
|
+
}
|
|
241
|
+
if (process.platform === 'win32' && checkpointBefore) {
|
|
242
|
+
releaseOwnedLock();
|
|
243
|
+
return { ok: false, error: { code: 'CHECKPOINT_TOPOLOGY_UNSUPPORTED', message: 'Checkpoint replacement durability cannot be proven on this host' } };
|
|
244
|
+
}
|
|
245
|
+
fs.renameSync(candidate, paths.checkpoint);
|
|
246
|
+
recovery.recovered = true;
|
|
247
|
+
fsyncDirectory(paths.directory);
|
|
248
|
+
const recovered = readGitHubCheckpoint(root, repository);
|
|
249
|
+
if (!recovered.ok) {
|
|
250
|
+
releaseOwnedLock();
|
|
251
|
+
return recovered;
|
|
252
|
+
}
|
|
253
|
+
checkpointBefore = recovered.value;
|
|
254
|
+
} else {
|
|
255
|
+
recovery.quarantined.push(quarantine(candidate));
|
|
256
|
+
releaseOwnedLock();
|
|
257
|
+
return { ok: false, error: { code: 'CHECKPOINT_RECOVERY_REQUIRED', message: 'Checkpoint candidate lacks owner proof' } };
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
recovery.quarantined.push(quarantine(candidate));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return { ok: true, value: { paths, repository, owner, recovery, expectedOld: checkpointBefore } };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function commitGitHubCheckpoint(lease: CheckpointLease, value: GitHubCheckpoint): ProcessResult<void> {
|
|
267
|
+
try {
|
|
268
|
+
const current = JSON.parse(fs.readFileSync(lease.paths.lock, 'utf8')) as CheckpointOwner;
|
|
269
|
+
if (current.ownerId !== lease.owner.ownerId) {
|
|
270
|
+
return { ok: false, error: { code: 'CHECKPOINT_OWNER_MISMATCH', message: 'Checkpoint lock ownership changed' } };
|
|
271
|
+
}
|
|
272
|
+
if (value.repository !== lease.repository || !validCheckpoint(value, lease.repository)) {
|
|
273
|
+
return { ok: false, error: { code: 'CHECKPOINT_INVALID', message: 'Checkpoint value is invalid' } };
|
|
274
|
+
}
|
|
275
|
+
const root = path.dirname(path.dirname(lease.paths.directory));
|
|
276
|
+
if (!checkpointMatchesSnapshot(root, value)) {
|
|
277
|
+
return { ok: false, error: { code: 'CHECKPOINT_INVALID', message: 'Checkpoint snapshot does not match a verified v2 manifest' } };
|
|
278
|
+
}
|
|
279
|
+
const currentCheckpoint = readGitHubCheckpoint(root, lease.repository);
|
|
280
|
+
if (!currentCheckpoint.ok) return currentCheckpoint;
|
|
281
|
+
if (checkpointState(currentCheckpoint.value).snapshotId !== checkpointState(lease.expectedOld).snapshotId
|
|
282
|
+
|| checkpointState(currentCheckpoint.value).manifestSha256 !== checkpointState(lease.expectedOld).manifestSha256) {
|
|
283
|
+
return { ok: false, error: { code: 'CHECKPOINT_CONFLICT', message: 'Checkpoint head changed before commit' } };
|
|
284
|
+
}
|
|
285
|
+
const candidate: CheckpointCandidate = {
|
|
286
|
+
...value,
|
|
287
|
+
candidateSchema: 'github-checkpoint-candidate/v1',
|
|
288
|
+
ownerId: lease.owner.ownerId,
|
|
289
|
+
expectedOld: checkpointState(lease.expectedOld)
|
|
290
|
+
};
|
|
291
|
+
const temporary = writeTemporary(lease.paths.checkpoint, candidate);
|
|
292
|
+
const rechecked = readGitHubCheckpoint(root, lease.repository);
|
|
293
|
+
if (!rechecked.ok) {
|
|
294
|
+
fs.unlinkSync(temporary);
|
|
295
|
+
return rechecked;
|
|
296
|
+
}
|
|
297
|
+
if (checkpointState(rechecked.value).snapshotId !== checkpointState(lease.expectedOld).snapshotId
|
|
298
|
+
|| checkpointState(rechecked.value).manifestSha256 !== checkpointState(lease.expectedOld).manifestSha256) {
|
|
299
|
+
fs.unlinkSync(temporary);
|
|
300
|
+
return { ok: false, error: { code: 'CHECKPOINT_CONFLICT', message: 'Checkpoint head changed during commit' } };
|
|
301
|
+
}
|
|
302
|
+
if (process.platform === 'win32' && fs.existsSync(lease.paths.checkpoint)) {
|
|
303
|
+
fs.unlinkSync(temporary);
|
|
304
|
+
return { ok: false, error: { code: 'CHECKPOINT_TOPOLOGY_UNSUPPORTED', message: 'Checkpoint replacement durability cannot be proven on this host' } };
|
|
305
|
+
}
|
|
306
|
+
fs.renameSync(temporary, lease.paths.checkpoint);
|
|
307
|
+
if (process.platform !== 'win32') fs.chmodSync(lease.paths.checkpoint, 0o600);
|
|
308
|
+
fsyncDirectory(lease.paths.directory);
|
|
309
|
+
return { ok: true, value: undefined };
|
|
310
|
+
} catch (error) {
|
|
311
|
+
return { ok: false, error: { code: 'CHECKPOINT_COMMIT_FAILED', message: String(error) } };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function releaseGitHubCheckpoint(lease: CheckpointLease): void {
|
|
316
|
+
try {
|
|
317
|
+
const current = JSON.parse(fs.readFileSync(lease.paths.lock, 'utf8')) as CheckpointOwner;
|
|
318
|
+
if (current.ownerId === lease.owner.ownerId) {
|
|
319
|
+
fs.unlinkSync(lease.paths.lock);
|
|
320
|
+
fsyncDirectory(lease.paths.directory);
|
|
321
|
+
}
|
|
322
|
+
} catch {
|
|
323
|
+
// The next invocation will fail closed and report recovery if the lock remains.
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export {
|
|
328
|
+
acquireGitHubCheckpoint,
|
|
329
|
+
checkpointPaths,
|
|
330
|
+
commitGitHubCheckpoint,
|
|
331
|
+
processStartToken,
|
|
332
|
+
readGitHubCheckpoint,
|
|
333
|
+
releaseGitHubCheckpoint
|
|
334
|
+
};
|
|
335
|
+
export type { CheckpointLease, CheckpointPaths };
|
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { execFileSync } from 'node:child_process';
|
|
4
3
|
|
|
5
4
|
import { detectRepoRoot } from '../task/resolve-ref.ts';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { acquireGitHubCheckpoint, commitGitHubCheckpoint, readGitHubCheckpoint, releaseGitHubCheckpoint } from './checkpoint.ts';
|
|
6
|
+
import { materializeJson, materializeSnapshot } from './materialize.ts';
|
|
7
|
+
import { normalizeResources } from './normalize.ts';
|
|
8
|
+
import { reconcileRecords, reconcileResourceRecords } from './reconcile.ts';
|
|
8
9
|
import { readAppliedOverlays, repairSnapshot } from './repair.ts';
|
|
9
|
-
import {
|
|
10
|
-
import { canonicalJsonBytes, createObjectStore,
|
|
10
|
+
import { collectGitHubBoundary, resolveGitHubRepository } from './sources.ts';
|
|
11
|
+
import { canonicalJsonBytes, createObjectStore, findSnapshot, publishSnapshotV2, verifySnapshot } from './store.ts';
|
|
12
|
+
import { createGitHubClient } from '../platform/github-client.ts';
|
|
11
13
|
|
|
12
|
-
import type {
|
|
14
|
+
import type { NormalizedRecord } from './types.ts';
|
|
13
15
|
|
|
14
16
|
const USAGE = `Usage: ai data <command> [options]
|
|
15
17
|
|
|
16
18
|
Commands:
|
|
17
|
-
capture [--source
|
|
19
|
+
capture [--source github] [--root <dir>] [--full-reconcile]
|
|
18
20
|
verify <snapshot-id> [--root <dir>]
|
|
19
21
|
audit <snapshot-id> [--root <dir>] [--format json|text]
|
|
20
22
|
repair <snapshot-id> [--root <dir>] [--apply]
|
|
21
|
-
export <snapshot-id> [--root <dir>] [--repairs none|applied] [--output <file|->]
|
|
23
|
+
export <snapshot-id> [--root <dir>] [--repairs none|applied] [--as-of <ISO>] [--output <file|->]
|
|
22
24
|
`;
|
|
23
25
|
|
|
24
26
|
type ParsedArgs = { positionals: string[]; options: Map<string, string | true> };
|
|
@@ -64,14 +66,6 @@ function dataRoot(repoRoot: string, requested?: string): string {
|
|
|
64
66
|
return resolved;
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
function repositoryIdentity(repoRoot: string): string {
|
|
68
|
-
try {
|
|
69
|
-
return execFileSync('git', ['config', '--get', 'remote.origin.url'], { cwd: repoRoot, encoding: 'utf8' }).trim() || path.basename(repoRoot);
|
|
70
|
-
} catch {
|
|
71
|
-
return path.basename(repoRoot);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
69
|
function writeJson(value: unknown): void {
|
|
76
70
|
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
77
71
|
}
|
|
@@ -82,54 +76,106 @@ function optionString(parsed: ParsedArgs, name: string): string | undefined {
|
|
|
82
76
|
}
|
|
83
77
|
|
|
84
78
|
function capture(args: string[]): number {
|
|
85
|
-
const parsed = parseArgs(args, new Set(['--source', '--root']), new Set(['--include-excerpts']));
|
|
79
|
+
const parsed = parseArgs(args, new Set(['--source', '--root']), new Set(['--full-reconcile', '--include-excerpts']));
|
|
86
80
|
if (parsed.positionals.length > 0) throw new Error('capture does not accept positional arguments');
|
|
87
|
-
const scope = optionString(parsed, '--source') ?? '
|
|
88
|
-
if (
|
|
81
|
+
const scope = optionString(parsed, '--source') ?? 'github';
|
|
82
|
+
if (scope !== 'github') throw new Error('new process-data capture only supports --source github; local data is already normalized and --source all is retired');
|
|
83
|
+
if (parsed.options.has('--include-excerpts')) throw new Error('--include-excerpts is not supported for GitHub-only capture');
|
|
89
84
|
const repoRoot = detectRepoRoot();
|
|
90
85
|
const root = dataRoot(repoRoot, optionString(parsed, '--root'));
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
86
|
+
const client = createGitHubClient();
|
|
87
|
+
const repositoryResult = resolveGitHubRepository(repoRoot, client);
|
|
88
|
+
if (!repositoryResult.ok) { writeJson({ ok: false, error: repositoryResult.error }); return 2; }
|
|
89
|
+
const repository = repositoryResult.value;
|
|
90
|
+
const leaseResult = acquireGitHubCheckpoint(root, repository);
|
|
91
|
+
if (!leaseResult.ok) { writeJson({ ok: false, error: leaseResult.error }); return 2; }
|
|
92
|
+
const lease = leaseResult.value;
|
|
93
|
+
try {
|
|
94
|
+
const checkpointResult = readGitHubCheckpoint(root, repository);
|
|
95
|
+
if (!checkpointResult.ok) { writeJson({ ok: false, error: checkpointResult.error }); return 2; }
|
|
96
|
+
const checkpoint = checkpointResult.value;
|
|
97
|
+
const full = parsed.options.has('--full-reconcile');
|
|
98
|
+
const observedFrom = new Date().toISOString();
|
|
99
|
+
const github = collectGitHubBoundary(repoRoot, {
|
|
100
|
+
client,
|
|
101
|
+
fromInclusive: full ? null : checkpoint?.watermark ?? null,
|
|
102
|
+
reconciliation: full ? 'full' : 'incremental'
|
|
103
|
+
});
|
|
103
104
|
if (!github.ok) { writeJson({ ok: false, error: github.error }); return 2; }
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
105
|
+
const store = createObjectStore(root);
|
|
106
|
+
for (const object of github.value.objects) {
|
|
107
|
+
if (object.content !== undefined && object.disposition?.state === 'included') {
|
|
108
|
+
const stored = store.put(Buffer.from(object.content, 'utf8'));
|
|
109
|
+
if (stored.sha256 !== object.sha256) throw new Error(`source hash mismatch: ${object.sourceIdentity}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
let parent: NormalizedRecord[] = [];
|
|
113
|
+
if (checkpoint) {
|
|
114
|
+
const parentPath = findSnapshot(root, checkpoint.snapshotId);
|
|
115
|
+
if (!parentPath) throw new Error(`checkpoint head snapshot is missing: ${checkpoint.snapshotId}`);
|
|
116
|
+
const materialized = materializeSnapshot(root, checkpoint.snapshotId);
|
|
117
|
+
if (!materialized.manifest || materialized.manifest.manifestSha256 !== checkpoint.manifestSha256 || materialized.manifest.watermark !== checkpoint.watermark) {
|
|
118
|
+
throw new Error('checkpoint does not match its verified snapshot head');
|
|
119
|
+
}
|
|
120
|
+
parent = materialized.records;
|
|
121
|
+
}
|
|
122
|
+
const current = normalizeResources(github.value.objects);
|
|
123
|
+
const reconciliation = reconcileResourceRecords(current, {
|
|
124
|
+
parent,
|
|
125
|
+
full,
|
|
126
|
+
deferred: github.value.deferred,
|
|
127
|
+
unavailable: github.value.unavailable
|
|
128
|
+
});
|
|
129
|
+
const quality = reconcileRecords(reconciliation.records, 'github');
|
|
130
|
+
const fromInclusive = checkpoint?.watermark ?? null;
|
|
131
|
+
const hasStrictSinceEndpoint = github.value.endpoints.some((endpoint) => endpoint.queryMode === 'strict-since');
|
|
132
|
+
const queryAfter = !full && fromInclusive && hasStrictSinceEndpoint
|
|
133
|
+
? new Date(new Date(fromInclusive).getTime() - 1000).toISOString()
|
|
134
|
+
: null;
|
|
135
|
+
const responseDates = github.value.responseDates;
|
|
136
|
+
const published = publishSnapshotV2(root, {
|
|
137
|
+
scope: 'github',
|
|
138
|
+
repository,
|
|
139
|
+
observedFrom,
|
|
140
|
+
observedTo: github.value.watermark,
|
|
141
|
+
objects: github.value.objects.map(({ content: _content, ...object }) => object),
|
|
142
|
+
endpoints: github.value.endpoints,
|
|
143
|
+
excerptsEnabled: false,
|
|
144
|
+
records: reconciliation.records,
|
|
145
|
+
quality: quality.findings,
|
|
146
|
+
repairs: quality.repairs,
|
|
147
|
+
snapshotKind: checkpoint ? 'delta' : 'base',
|
|
148
|
+
parentSnapshotId: checkpoint?.snapshotId ?? null,
|
|
149
|
+
checkpointBefore: checkpoint?.snapshotId ?? null,
|
|
150
|
+
watermark: github.value.watermark,
|
|
151
|
+
window: { fromInclusive, queryAfter, toExclusive: github.value.watermark, precision: 'second' },
|
|
152
|
+
observation: {
|
|
153
|
+
cutoffSource: 'github-response-date',
|
|
154
|
+
preflightDate: github.value.preflightDate,
|
|
155
|
+
responseDates
|
|
156
|
+
},
|
|
157
|
+
coverage: { mode: 'boundary-reread-with-full-reconcile', absoluteCompleteness: false },
|
|
158
|
+
reconciliation: full ? 'full' : 'incremental',
|
|
159
|
+
operations: reconciliation.operations
|
|
160
|
+
});
|
|
161
|
+
const verified = verifySnapshot(root, published.snapshotId);
|
|
162
|
+
if (!verified.ok || !verified.manifest || verified.manifest.schema !== 'raw-manifest/v2') {
|
|
163
|
+
throw new Error(`published snapshot failed verification: ${verified.errors.join('; ')}`);
|
|
114
164
|
}
|
|
165
|
+
const committed = commitGitHubCheckpoint(lease, {
|
|
166
|
+
schema: 'github-checkpoint/v1',
|
|
167
|
+
repository,
|
|
168
|
+
snapshotId: published.snapshotId,
|
|
169
|
+
watermark: github.value.watermark,
|
|
170
|
+
manifestSha256: verified.manifest.manifestSha256,
|
|
171
|
+
committedAt: new Date().toISOString()
|
|
172
|
+
});
|
|
173
|
+
if (!committed.ok) { writeJson({ ok: false, error: committed.error }); return 2; }
|
|
174
|
+
writeJson({ ok: true, snapshotId: published.snapshotId, scope, snapshotKind: checkpoint ? 'delta' : 'base', reconciliation: full ? 'full' : 'incremental', created: published.created, objects: github.value.objects.length, records: reconciliation.records.length, watermark: github.value.watermark, deferred: github.value.deferred.length });
|
|
175
|
+
return 0;
|
|
176
|
+
} finally {
|
|
177
|
+
releaseGitHubCheckpoint(lease);
|
|
115
178
|
}
|
|
116
|
-
const records = normalizeObjects(objects);
|
|
117
|
-
const reconciliation = reconcileRecords(records, scope as 'all' | 'local' | 'github');
|
|
118
|
-
const observedTo = new Date().toISOString();
|
|
119
|
-
const published = publishSnapshot(root, {
|
|
120
|
-
scope: scope as 'all' | 'local' | 'github',
|
|
121
|
-
repository,
|
|
122
|
-
observedFrom,
|
|
123
|
-
observedTo,
|
|
124
|
-
objects: objects.map(({ content: _content, ...object }) => object),
|
|
125
|
-
endpoints,
|
|
126
|
-
excerptsEnabled: parsed.options.has('--include-excerpts'),
|
|
127
|
-
records,
|
|
128
|
-
quality: reconciliation.findings,
|
|
129
|
-
repairs: reconciliation.repairs
|
|
130
|
-
});
|
|
131
|
-
writeJson({ ok: true, snapshotId: published.snapshotId, scope, created: published.created, objects: objects.length, records: records.length });
|
|
132
|
-
return 0;
|
|
133
179
|
}
|
|
134
180
|
|
|
135
181
|
function verify(args: string[]): number {
|
|
@@ -175,7 +221,7 @@ function repair(args: string[]): number {
|
|
|
175
221
|
}
|
|
176
222
|
|
|
177
223
|
function exportSnapshot(args: string[]): number {
|
|
178
|
-
const parsed = parseArgs(args, new Set(['--root', '--repairs', '--output']), new Set());
|
|
224
|
+
const parsed = parseArgs(args, new Set(['--root', '--repairs', '--output', '--as-of']), new Set());
|
|
179
225
|
if (parsed.positionals.length !== 1) throw new Error('export requires one snapshot id');
|
|
180
226
|
const repairMode = optionString(parsed, '--repairs') ?? 'none';
|
|
181
227
|
if (!['none', 'applied'].includes(repairMode)) throw new Error('--repairs must be none or applied');
|
|
@@ -184,7 +230,13 @@ function exportSnapshot(args: string[]): number {
|
|
|
184
230
|
const root = dataRoot(repoRoot, optionString(parsed, '--root'));
|
|
185
231
|
const verified = verifySnapshot(root, parsed.positionals[0]!);
|
|
186
232
|
if (!verified.ok || !verified.snapshotPath) { writeJson(verified); return 1; }
|
|
187
|
-
let content
|
|
233
|
+
let content: string;
|
|
234
|
+
if (verified.manifest?.schema === 'raw-manifest/v2') {
|
|
235
|
+
content = materializeJson(root, parsed.positionals[0]!, { asOf: optionString(parsed, '--as-of') });
|
|
236
|
+
} else {
|
|
237
|
+
if (optionString(parsed, '--as-of')) throw new Error('--as-of is only supported for v2 GitHub snapshots');
|
|
238
|
+
content = fs.readFileSync(path.join(verified.snapshotPath, 'normalized.jsonl'), 'utf8');
|
|
239
|
+
}
|
|
188
240
|
if (repairMode === 'applied') content += readAppliedOverlays(root, parsed.positionals[0]!);
|
|
189
241
|
if (output === '-') process.stdout.write(content);
|
|
190
242
|
else {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { findSnapshot, verifySnapshot } from './store.ts';
|
|
5
|
+
import type { NormalizedRecord, SnapshotManifestV2 } from './types.ts';
|
|
6
|
+
|
|
7
|
+
type MaterializeOptions = { asOf?: string };
|
|
8
|
+
type MaterializeResult = { snapshotId: string; records: NormalizedRecord[]; manifest: SnapshotManifestV2 | null };
|
|
9
|
+
|
|
10
|
+
function readRecords(snapshotPath: string, schema: 'normalized/v1' | 'normalized/v2'): NormalizedRecord[] {
|
|
11
|
+
const file = path.join(snapshotPath, 'normalized.jsonl');
|
|
12
|
+
if (!fs.existsSync(file)) return [];
|
|
13
|
+
return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).flatMap((line) => {
|
|
14
|
+
const value = JSON.parse(line) as { schema?: string } & NormalizedRecord;
|
|
15
|
+
if (value.schema !== schema) return [];
|
|
16
|
+
const { schema: _schema, ...record } = value;
|
|
17
|
+
return [record];
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function materializeSnapshot(root: string, snapshotId: string, options: MaterializeOptions = {}): MaterializeResult {
|
|
22
|
+
const target = options.asOf ? new Date(options.asOf) : null;
|
|
23
|
+
if (target && !Number.isFinite(target.getTime())) throw new Error('as-of must be an ISO timestamp');
|
|
24
|
+
const chain: Array<{ id: string; path: string; manifest: SnapshotManifestV2 }> = [];
|
|
25
|
+
let currentId: string | null = snapshotId;
|
|
26
|
+
let repository: string | null = null;
|
|
27
|
+
while (currentId) {
|
|
28
|
+
const currentPath = findSnapshot(root, currentId);
|
|
29
|
+
if (!currentPath) throw new Error(`snapshot not found: ${currentId}`);
|
|
30
|
+
const verified = verifySnapshot(root, currentId);
|
|
31
|
+
if (!verified.ok || !verified.manifest) throw new Error(`snapshot is not verified: ${currentId}`);
|
|
32
|
+
if (verified.manifest.schema !== 'raw-manifest/v2') {
|
|
33
|
+
if (chain.length > 0) throw new Error('v1 and v2 snapshots cannot share a lineage');
|
|
34
|
+
return { snapshotId, records: readRecords(currentPath, 'normalized/v1'), manifest: null };
|
|
35
|
+
}
|
|
36
|
+
const manifest = verified.manifest;
|
|
37
|
+
if (repository && manifest.repository !== repository) throw new Error('snapshot lineage repository mismatch');
|
|
38
|
+
repository = manifest.repository;
|
|
39
|
+
chain.push({ id: currentId, path: currentPath, manifest });
|
|
40
|
+
currentId = manifest.parentSnapshotId;
|
|
41
|
+
}
|
|
42
|
+
if (chain.length === 0) throw new Error('snapshot lineage is empty');
|
|
43
|
+
const chronological = [...chain].reverse();
|
|
44
|
+
for (let index = 1; index < chronological.length; index += 1) {
|
|
45
|
+
if (new Date(chronological[index - 1]!.manifest.watermark).getTime() >= new Date(chronological[index]!.manifest.watermark).getTime()) {
|
|
46
|
+
throw new Error('snapshot watermark is not monotonic');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const view = new Map<string, NormalizedRecord>();
|
|
50
|
+
let appliedManifest: SnapshotManifestV2 | null = null;
|
|
51
|
+
for (const entry of chronological) {
|
|
52
|
+
if (target && new Date(entry.manifest.watermark).getTime() > target.getTime()) continue;
|
|
53
|
+
appliedManifest = entry.manifest;
|
|
54
|
+
for (const record of readRecords(entry.path, 'normalized/v2')) {
|
|
55
|
+
const identity = record.resourceIdentity ?? record.sourceIdentity;
|
|
56
|
+
switch (record.operation) {
|
|
57
|
+
case 'tombstone':
|
|
58
|
+
view.delete(identity);
|
|
59
|
+
break;
|
|
60
|
+
case 'unavailable':
|
|
61
|
+
view.set(identity, record);
|
|
62
|
+
break;
|
|
63
|
+
default:
|
|
64
|
+
view.set(identity, { ...record, operation: undefined });
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (target && !appliedManifest) throw new Error('no verified snapshot is available at the requested as-of time');
|
|
70
|
+
return {
|
|
71
|
+
snapshotId,
|
|
72
|
+
records: [...view.values()].sort((left, right) => left.recordId.localeCompare(right.recordId)),
|
|
73
|
+
manifest: appliedManifest
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function materializeJson(root: string, snapshotId: string, options: MaterializeOptions = {}): string {
|
|
78
|
+
const result = materializeSnapshot(root, snapshotId, options);
|
|
79
|
+
return result.records.map((record) => JSON.stringify({ schema: 'normalized/v2', ...record })).join('\n') + (result.records.length ? '\n' : '');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export { materializeJson, materializeSnapshot };
|
|
83
|
+
export type { MaterializeOptions, MaterializeResult };
|