taphound 0.2.0-dev.1
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/LICENSE +202 -0
- package/README.md +200 -0
- package/README.zh-CN.md +200 -0
- package/assets/brand/taphound-mark.svg +9 -0
- package/assets/skills/taphound-ai-journey/GUIDE.md +862 -0
- package/assets/skills/taphound-ai-journey/SKILL.md +377 -0
- package/assets/skills/taphound-ai-journey/prompts/analyze-project.md +526 -0
- package/assets/skills/taphound-ai-journey/prompts/check-completion.md +52 -0
- package/assets/skills/taphound-ai-journey/prompts/generate-step.md +109 -0
- package/assets/skills/taphound-ai-journey/schemas/observe-output.json +42 -0
- package/assets/skills/taphound-ai-journey/schemas/project-context.json +81 -0
- package/assets/skills/taphound-ai-journey/schemas/proposed-step-envelope.json +267 -0
- package/assets/skills/taphound-ai-journey/templates/project-context.example.json +49 -0
- package/dist/adapters/adb/adb-adapter.d.ts +23 -0
- package/dist/adapters/adb/adb-adapter.js +218 -0
- package/dist/adapters/android-cli/android-cli-adapter.d.ts +11 -0
- package/dist/adapters/android-cli/android-cli-adapter.js +64 -0
- package/dist/adapters/android-cli/layout-parser.d.ts +3 -0
- package/dist/adapters/android-cli/layout-parser.js +174 -0
- package/dist/adapters/clock/system-clock.d.ts +5 -0
- package/dist/adapters/clock/system-clock.js +13 -0
- package/dist/adapters/filesystem/artifact-store.d.ts +4 -0
- package/dist/adapters/filesystem/artifact-store.js +73 -0
- package/dist/adapters/filesystem/generation-meta-writer.d.ts +16 -0
- package/dist/adapters/filesystem/generation-meta-writer.js +34 -0
- package/dist/adapters/filesystem/generation-session-store.d.ts +68 -0
- package/dist/adapters/filesystem/generation-session-store.js +1603 -0
- package/dist/adapters/filesystem/journey-writer.d.ts +16 -0
- package/dist/adapters/filesystem/journey-writer.js +34 -0
- package/dist/adapters/filesystem/project-bound-file.d.ts +6 -0
- package/dist/adapters/filesystem/project-bound-file.js +240 -0
- package/dist/adapters/filesystem/project-file-inspector.d.ts +8 -0
- package/dist/adapters/filesystem/project-file-inspector.js +198 -0
- package/dist/adapters/filesystem/skill-installer.d.ts +10 -0
- package/dist/adapters/filesystem/skill-installer.js +39 -0
- package/dist/adapters/process/node-process-runner.d.ts +8 -0
- package/dist/adapters/process/node-process-runner.js +155 -0
- package/dist/adapters/prompt/inquirer-generation-prompt.d.ts +55 -0
- package/dist/adapters/prompt/inquirer-generation-prompt.js +213 -0
- package/dist/adapters/prompt/inquirer-init-prompt.d.ts +32 -0
- package/dist/adapters/prompt/inquirer-init-prompt.js +55 -0
- package/dist/adapters/prompt/inquirer-recorder-prompt.d.ts +47 -0
- package/dist/adapters/prompt/inquirer-recorder-prompt.js +157 -0
- package/dist/application/assertion/expectation-evaluator.d.ts +61 -0
- package/dist/application/assertion/expectation-evaluator.js +207 -0
- package/dist/application/collector/logcat-collector.d.ts +39 -0
- package/dist/application/collector/logcat-collector.js +107 -0
- package/dist/application/context/context-validator.d.ts +27 -0
- package/dist/application/context/context-validator.js +120 -0
- package/dist/application/doctor/doctor-service.d.ts +35 -0
- package/dist/application/doctor/doctor-service.js +178 -0
- package/dist/application/generation/focused-input.d.ts +2 -0
- package/dist/application/generation/focused-input.js +9 -0
- package/dist/application/generation/generation-confirmation-service.d.ts +76 -0
- package/dist/application/generation/generation-confirmation-service.js +383 -0
- package/dist/application/generation/generation-finalizer.d.ts +67 -0
- package/dist/application/generation/generation-finalizer.js +646 -0
- package/dist/application/generation/generation-publisher.d.ts +46 -0
- package/dist/application/generation/generation-publisher.js +178 -0
- package/dist/application/generation/generation-starter.d.ts +31 -0
- package/dist/application/generation/generation-starter.js +102 -0
- package/dist/application/generation/generation-step-executor.d.ts +52 -0
- package/dist/application/generation/generation-step-executor.js +668 -0
- package/dist/application/generation/proposed-step-validator.d.ts +11 -0
- package/dist/application/generation/proposed-step-validator.js +88 -0
- package/dist/application/generation/risk-evaluator.d.ts +9 -0
- package/dist/application/generation/risk-evaluator.js +14 -0
- package/dist/application/generation/runtime-observer.d.ts +39 -0
- package/dist/application/generation/runtime-observer.js +157 -0
- package/dist/application/init/init-service.d.ts +17 -0
- package/dist/application/init/init-service.js +44 -0
- package/dist/application/interaction/action-executor.d.ts +25 -0
- package/dist/application/interaction/action-executor.js +103 -0
- package/dist/application/interaction/fallback-resolver.d.ts +24 -0
- package/dist/application/interaction/fallback-resolver.js +48 -0
- package/dist/application/interaction/scroll-to-executor.d.ts +40 -0
- package/dist/application/interaction/scroll-to-executor.js +191 -0
- package/dist/application/locator/locator-resolver.d.ts +20 -0
- package/dist/application/locator/locator-resolver.js +89 -0
- package/dist/application/project/project-describer.d.ts +14 -0
- package/dist/application/project/project-describer.js +8 -0
- package/dist/application/recorder/locator-selector.d.ts +10 -0
- package/dist/application/recorder/locator-selector.js +80 -0
- package/dist/application/recorder/recorder-service.d.ts +41 -0
- package/dist/application/recorder/recorder-service.js +331 -0
- package/dist/application/report/report-writer.d.ts +10 -0
- package/dist/application/report/report-writer.js +39 -0
- package/dist/application/runtime/activity-waiter.d.ts +33 -0
- package/dist/application/runtime/activity-waiter.js +73 -0
- package/dist/application/runtime/launch-failure.d.ts +2 -0
- package/dist/application/runtime/launch-failure.js +20 -0
- package/dist/application/runtime/process-waiter.d.ts +27 -0
- package/dist/application/runtime/process-waiter.js +73 -0
- package/dist/application/runtime/step-runner.d.ts +50 -0
- package/dist/application/runtime/step-runner.js +429 -0
- package/dist/application/runtime/verify-runtime.d.ts +44 -0
- package/dist/application/runtime/verify-runtime.js +350 -0
- package/dist/application/wait/idle-waiter.d.ts +30 -0
- package/dist/application/wait/idle-waiter.js +90 -0
- package/dist/cli/commands/context.d.ts +3 -0
- package/dist/cli/commands/context.js +94 -0
- package/dist/cli/commands/doctor.d.ts +3 -0
- package/dist/cli/commands/doctor.js +51 -0
- package/dist/cli/commands/generation.d.ts +3 -0
- package/dist/cli/commands/generation.js +518 -0
- package/dist/cli/commands/init.d.ts +3 -0
- package/dist/cli/commands/init.js +104 -0
- package/dist/cli/commands/project.d.ts +3 -0
- package/dist/cli/commands/project.js +58 -0
- package/dist/cli/commands/record.d.ts +3 -0
- package/dist/cli/commands/record.js +88 -0
- package/dist/cli/commands/verify.d.ts +3 -0
- package/dist/cli/commands/verify.js +106 -0
- package/dist/cli/dependencies.d.ts +66 -0
- package/dist/cli/dependencies.js +204 -0
- package/dist/cli/main.d.ts +8 -0
- package/dist/cli/main.js +73 -0
- package/dist/cli/output.d.ts +16 -0
- package/dist/cli/output.js +22 -0
- package/dist/cli/program.d.ts +3 -0
- package/dist/cli/program.js +43 -0
- package/dist/domain/activity.d.ts +7 -0
- package/dist/domain/activity.js +65 -0
- package/dist/domain/app-process.d.ts +7 -0
- package/dist/domain/app-process.js +10 -0
- package/dist/domain/config.d.ts +15 -0
- package/dist/domain/config.js +17 -0
- package/dist/domain/failure.d.ts +4 -0
- package/dist/domain/failure.js +43 -0
- package/dist/domain/generation.d.ts +912 -0
- package/dist/domain/generation.js +342 -0
- package/dist/domain/init.d.ts +42 -0
- package/dist/domain/init.js +114 -0
- package/dist/domain/journey.d.ts +673 -0
- package/dist/domain/journey.js +109 -0
- package/dist/domain/layout.d.ts +35 -0
- package/dist/domain/layout.js +33 -0
- package/dist/domain/locator.d.ts +8 -0
- package/dist/domain/locator.js +5 -0
- package/dist/domain/project-context.d.ts +101 -0
- package/dist/domain/project-context.js +64 -0
- package/dist/domain/proposed-step.d.ts +341 -0
- package/dist/domain/proposed-step.js +87 -0
- package/dist/domain/report.d.ts +349 -0
- package/dist/domain/report.js +135 -0
- package/dist/domain/runtime-snapshot.d.ts +16 -0
- package/dist/domain/runtime-snapshot.js +46 -0
- package/dist/ports/adb.d.ts +42 -0
- package/dist/ports/adb.js +1 -0
- package/dist/ports/android-cli.d.ts +21 -0
- package/dist/ports/android-cli.js +1 -0
- package/dist/ports/artifact-store.d.ts +12 -0
- package/dist/ports/artifact-store.js +1 -0
- package/dist/ports/clock.d.ts +4 -0
- package/dist/ports/clock.js +1 -0
- package/dist/ports/generation-meta-writer.d.ts +10 -0
- package/dist/ports/generation-meta-writer.js +1 -0
- package/dist/ports/generation-prompt.d.ts +19 -0
- package/dist/ports/generation-prompt.js +6 -0
- package/dist/ports/generation-session-store.d.ts +32 -0
- package/dist/ports/generation-session-store.js +26 -0
- package/dist/ports/init-prompt.d.ts +8 -0
- package/dist/ports/init-prompt.js +6 -0
- package/dist/ports/journey-writer.d.ts +10 -0
- package/dist/ports/journey-writer.js +1 -0
- package/dist/ports/process-runner.d.ts +32 -0
- package/dist/ports/process-runner.js +1 -0
- package/dist/ports/project-bound-file.d.ts +8 -0
- package/dist/ports/project-bound-file.js +1 -0
- package/dist/ports/project-file-inspector.d.ts +33 -0
- package/dist/ports/project-file-inspector.js +1 -0
- package/dist/ports/recorder-prompt.d.ts +29 -0
- package/dist/ports/recorder-prompt.js +1 -0
- package/dist/ports/skill-installer.d.ts +8 -0
- package/dist/ports/skill-installer.js +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,1603 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants, link, lstat, mkdir, open, readdir, realpath, rename, rm, unlink } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, posix, relative, resolve } from "node:path";
|
|
4
|
+
import { GenerationInFlightSchema, GenerationSessionSchema, PendingConfirmationSchema, generationCoreIdentity } from "../../domain/generation.js";
|
|
5
|
+
import { GenerationSessionStoreError } from "../../ports/generation-session-store.js";
|
|
6
|
+
const DEFAULT_OPTIONS = {
|
|
7
|
+
lockTimeoutMs: 2_000,
|
|
8
|
+
lockRetryMs: 10
|
|
9
|
+
};
|
|
10
|
+
const HOOK_NAMES = [
|
|
11
|
+
"beforeLockStagingWrite",
|
|
12
|
+
"beforeLockInstall",
|
|
13
|
+
"afterLockTombstoneRename",
|
|
14
|
+
"afterStateOpen",
|
|
15
|
+
"beforeStateRename",
|
|
16
|
+
"beforePublishRename",
|
|
17
|
+
"beforeEvidenceInstall",
|
|
18
|
+
"afterEvidenceInstall",
|
|
19
|
+
"afterEvidenceOpen",
|
|
20
|
+
"afterEvidenceRead",
|
|
21
|
+
"afterEvidenceDirectoryRead",
|
|
22
|
+
"afterDirectorySync"
|
|
23
|
+
];
|
|
24
|
+
function parseStoreConfiguration(projectRoot, options) {
|
|
25
|
+
try {
|
|
26
|
+
if (typeof projectRoot !== "string") {
|
|
27
|
+
throw new TypeError("projectRoot must be a string");
|
|
28
|
+
}
|
|
29
|
+
if (options === null || typeof options !== "object") {
|
|
30
|
+
throw new TypeError("options must be an object");
|
|
31
|
+
}
|
|
32
|
+
const lockTimeoutInput = Reflect.get(options, "lockTimeoutMs");
|
|
33
|
+
const lockRetryInput = Reflect.get(options, "lockRetryMs");
|
|
34
|
+
const nowInput = Reflect.get(options, "now");
|
|
35
|
+
const lockTimeoutMs = lockTimeoutInput
|
|
36
|
+
?? DEFAULT_OPTIONS.lockTimeoutMs;
|
|
37
|
+
const lockRetryMs = lockRetryInput ?? DEFAULT_OPTIONS.lockRetryMs;
|
|
38
|
+
if (!Number.isSafeInteger(lockTimeoutMs)
|
|
39
|
+
|| lockTimeoutMs < 0
|
|
40
|
+
|| !Number.isSafeInteger(lockRetryMs)
|
|
41
|
+
|| lockRetryMs < 0) {
|
|
42
|
+
throw new TypeError("lock timing options must be safe integers");
|
|
43
|
+
}
|
|
44
|
+
if (nowInput !== undefined && typeof nowInput !== "function") {
|
|
45
|
+
throw new TypeError("now must be a function");
|
|
46
|
+
}
|
|
47
|
+
const hooksInput = Reflect.get(options, "hooks");
|
|
48
|
+
const hooks = {};
|
|
49
|
+
if (hooksInput !== undefined) {
|
|
50
|
+
if (hooksInput === null || typeof hooksInput !== "object") {
|
|
51
|
+
throw new TypeError("hooks must be an object");
|
|
52
|
+
}
|
|
53
|
+
for (const name of HOOK_NAMES) {
|
|
54
|
+
const hook = Reflect.get(hooksInput, name);
|
|
55
|
+
if (hook !== undefined && typeof hook !== "function") {
|
|
56
|
+
throw new TypeError(`${name} must be a function`);
|
|
57
|
+
}
|
|
58
|
+
if (hook !== undefined) {
|
|
59
|
+
hooks[name] = hook;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
projectRoot: resolve(projectRoot),
|
|
65
|
+
options: {
|
|
66
|
+
lockTimeoutMs: lockTimeoutMs,
|
|
67
|
+
lockRetryMs: lockRetryMs
|
|
68
|
+
},
|
|
69
|
+
now: (nowInput ?? (() => new Date())),
|
|
70
|
+
hooks
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
throw new GenerationSessionStoreError("IO_ERROR", "Generation session store configuration is invalid", { cause: error });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function snapshotMetadata(stats) {
|
|
78
|
+
return {
|
|
79
|
+
dev: stats.dev,
|
|
80
|
+
ino: stats.ino,
|
|
81
|
+
size: stats.size,
|
|
82
|
+
mtimeNs: stats.mtimeNs,
|
|
83
|
+
ctimeNs: stats.ctimeNs
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function sameSnapshotMetadata(left, right) {
|
|
87
|
+
return (sameIdentity(left, right)
|
|
88
|
+
&& left.size === right.size
|
|
89
|
+
&& left.mtimeNs === right.mtimeNs
|
|
90
|
+
&& left.ctimeNs === right.ctimeNs);
|
|
91
|
+
}
|
|
92
|
+
function evidenceEntryType(entry) {
|
|
93
|
+
if (entry.isFile())
|
|
94
|
+
return "file";
|
|
95
|
+
if (entry.isDirectory())
|
|
96
|
+
return "directory";
|
|
97
|
+
if (entry.isSymbolicLink())
|
|
98
|
+
return "symlink";
|
|
99
|
+
return "other";
|
|
100
|
+
}
|
|
101
|
+
function evidenceEntrySnapshot(entries) {
|
|
102
|
+
return entries
|
|
103
|
+
.map((entry) => `${entry.name}\0${evidenceEntryType(entry)}`)
|
|
104
|
+
.sort();
|
|
105
|
+
}
|
|
106
|
+
function sameIdentity(left, right) {
|
|
107
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
108
|
+
}
|
|
109
|
+
async function captureStoreDirectory(path) {
|
|
110
|
+
const stats = await lstat(path, { bigint: true });
|
|
111
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
112
|
+
throw new GenerationSessionStoreError("IO_ERROR", `Generation store path is not a real directory: ${path}`);
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
path,
|
|
116
|
+
canonicalPath: await realpath(path),
|
|
117
|
+
identity: { dev: stats.dev, ino: stats.ino }
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
async function verifyStoreDirectory(expected) {
|
|
121
|
+
const current = await captureStoreDirectory(expected.path);
|
|
122
|
+
if (current.canonicalPath !== expected.canonicalPath
|
|
123
|
+
|| !sameIdentity(current.identity, expected.identity)) {
|
|
124
|
+
throw new GenerationSessionStoreError("IO_ERROR", `Generation store directory identity changed: ${expected.path}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function captureStoreFile(path) {
|
|
128
|
+
const stats = await lstat(path, { bigint: true });
|
|
129
|
+
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
130
|
+
throw new GenerationSessionStoreError("IO_ERROR", `Generation store path is not a real file: ${path}`);
|
|
131
|
+
}
|
|
132
|
+
return { dev: stats.dev, ino: stats.ino };
|
|
133
|
+
}
|
|
134
|
+
async function captureOptionalStoreFile(path) {
|
|
135
|
+
try {
|
|
136
|
+
return await captureStoreFile(path);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function verifyOptionalStoreFile(path, expected) {
|
|
146
|
+
const current = await captureOptionalStoreFile(path);
|
|
147
|
+
if ((current === null) !== (expected === null)
|
|
148
|
+
|| (current !== null
|
|
149
|
+
&& expected !== null
|
|
150
|
+
&& !sameIdentity(current, expected))) {
|
|
151
|
+
throw new GenerationSessionStoreError("IO_ERROR", `Generation state file identity changed: ${path}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function isNodeError(error) {
|
|
155
|
+
return error instanceof Error && "code" in error;
|
|
156
|
+
}
|
|
157
|
+
function asStoreIoError(error, action) {
|
|
158
|
+
if (error instanceof GenerationSessionStoreError) {
|
|
159
|
+
return error;
|
|
160
|
+
}
|
|
161
|
+
return new GenerationSessionStoreError("IO_ERROR", `Generation session store failed to ${action}`, { cause: error });
|
|
162
|
+
}
|
|
163
|
+
function assertId(id) {
|
|
164
|
+
if (typeof id !== "string"
|
|
165
|
+
|| !/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) {
|
|
166
|
+
throw new GenerationSessionStoreError("INVALID_ID", "Invalid generation session id");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function parseSession(value, classifyInvalidId = false) {
|
|
170
|
+
try {
|
|
171
|
+
return GenerationSessionSchema.parse(value);
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (classifyInvalidId && value !== null && typeof value === "object") {
|
|
175
|
+
try {
|
|
176
|
+
const id = Reflect.get(value, "id");
|
|
177
|
+
if (typeof id === "string") {
|
|
178
|
+
assertId(id);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch (idError) {
|
|
182
|
+
if (idError instanceof GenerationSessionStoreError) {
|
|
183
|
+
throw idError;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
throw new GenerationSessionStoreError("INVALID_SESSION", "Generation session state is invalid", { cause: error });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function sessionId(value) {
|
|
191
|
+
const id = value.id;
|
|
192
|
+
assertId(id);
|
|
193
|
+
return id;
|
|
194
|
+
}
|
|
195
|
+
function validateExpectedRevision(revision) {
|
|
196
|
+
if (!Number.isSafeInteger(revision) || revision < 0) {
|
|
197
|
+
throw new GenerationSessionStoreError("INVALID_REVISION", "Invalid expected generation revision");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function validateInitialRevision(session) {
|
|
201
|
+
if (session.revision !== 0) {
|
|
202
|
+
throw new GenerationSessionStoreError("INVALID_REVISION", "A new generation session must start at revision 0");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function validateNextRevision(id, expectedRevision, next) {
|
|
206
|
+
validateExpectedRevision(expectedRevision);
|
|
207
|
+
if (expectedRevision === Number.MAX_SAFE_INTEGER) {
|
|
208
|
+
throw new GenerationSessionStoreError("INVALID_REVISION", "Generation revision cannot increment beyond Number.MAX_SAFE_INTEGER");
|
|
209
|
+
}
|
|
210
|
+
if (sessionId(next) !== id) {
|
|
211
|
+
throw new GenerationSessionStoreError("INVALID_ID", "Updated generation session id does not match the requested id");
|
|
212
|
+
}
|
|
213
|
+
if (next.revision !== expectedRevision + 1) {
|
|
214
|
+
throw new GenerationSessionStoreError("INVALID_REVISION", "Next generation revision must increment expectedRevision by exactly one");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function parseInFlight(value) {
|
|
218
|
+
try {
|
|
219
|
+
return GenerationInFlightSchema.parse(value);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
throw new GenerationSessionStoreError("INVALID_TRANSITION", "Expected inFlight record is invalid", { cause: error });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function sameInFlight(left, right) {
|
|
226
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
227
|
+
}
|
|
228
|
+
function transitionStableState(session) {
|
|
229
|
+
return {
|
|
230
|
+
version: session.version,
|
|
231
|
+
id: session.id,
|
|
232
|
+
bindings: session.bindings,
|
|
233
|
+
target: session.target,
|
|
234
|
+
variables: session.variables,
|
|
235
|
+
candidateSteps: session.candidateSteps,
|
|
236
|
+
candidateSources: session.candidateSources,
|
|
237
|
+
pendingConfirmation: session.pendingConfirmation,
|
|
238
|
+
verification: session.verification,
|
|
239
|
+
publication: session.publication
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function assertCoreIdentityPreserved(current, next) {
|
|
243
|
+
if (JSON.stringify(generationCoreIdentity(current))
|
|
244
|
+
!== JSON.stringify(generationCoreIdentity(next))) {
|
|
245
|
+
throw new GenerationSessionStoreError("INVALID_TRANSITION", "Generation Core identity is immutable");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function assertLatestSnapshotPreserved(current, next) {
|
|
249
|
+
if (current.bindings.snapshotHash !== next.bindings.snapshotHash) {
|
|
250
|
+
throw new GenerationSessionStoreError("INVALID_TRANSITION", "Latest snapshot may only change through commitSnapshot");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function assertSnapshotTransition(current, next) {
|
|
254
|
+
assertCoreIdentityPreserved(current, next);
|
|
255
|
+
const currentBindings = {
|
|
256
|
+
projectHash: current.bindings.projectHash,
|
|
257
|
+
configHash: current.bindings.configHash,
|
|
258
|
+
contextHash: current.bindings.contextHash
|
|
259
|
+
};
|
|
260
|
+
const { snapshotHash: nextSnapshotHash, ...nextBindings } = next.bindings;
|
|
261
|
+
if (current.state !== "active"
|
|
262
|
+
|| current.inFlight !== null
|
|
263
|
+
|| current.pendingConfirmation !== null
|
|
264
|
+
|| current.verification.status !== "notRun"
|
|
265
|
+
|| current.publication.status !== "notRun"
|
|
266
|
+
|| next.state !== "active"
|
|
267
|
+
|| next.inFlight !== null
|
|
268
|
+
|| next.pendingConfirmation !== null
|
|
269
|
+
|| nextSnapshotHash === null
|
|
270
|
+
|| JSON.stringify(currentBindings) !== JSON.stringify(nextBindings)
|
|
271
|
+
|| JSON.stringify({
|
|
272
|
+
...transitionStableState(current),
|
|
273
|
+
bindings: undefined
|
|
274
|
+
}) !== JSON.stringify({
|
|
275
|
+
...transitionStableState(next),
|
|
276
|
+
bindings: undefined
|
|
277
|
+
})) {
|
|
278
|
+
throw new GenerationSessionStoreError("INVALID_TRANSITION", "Snapshot commit may only replace the active snapshot binding");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function assertOrdinaryTransition(current, next) {
|
|
282
|
+
assertCoreIdentityPreserved(current, next);
|
|
283
|
+
assertLatestSnapshotPreserved(current, next);
|
|
284
|
+
if (current.verification.status !== "notRun"
|
|
285
|
+
|| current.publication.status !== "notRun"
|
|
286
|
+
|| JSON.stringify(current.verification) !== JSON.stringify(next.verification)
|
|
287
|
+
|| JSON.stringify(current.publication) !== JSON.stringify(next.publication)) {
|
|
288
|
+
throw new GenerationSessionStoreError("INVALID_TRANSITION", "Verification and publication require explicit transitions");
|
|
289
|
+
}
|
|
290
|
+
if (current.state === "recoveryRequired") {
|
|
291
|
+
throw new GenerationSessionStoreError("INVALID_TRANSITION", "Recovery-required state must use the explicit recovery transition");
|
|
292
|
+
}
|
|
293
|
+
if (JSON.stringify(current.pendingConfirmation)
|
|
294
|
+
!== JSON.stringify(next.pendingConfirmation)) {
|
|
295
|
+
throw new GenerationSessionStoreError("INVALID_TRANSITION", "Pending confirmation must use the explicit confirmation transition");
|
|
296
|
+
}
|
|
297
|
+
const stableStateMatches = JSON.stringify(transitionStableState(current))
|
|
298
|
+
=== JSON.stringify(transitionStableState(next));
|
|
299
|
+
if (current.inFlight !== null) {
|
|
300
|
+
if (next.state === "recoveryRequired"
|
|
301
|
+
&& sameInFlight(current.inFlight, next.inFlight)
|
|
302
|
+
&& stableStateMatches) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
throw new GenerationSessionStoreError("INVALID_TRANSITION", "Persisted inFlight may only be marked recoveryRequired without mutation");
|
|
306
|
+
}
|
|
307
|
+
if (next.state === "recoveryRequired"
|
|
308
|
+
|| (next.inFlight !== null && !stableStateMatches)) {
|
|
309
|
+
throw new GenerationSessionStoreError("INVALID_TRANSITION", "Starting inFlight cannot mutate candidate or result state");
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function verificationStableState(session) {
|
|
313
|
+
return {
|
|
314
|
+
...transitionStableState(session),
|
|
315
|
+
verification: undefined
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function assertVerificationCompletionTransition(current, next, status) {
|
|
319
|
+
assertCoreIdentityPreserved(current, next);
|
|
320
|
+
assertLatestSnapshotPreserved(current, next);
|
|
321
|
+
if (current.state !== "active"
|
|
322
|
+
|| next.state !== "active"
|
|
323
|
+
|| current.inFlight !== null
|
|
324
|
+
|| next.inFlight !== null
|
|
325
|
+
|| current.pendingConfirmation !== null
|
|
326
|
+
|| next.pendingConfirmation !== null
|
|
327
|
+
|| current.verification.status !== "running"
|
|
328
|
+
|| next.verification.status !== status
|
|
329
|
+
|| next.publication.status !== "notRun"
|
|
330
|
+
|| JSON.stringify(verificationStableState(current))
|
|
331
|
+
!== JSON.stringify(verificationStableState(next))) {
|
|
332
|
+
throw new GenerationSessionStoreError("INVALID_TRANSITION", `Verification completion must record ${status} from the running attempt`);
|
|
333
|
+
}
|
|
334
|
+
if (next.verification.status === "passed"
|
|
335
|
+
&& next.verification.attemptId !== current.verification.attemptId) {
|
|
336
|
+
throw new GenerationSessionStoreError("INVALID_TRANSITION", "Verification completion attempt does not match");
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function assertBundlePublishableTransition(current, next) {
|
|
340
|
+
assertCoreIdentityPreserved(current, next);
|
|
341
|
+
assertLatestSnapshotPreserved(current, next);
|
|
342
|
+
if (current.state !== "active"
|
|
343
|
+
|| next.state !== "active"
|
|
344
|
+
|| current.inFlight !== null
|
|
345
|
+
|| next.inFlight !== null
|
|
346
|
+
|| current.pendingConfirmation !== null
|
|
347
|
+
|| next.pendingConfirmation !== null
|
|
348
|
+
|| current.verification.status !== "passed"
|
|
349
|
+
|| JSON.stringify(current.verification) !== JSON.stringify(next.verification)
|
|
350
|
+
|| current.publication.status !== "notRun"
|
|
351
|
+
|| next.publication.status !== "published"
|
|
352
|
+
|| JSON.stringify({
|
|
353
|
+
...transitionStableState(current),
|
|
354
|
+
publication: undefined
|
|
355
|
+
}) !== JSON.stringify({
|
|
356
|
+
...transitionStableState(next),
|
|
357
|
+
publication: undefined
|
|
358
|
+
})) {
|
|
359
|
+
throw new GenerationSessionStoreError("INVALID_TRANSITION", "Bundle publication must be explicitly marked after passed verification");
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function confirmationStableState(session) {
|
|
363
|
+
const stable = transitionStableState(session);
|
|
364
|
+
return { ...stable, pendingConfirmation: undefined };
|
|
365
|
+
}
|
|
366
|
+
function assertConfirmationTransition(current, next) {
|
|
367
|
+
assertCoreIdentityPreserved(current, next);
|
|
368
|
+
assertLatestSnapshotPreserved(current, next);
|
|
369
|
+
if (current.state !== "active"
|
|
370
|
+
|| next.state !== "active"
|
|
371
|
+
|| current.inFlight !== null
|
|
372
|
+
|| next.inFlight !== null
|
|
373
|
+
|| current.verification.status !== "notRun"
|
|
374
|
+
|| current.publication.status !== "notRun"
|
|
375
|
+
|| JSON.stringify(confirmationStableState(current))
|
|
376
|
+
!== JSON.stringify(confirmationStableState(next))) {
|
|
377
|
+
throw new GenerationSessionStoreError("INVALID_TRANSITION", "Confirmation transition may only change pending confirmation state");
|
|
378
|
+
}
|
|
379
|
+
const before = current.pendingConfirmation;
|
|
380
|
+
const after = next.pendingConfirmation;
|
|
381
|
+
if (before === null) {
|
|
382
|
+
if (after !== null && after.status === "pending") {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
else if (after === null) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
else if (before.status === "pending"
|
|
390
|
+
&& after.status === "approved"
|
|
391
|
+
&& JSON.stringify({ ...before, status: undefined })
|
|
392
|
+
=== JSON.stringify({ ...after, status: undefined })) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
throw new GenerationSessionStoreError("INVALID_TRANSITION", "Invalid pending confirmation lifecycle transition");
|
|
396
|
+
}
|
|
397
|
+
function assertRecoveryTransition(current, next) {
|
|
398
|
+
assertCoreIdentityPreserved(current, next);
|
|
399
|
+
assertLatestSnapshotPreserved(current, next);
|
|
400
|
+
if (current.state !== "recoveryRequired"
|
|
401
|
+
|| current.inFlight === null
|
|
402
|
+
|| next.state !== "active"
|
|
403
|
+
|| next.inFlight !== null
|
|
404
|
+
|| JSON.stringify(transitionStableState(current))
|
|
405
|
+
!== JSON.stringify(transitionStableState(next))) {
|
|
406
|
+
throw new GenerationSessionStoreError("INVALID_TRANSITION", "Recovery must only clear preserved inFlight evidence and reactivate state");
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async function pathExists(path) {
|
|
410
|
+
try {
|
|
411
|
+
await lstat(path);
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
async function requireRealDirectory(path) {
|
|
422
|
+
const stats = await lstat(path);
|
|
423
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
424
|
+
throw new GenerationSessionStoreError("IO_ERROR", `Generation store path is not a real directory: ${path}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
async function createOrRequireDirectory(path) {
|
|
428
|
+
let created = false;
|
|
429
|
+
try {
|
|
430
|
+
await mkdir(path);
|
|
431
|
+
created = true;
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
if (!isNodeError(error) || error.code !== "EEXIST") {
|
|
435
|
+
throw error;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
await requireRealDirectory(path);
|
|
439
|
+
return created;
|
|
440
|
+
}
|
|
441
|
+
async function syncDirectory(path) {
|
|
442
|
+
const handle = await open(path, constants.O_RDONLY);
|
|
443
|
+
try {
|
|
444
|
+
await handle.sync();
|
|
445
|
+
}
|
|
446
|
+
finally {
|
|
447
|
+
await handle.close();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function serializeJson(value) {
|
|
451
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
452
|
+
}
|
|
453
|
+
function canonicalizeEvidenceValue(value, ancestors = new Set()) {
|
|
454
|
+
if (value === null
|
|
455
|
+
|| typeof value === "string"
|
|
456
|
+
|| typeof value === "boolean") {
|
|
457
|
+
return value;
|
|
458
|
+
}
|
|
459
|
+
if (typeof value === "number") {
|
|
460
|
+
if (Number.isFinite(value)) {
|
|
461
|
+
return value;
|
|
462
|
+
}
|
|
463
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE", "Evidence JSON cannot contain non-finite numbers");
|
|
464
|
+
}
|
|
465
|
+
if (typeof value !== "object") {
|
|
466
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE", "Evidence value must be JSON serializable");
|
|
467
|
+
}
|
|
468
|
+
if (ancestors.has(value)) {
|
|
469
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE", "Evidence JSON cannot contain cycles");
|
|
470
|
+
}
|
|
471
|
+
ancestors.add(value);
|
|
472
|
+
try {
|
|
473
|
+
if (Array.isArray(value)) {
|
|
474
|
+
return value.map((item) => canonicalizeEvidenceValue(item, ancestors));
|
|
475
|
+
}
|
|
476
|
+
const prototype = Object.getPrototypeOf(value);
|
|
477
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
478
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE", "Evidence value must contain only JSON objects");
|
|
479
|
+
}
|
|
480
|
+
return Object.fromEntries(Object.keys(value)
|
|
481
|
+
.sort((left, right) => left.localeCompare(right))
|
|
482
|
+
.map((key) => [
|
|
483
|
+
key,
|
|
484
|
+
canonicalizeEvidenceValue(value[key], ancestors)
|
|
485
|
+
]));
|
|
486
|
+
}
|
|
487
|
+
finally {
|
|
488
|
+
ancestors.delete(value);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
function canonicalizeEvidence(value) {
|
|
492
|
+
try {
|
|
493
|
+
return canonicalizeEvidenceValue(value);
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
if (error instanceof GenerationSessionStoreError) {
|
|
497
|
+
throw error;
|
|
498
|
+
}
|
|
499
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE", "Evidence value cannot be safely inspected", { cause: error });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
function validateEvidencePath(relativePath) {
|
|
503
|
+
if (typeof relativePath !== "string"
|
|
504
|
+
|| relativePath.length === 0
|
|
505
|
+
|| relativePath.includes("\\")
|
|
506
|
+
|| relativePath.includes("\0")
|
|
507
|
+
|| isAbsolute(relativePath)
|
|
508
|
+
|| posix.isAbsolute(relativePath)) {
|
|
509
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE_PATH", "Invalid generation evidence path");
|
|
510
|
+
}
|
|
511
|
+
const segments = relativePath.split("/");
|
|
512
|
+
if (segments.some((segment) => (segment.length === 0
|
|
513
|
+
|| segment === "."
|
|
514
|
+
|| segment === ".."))
|
|
515
|
+
|| posix.normalize(relativePath) !== relativePath) {
|
|
516
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE_PATH", "Invalid generation evidence path");
|
|
517
|
+
}
|
|
518
|
+
return segments;
|
|
519
|
+
}
|
|
520
|
+
function assertContained(root, candidate, allowRoot = false) {
|
|
521
|
+
const fromRoot = relative(root, candidate);
|
|
522
|
+
if ((!allowRoot && fromRoot.length === 0)
|
|
523
|
+
|| fromRoot.startsWith("..")
|
|
524
|
+
|| isAbsolute(fromRoot)) {
|
|
525
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE_PATH", `Evidence path escapes generation session: ${candidate}`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
async function captureDirectoryEvidence(path) {
|
|
529
|
+
const stats = await lstat(path, { bigint: true });
|
|
530
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
531
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE_PATH", `Evidence parent is not a real directory: ${path}`);
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
path,
|
|
535
|
+
canonicalPath: await realpath(path),
|
|
536
|
+
identity: { dev: stats.dev, ino: stats.ino }
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
async function verifyDirectoryEvidence(activeCanonicalPath, directories) {
|
|
540
|
+
for (const evidence of directories) {
|
|
541
|
+
const current = await captureDirectoryEvidence(evidence.path);
|
|
542
|
+
assertContained(activeCanonicalPath, current.canonicalPath, true);
|
|
543
|
+
if (current.canonicalPath !== evidence.canonicalPath
|
|
544
|
+
|| current.identity.dev !== evidence.identity.dev
|
|
545
|
+
|| current.identity.ino !== evidence.identity.ino) {
|
|
546
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE_PATH", `Evidence parent identity changed: ${evidence.path}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
async function assertEvidenceNamespaceAvailable(activeDirectory, activeEvidence, inFlight) {
|
|
551
|
+
const relativePath = `evidence/steps/${String(inFlight.stepIndex)}-${inFlight.attemptId}`;
|
|
552
|
+
const segments = validateEvidencePath(relativePath);
|
|
553
|
+
await verifyStoreDirectory(activeEvidence);
|
|
554
|
+
let parent = activeDirectory;
|
|
555
|
+
const directories = [activeEvidence];
|
|
556
|
+
for (const [index, segment] of segments.entries()) {
|
|
557
|
+
const candidate = join(parent, segment);
|
|
558
|
+
assertContained(activeDirectory, candidate);
|
|
559
|
+
let stats;
|
|
560
|
+
try {
|
|
561
|
+
stats = await lstat(candidate);
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
565
|
+
await verifyDirectoryEvidence(activeEvidence.canonicalPath, directories);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
throw error;
|
|
569
|
+
}
|
|
570
|
+
if (index === segments.length - 1) {
|
|
571
|
+
throw new GenerationSessionStoreError("EVIDENCE_ALREADY_EXISTS", `Generation attempt evidence namespace already exists: ${relativePath}`);
|
|
572
|
+
}
|
|
573
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
574
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE_PATH", `Attempt evidence parent is not a safe directory: ${relativePath}`);
|
|
575
|
+
}
|
|
576
|
+
const evidence = await captureDirectoryEvidence(candidate);
|
|
577
|
+
assertContained(activeEvidence.canonicalPath, evidence.canonicalPath, true);
|
|
578
|
+
directories.push(evidence);
|
|
579
|
+
parent = candidate;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
async function fileIdentity(path) {
|
|
583
|
+
const stats = await lstat(path, { bigint: true });
|
|
584
|
+
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
585
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE_PATH", `Evidence output is not a regular file: ${path}`);
|
|
586
|
+
}
|
|
587
|
+
return { dev: stats.dev, ino: stats.ino };
|
|
588
|
+
}
|
|
589
|
+
function evidenceNotFound(relativePath, cause) {
|
|
590
|
+
return new GenerationSessionStoreError("EVIDENCE_NOT_FOUND", `Generation evidence does not exist: ${relativePath}`, { cause });
|
|
591
|
+
}
|
|
592
|
+
async function readStateFromDirectory(directory, afterOpen, expectedDirectory) {
|
|
593
|
+
const directoryEvidence = expectedDirectory
|
|
594
|
+
?? await captureStoreDirectory(directory);
|
|
595
|
+
await verifyStoreDirectory(directoryEvidence);
|
|
596
|
+
const statePath = join(directory, "state.json");
|
|
597
|
+
let text;
|
|
598
|
+
try {
|
|
599
|
+
const handle = await open(statePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
600
|
+
try {
|
|
601
|
+
const openedStats = await handle.stat({ bigint: true });
|
|
602
|
+
if (!openedStats.isFile()) {
|
|
603
|
+
throw new GenerationSessionStoreError("IO_ERROR", `Generation state is not a regular file: ${statePath}`);
|
|
604
|
+
}
|
|
605
|
+
const openedIdentity = {
|
|
606
|
+
dev: openedStats.dev,
|
|
607
|
+
ino: openedStats.ino
|
|
608
|
+
};
|
|
609
|
+
await afterOpen?.();
|
|
610
|
+
await verifyStoreDirectory(directoryEvidence);
|
|
611
|
+
const pathIdentity = await captureStoreFile(statePath);
|
|
612
|
+
if (!sameIdentity(openedIdentity, pathIdentity)) {
|
|
613
|
+
throw new GenerationSessionStoreError("IO_ERROR", `Generation state file identity changed: ${statePath}`);
|
|
614
|
+
}
|
|
615
|
+
text = await handle.readFile("utf8");
|
|
616
|
+
}
|
|
617
|
+
finally {
|
|
618
|
+
await handle.close();
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
if (error instanceof GenerationSessionStoreError) {
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
625
|
+
throw new GenerationSessionStoreError("IO_ERROR", `Unable to read generation session state: ${statePath}`, { cause: error });
|
|
626
|
+
}
|
|
627
|
+
try {
|
|
628
|
+
return parseSession(JSON.parse(text));
|
|
629
|
+
}
|
|
630
|
+
catch (error) {
|
|
631
|
+
if (error instanceof GenerationSessionStoreError) {
|
|
632
|
+
throw error;
|
|
633
|
+
}
|
|
634
|
+
throw new GenerationSessionStoreError("INVALID_SESSION", `Unable to parse generation session state: ${statePath}`, { cause: error });
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
async function readBoundState(directory, id, afterOpen, expectedDirectory) {
|
|
638
|
+
const session = await readStateFromDirectory(directory, afterOpen, expectedDirectory);
|
|
639
|
+
if (sessionId(session) !== id) {
|
|
640
|
+
throw new GenerationSessionStoreError("INVALID_SESSION", "Persisted generation session id does not match its directory");
|
|
641
|
+
}
|
|
642
|
+
return session;
|
|
643
|
+
}
|
|
644
|
+
async function writeStateAtomically(directory, session, sync = syncDirectory, beforeRename, expectedDirectory, beforeInstall) {
|
|
645
|
+
const directoryEvidence = expectedDirectory
|
|
646
|
+
?? await captureStoreDirectory(directory);
|
|
647
|
+
await verifyStoreDirectory(directoryEvidence);
|
|
648
|
+
const statePath = join(directory, "state.json");
|
|
649
|
+
const originalStateIdentity = await captureOptionalStoreFile(statePath);
|
|
650
|
+
const temporaryPath = join(directory, `.state.json.${randomUUID()}.tmp`);
|
|
651
|
+
let handle;
|
|
652
|
+
try {
|
|
653
|
+
handle = await open(temporaryPath, constants.O_WRONLY
|
|
654
|
+
| constants.O_CREAT
|
|
655
|
+
| constants.O_EXCL
|
|
656
|
+
| constants.O_NOFOLLOW, 0o600);
|
|
657
|
+
await handle.writeFile(serializeJson(session), "utf8");
|
|
658
|
+
await handle.sync();
|
|
659
|
+
await handle.close();
|
|
660
|
+
handle = undefined;
|
|
661
|
+
const temporaryIdentity = await captureStoreFile(temporaryPath);
|
|
662
|
+
await beforeRename?.();
|
|
663
|
+
await verifyStoreDirectory(directoryEvidence);
|
|
664
|
+
await verifyOptionalStoreFile(statePath, originalStateIdentity);
|
|
665
|
+
await beforeInstall?.();
|
|
666
|
+
await rename(temporaryPath, statePath);
|
|
667
|
+
const installedIdentity = await captureStoreFile(statePath);
|
|
668
|
+
if (!sameIdentity(temporaryIdentity, installedIdentity)) {
|
|
669
|
+
throw new GenerationSessionStoreError("IO_ERROR", `Generation state install identity changed: ${statePath}`);
|
|
670
|
+
}
|
|
671
|
+
await sync(directory);
|
|
672
|
+
}
|
|
673
|
+
catch (error) {
|
|
674
|
+
if (handle !== undefined) {
|
|
675
|
+
await handle.close().catch(() => undefined);
|
|
676
|
+
}
|
|
677
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
678
|
+
throw error;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
export class FileSystemGenerationSessionStore {
|
|
682
|
+
projectRoot;
|
|
683
|
+
generationRoot;
|
|
684
|
+
locksRoot;
|
|
685
|
+
options;
|
|
686
|
+
now;
|
|
687
|
+
hooks;
|
|
688
|
+
constructor(projectRoot, options = {}) {
|
|
689
|
+
const configuration = parseStoreConfiguration(projectRoot, options);
|
|
690
|
+
this.projectRoot = configuration.projectRoot;
|
|
691
|
+
this.generationRoot = join(this.projectRoot, ".taphound", "generations");
|
|
692
|
+
this.locksRoot = join(this.generationRoot, ".locks");
|
|
693
|
+
this.options = configuration.options;
|
|
694
|
+
this.now = configuration.now;
|
|
695
|
+
this.hooks = configuration.hooks;
|
|
696
|
+
}
|
|
697
|
+
create = async (input) => {
|
|
698
|
+
const session = parseSession(input, true);
|
|
699
|
+
const id = sessionId(session);
|
|
700
|
+
validateInitialRevision(session);
|
|
701
|
+
await this.ensureGenerationRoot();
|
|
702
|
+
await this.withLock(id, async () => {
|
|
703
|
+
const activeDirectory = this.activeDirectory(id);
|
|
704
|
+
const finalDirectory = this.finalDirectory(id);
|
|
705
|
+
if (await pathExists(activeDirectory)
|
|
706
|
+
|| await pathExists(finalDirectory)) {
|
|
707
|
+
throw new GenerationSessionStoreError("SESSION_ALREADY_EXISTS", `Generation session already exists: ${id}`);
|
|
708
|
+
}
|
|
709
|
+
await mkdir(activeDirectory);
|
|
710
|
+
try {
|
|
711
|
+
const activeEvidence = await captureStoreDirectory(activeDirectory);
|
|
712
|
+
await writeStateAtomically(activeDirectory, session, this.syncDirectory, this.hooks.beforeStateRename, activeEvidence);
|
|
713
|
+
await this.syncDirectory(this.generationRoot);
|
|
714
|
+
}
|
|
715
|
+
catch (error) {
|
|
716
|
+
await rm(activeDirectory).catch(() => undefined);
|
|
717
|
+
throw error;
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
};
|
|
721
|
+
read = async (id) => {
|
|
722
|
+
assertId(id);
|
|
723
|
+
await this.ensureGenerationRoot();
|
|
724
|
+
return this.withLock(id, async () => {
|
|
725
|
+
const finalDirectory = this.finalDirectory(id);
|
|
726
|
+
if (await pathExists(finalDirectory)) {
|
|
727
|
+
const finalEvidence = await captureStoreDirectory(finalDirectory);
|
|
728
|
+
return readBoundState(finalDirectory, id, this.hooks.afterStateOpen, finalEvidence);
|
|
729
|
+
}
|
|
730
|
+
const activeDirectory = this.activeDirectory(id);
|
|
731
|
+
if (await pathExists(activeDirectory)) {
|
|
732
|
+
const activeEvidence = await captureStoreDirectory(activeDirectory);
|
|
733
|
+
return readBoundState(activeDirectory, id, this.hooks.afterStateOpen, activeEvidence);
|
|
734
|
+
}
|
|
735
|
+
throw new GenerationSessionStoreError("SESSION_NOT_FOUND", `Generation session does not exist: ${id}`);
|
|
736
|
+
});
|
|
737
|
+
};
|
|
738
|
+
update = async (id, expectedRevision, input) => {
|
|
739
|
+
assertId(id);
|
|
740
|
+
const next = parseSession(input, true);
|
|
741
|
+
validateNextRevision(id, expectedRevision, next);
|
|
742
|
+
await this.ensureGenerationRoot();
|
|
743
|
+
await this.withLock(id, async () => {
|
|
744
|
+
const activeDirectory = this.activeDirectory(id);
|
|
745
|
+
if (!await pathExists(activeDirectory)) {
|
|
746
|
+
if (await pathExists(this.finalDirectory(id))) {
|
|
747
|
+
throw new GenerationSessionStoreError("SESSION_PUBLISHED", `Published generation session cannot be updated: ${id}`);
|
|
748
|
+
}
|
|
749
|
+
throw new GenerationSessionStoreError("SESSION_NOT_FOUND", `Generation session does not exist: ${id}`);
|
|
750
|
+
}
|
|
751
|
+
const activeEvidence = await captureStoreDirectory(activeDirectory);
|
|
752
|
+
const current = await readBoundState(activeDirectory, id, this.hooks.afterStateOpen, activeEvidence);
|
|
753
|
+
if (current.revision !== expectedRevision) {
|
|
754
|
+
throw new GenerationSessionStoreError("REVISION_CONFLICT", `Expected generation revision ${String(expectedRevision)}, found ${String(current.revision)}`);
|
|
755
|
+
}
|
|
756
|
+
assertOrdinaryTransition(current, next);
|
|
757
|
+
await writeStateAtomically(activeDirectory, next, this.syncDirectory, this.hooks.beforeStateRename, activeEvidence);
|
|
758
|
+
});
|
|
759
|
+
};
|
|
760
|
+
commitSnapshot = async (id, expectedRevision, input) => {
|
|
761
|
+
assertId(id);
|
|
762
|
+
const next = parseSession(input, true);
|
|
763
|
+
validateNextRevision(id, expectedRevision, next);
|
|
764
|
+
await this.ensureGenerationRoot();
|
|
765
|
+
await this.withLock(id, async () => {
|
|
766
|
+
const activeDirectory = this.activeDirectory(id);
|
|
767
|
+
if (!await pathExists(activeDirectory)) {
|
|
768
|
+
if (await pathExists(this.finalDirectory(id))) {
|
|
769
|
+
throw new GenerationSessionStoreError("SESSION_PUBLISHED", `Published generation session cannot commit a snapshot: ${id}`);
|
|
770
|
+
}
|
|
771
|
+
throw new GenerationSessionStoreError("SESSION_NOT_FOUND", `Generation session does not exist: ${id}`);
|
|
772
|
+
}
|
|
773
|
+
const activeEvidence = await captureStoreDirectory(activeDirectory);
|
|
774
|
+
const current = await readBoundState(activeDirectory, id, this.hooks.afterStateOpen, activeEvidence);
|
|
775
|
+
if (current.revision !== expectedRevision) {
|
|
776
|
+
throw new GenerationSessionStoreError("REVISION_CONFLICT", `Expected generation revision ${String(expectedRevision)}, found ${String(current.revision)}`);
|
|
777
|
+
}
|
|
778
|
+
assertSnapshotTransition(current, next);
|
|
779
|
+
await writeStateAtomically(activeDirectory, next, this.syncDirectory, this.hooks.beforeStateRename, activeEvidence);
|
|
780
|
+
});
|
|
781
|
+
};
|
|
782
|
+
updateConfirmation = async (id, expectedRevision, input) => {
|
|
783
|
+
assertId(id);
|
|
784
|
+
const next = parseSession(input, true);
|
|
785
|
+
validateNextRevision(id, expectedRevision, next);
|
|
786
|
+
await this.ensureGenerationRoot();
|
|
787
|
+
await this.withLock(id, async () => {
|
|
788
|
+
const activeDirectory = this.activeDirectory(id);
|
|
789
|
+
if (!await pathExists(activeDirectory)) {
|
|
790
|
+
if (await pathExists(this.finalDirectory(id))) {
|
|
791
|
+
throw new GenerationSessionStoreError("SESSION_PUBLISHED", `Published generation session cannot update confirmation: ${id}`);
|
|
792
|
+
}
|
|
793
|
+
throw new GenerationSessionStoreError("SESSION_NOT_FOUND", `Generation session does not exist: ${id}`);
|
|
794
|
+
}
|
|
795
|
+
const activeEvidence = await captureStoreDirectory(activeDirectory);
|
|
796
|
+
const current = await readBoundState(activeDirectory, id, this.hooks.afterStateOpen, activeEvidence);
|
|
797
|
+
if (current.revision !== expectedRevision) {
|
|
798
|
+
throw new GenerationSessionStoreError("REVISION_CONFLICT", `Expected generation revision ${String(expectedRevision)}, found ${String(current.revision)}`);
|
|
799
|
+
}
|
|
800
|
+
assertConfirmationTransition(current, next);
|
|
801
|
+
await writeStateAtomically(activeDirectory, next, this.syncDirectory, this.hooks.beforeStateRename, activeEvidence);
|
|
802
|
+
});
|
|
803
|
+
};
|
|
804
|
+
beginStep = async (id, expectedRevision, inFlightInput, approvedConfirmationInput) => {
|
|
805
|
+
assertId(id);
|
|
806
|
+
validateExpectedRevision(expectedRevision);
|
|
807
|
+
if (expectedRevision >= Number.MAX_SAFE_INTEGER - 1) {
|
|
808
|
+
throw new GenerationSessionStoreError("INVALID_REVISION", "Step begin must reserve a revision for completion or recovery");
|
|
809
|
+
}
|
|
810
|
+
const inFlight = parseInFlight(inFlightInput);
|
|
811
|
+
const approvedConfirmation = approvedConfirmationInput === undefined
|
|
812
|
+
? undefined
|
|
813
|
+
: PendingConfirmationSchema.parse(approvedConfirmationInput);
|
|
814
|
+
await this.ensureGenerationRoot();
|
|
815
|
+
return this.withLock(id, async () => {
|
|
816
|
+
const activeDirectory = this.activeDirectory(id);
|
|
817
|
+
if (!await pathExists(activeDirectory)) {
|
|
818
|
+
if (await pathExists(this.finalDirectory(id))) {
|
|
819
|
+
throw new GenerationSessionStoreError("SESSION_PUBLISHED", `Published generation session cannot begin a step: ${id}`);
|
|
820
|
+
}
|
|
821
|
+
throw new GenerationSessionStoreError("SESSION_NOT_FOUND", `Generation session does not exist: ${id}`);
|
|
822
|
+
}
|
|
823
|
+
const activeEvidence = await captureStoreDirectory(activeDirectory);
|
|
824
|
+
const current = await readBoundState(activeDirectory, id, this.hooks.afterStateOpen, activeEvidence);
|
|
825
|
+
if (current.revision !== expectedRevision) {
|
|
826
|
+
throw new GenerationSessionStoreError("REVISION_CONFLICT", `Expected generation revision ${String(expectedRevision)}, found ${String(current.revision)}`);
|
|
827
|
+
}
|
|
828
|
+
const expectedConfirmation = approvedConfirmation ?? null;
|
|
829
|
+
if (current.state !== "active"
|
|
830
|
+
|| current.inFlight !== null
|
|
831
|
+
|| current.verification.status !== "notRun"
|
|
832
|
+
|| current.publication.status !== "notRun"
|
|
833
|
+
|| inFlight.stepIndex !== current.candidateSteps.length
|
|
834
|
+
|| JSON.stringify(current.pendingConfirmation)
|
|
835
|
+
!== JSON.stringify(expectedConfirmation)
|
|
836
|
+
|| (approvedConfirmation !== undefined
|
|
837
|
+
&& (approvedConfirmation.status !== "approved"
|
|
838
|
+
|| approvedConfirmation.proposalHash !== inFlight.proposalHash
|
|
839
|
+
|| approvedConfirmation.snapshotHash !== inFlight.snapshotHash))) {
|
|
840
|
+
throw new GenerationSessionStoreError("INVALID_TRANSITION", "Step begin requires exact active state and approved confirmation");
|
|
841
|
+
}
|
|
842
|
+
const next = GenerationSessionSchema.parse({
|
|
843
|
+
...current,
|
|
844
|
+
revision: current.revision + 1,
|
|
845
|
+
inFlight,
|
|
846
|
+
pendingConfirmation: null
|
|
847
|
+
});
|
|
848
|
+
await writeStateAtomically(activeDirectory, next, this.syncDirectory, this.hooks.beforeStateRename, activeEvidence, async () => {
|
|
849
|
+
await assertEvidenceNamespaceAvailable(activeDirectory, activeEvidence, inFlight);
|
|
850
|
+
if (approvedConfirmation !== undefined) {
|
|
851
|
+
const now = this.now();
|
|
852
|
+
if (!(now instanceof Date)
|
|
853
|
+
|| !Number.isFinite(now.getTime())
|
|
854
|
+
|| now.getTime()
|
|
855
|
+
>= new Date(approvedConfirmation.expiresAt).getTime()) {
|
|
856
|
+
throw new GenerationSessionStoreError("INVALID_TRANSITION", "Approved confirmation expired before step begin");
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
return next;
|
|
861
|
+
});
|
|
862
|
+
};
|
|
863
|
+
completeStep = async (id, expectedRevision, expectedInFlightInput, input) => {
|
|
864
|
+
assertId(id);
|
|
865
|
+
const expectedInFlight = parseInFlight(expectedInFlightInput);
|
|
866
|
+
const next = parseSession(input, true);
|
|
867
|
+
validateNextRevision(id, expectedRevision, next);
|
|
868
|
+
await this.ensureGenerationRoot();
|
|
869
|
+
await this.withLock(id, async () => {
|
|
870
|
+
const activeDirectory = this.activeDirectory(id);
|
|
871
|
+
if (!await pathExists(activeDirectory)) {
|
|
872
|
+
if (await pathExists(this.finalDirectory(id))) {
|
|
873
|
+
throw new GenerationSessionStoreError("SESSION_PUBLISHED", `Published generation session cannot complete a step: ${id}`);
|
|
874
|
+
}
|
|
875
|
+
throw new GenerationSessionStoreError("SESSION_NOT_FOUND", `Generation session does not exist: ${id}`);
|
|
876
|
+
}
|
|
877
|
+
const activeEvidence = await captureStoreDirectory(activeDirectory);
|
|
878
|
+
const current = await readBoundState(activeDirectory, id, this.hooks.afterStateOpen, activeEvidence);
|
|
879
|
+
if (current.revision !== expectedRevision) {
|
|
880
|
+
throw new GenerationSessionStoreError("REVISION_CONFLICT", `Expected generation revision ${String(expectedRevision)}, found ${String(current.revision)}`);
|
|
881
|
+
}
|
|
882
|
+
if (current.state !== "active"
|
|
883
|
+
|| current.inFlight === null
|
|
884
|
+
|| !sameInFlight(current.inFlight, expectedInFlight)
|
|
885
|
+
|| next.state !== "active"
|
|
886
|
+
|| next.inFlight !== null) {
|
|
887
|
+
throw new GenerationSessionStoreError("INVALID_TRANSITION", "Step completion must clear the matching active inFlight record");
|
|
888
|
+
}
|
|
889
|
+
assertCoreIdentityPreserved(current, next);
|
|
890
|
+
assertLatestSnapshotPreserved(current, next);
|
|
891
|
+
const candidateAppended = next.candidateSteps.length
|
|
892
|
+
=== current.candidateSteps.length + 1
|
|
893
|
+
&& JSON.stringify(next.candidateSteps.slice(0, current.candidateSteps.length)) === JSON.stringify(current.candidateSteps);
|
|
894
|
+
const sourceAppended = next.candidateSources.length
|
|
895
|
+
=== current.candidateSources.length + 1
|
|
896
|
+
&& JSON.stringify(next.candidateSources.slice(0, current.candidateSources.length)) === JSON.stringify(current.candidateSources);
|
|
897
|
+
const currentStable = {
|
|
898
|
+
...transitionStableState(current),
|
|
899
|
+
candidateSteps: undefined,
|
|
900
|
+
candidateSources: undefined,
|
|
901
|
+
pendingConfirmation: undefined
|
|
902
|
+
};
|
|
903
|
+
const nextStable = {
|
|
904
|
+
...transitionStableState(next),
|
|
905
|
+
candidateSteps: undefined,
|
|
906
|
+
candidateSources: undefined,
|
|
907
|
+
pendingConfirmation: undefined
|
|
908
|
+
};
|
|
909
|
+
if (!candidateAppended
|
|
910
|
+
|| !sourceAppended
|
|
911
|
+
|| current.pendingConfirmation !== null
|
|
912
|
+
|| next.pendingConfirmation !== null
|
|
913
|
+
|| JSON.stringify(currentStable) !== JSON.stringify(nextStable)) {
|
|
914
|
+
throw new GenerationSessionStoreError("INVALID_TRANSITION", "Step completion must append exactly one successful Journey step");
|
|
915
|
+
}
|
|
916
|
+
await writeStateAtomically(activeDirectory, next, this.syncDirectory, this.hooks.beforeStateRename, activeEvidence);
|
|
917
|
+
});
|
|
918
|
+
};
|
|
919
|
+
recover = async (id, expectedRevision, input) => {
|
|
920
|
+
assertId(id);
|
|
921
|
+
const next = parseSession(input, true);
|
|
922
|
+
validateNextRevision(id, expectedRevision, next);
|
|
923
|
+
await this.ensureGenerationRoot();
|
|
924
|
+
await this.withLock(id, async () => {
|
|
925
|
+
const activeDirectory = this.activeDirectory(id);
|
|
926
|
+
if (!await pathExists(activeDirectory)) {
|
|
927
|
+
if (await pathExists(this.finalDirectory(id))) {
|
|
928
|
+
throw new GenerationSessionStoreError("SESSION_PUBLISHED", `Published generation session cannot be recovered: ${id}`);
|
|
929
|
+
}
|
|
930
|
+
throw new GenerationSessionStoreError("SESSION_NOT_FOUND", `Generation session does not exist: ${id}`);
|
|
931
|
+
}
|
|
932
|
+
const activeEvidence = await captureStoreDirectory(activeDirectory);
|
|
933
|
+
const current = await readBoundState(activeDirectory, id, this.hooks.afterStateOpen, activeEvidence);
|
|
934
|
+
if (current.revision !== expectedRevision) {
|
|
935
|
+
throw new GenerationSessionStoreError("REVISION_CONFLICT", `Expected generation revision ${String(expectedRevision)}, found ${String(current.revision)}`);
|
|
936
|
+
}
|
|
937
|
+
assertRecoveryTransition(current, next);
|
|
938
|
+
await writeStateAtomically(activeDirectory, next, this.syncDirectory, this.hooks.beforeStateRename, activeEvidence);
|
|
939
|
+
});
|
|
940
|
+
};
|
|
941
|
+
beginVerification = async (id, expectedRevision, attemptId) => {
|
|
942
|
+
assertId(id);
|
|
943
|
+
validateExpectedRevision(expectedRevision);
|
|
944
|
+
assertId(attemptId);
|
|
945
|
+
if (expectedRevision > Number.MAX_SAFE_INTEGER - 3) {
|
|
946
|
+
throw new GenerationSessionStoreError("INVALID_REVISION", "Verification must reserve revisions for completion and publication");
|
|
947
|
+
}
|
|
948
|
+
await this.ensureGenerationRoot();
|
|
949
|
+
return this.withLock(id, async () => {
|
|
950
|
+
const activeDirectory = this.activeDirectory(id);
|
|
951
|
+
if (!await pathExists(activeDirectory)) {
|
|
952
|
+
if (await pathExists(this.finalDirectory(id))) {
|
|
953
|
+
throw new GenerationSessionStoreError("SESSION_PUBLISHED", `Published generation session cannot begin verification: ${id}`);
|
|
954
|
+
}
|
|
955
|
+
throw new GenerationSessionStoreError("SESSION_NOT_FOUND", `Generation session does not exist: ${id}`);
|
|
956
|
+
}
|
|
957
|
+
const activeEvidence = await captureStoreDirectory(activeDirectory);
|
|
958
|
+
const current = await readBoundState(activeDirectory, id, this.hooks.afterStateOpen, activeEvidence);
|
|
959
|
+
if (current.revision !== expectedRevision) {
|
|
960
|
+
throw new GenerationSessionStoreError("REVISION_CONFLICT", `Expected generation revision ${String(expectedRevision)}, found ${String(current.revision)}`);
|
|
961
|
+
}
|
|
962
|
+
if (current.state !== "active"
|
|
963
|
+
|| current.inFlight !== null
|
|
964
|
+
|| current.pendingConfirmation !== null
|
|
965
|
+
|| current.candidateSteps.length === 0
|
|
966
|
+
|| current.candidateSources.length !== current.candidateSteps.length
|
|
967
|
+
|| current.verification.status !== "notRun"
|
|
968
|
+
|| current.publication.status !== "notRun") {
|
|
969
|
+
throw new GenerationSessionStoreError("INVALID_TRANSITION", "Verification requires a complete non-empty active candidate");
|
|
970
|
+
}
|
|
971
|
+
const next = GenerationSessionSchema.parse({
|
|
972
|
+
...current,
|
|
973
|
+
revision: current.revision + 1,
|
|
974
|
+
verification: { status: "running", attemptId }
|
|
975
|
+
});
|
|
976
|
+
await writeStateAtomically(activeDirectory, next, this.syncDirectory, this.hooks.beforeStateRename, activeEvidence);
|
|
977
|
+
return next;
|
|
978
|
+
});
|
|
979
|
+
};
|
|
980
|
+
completeVerification = async (id, expectedRevision, input) => {
|
|
981
|
+
await this.writeVerificationTransition(id, expectedRevision, input, "passed");
|
|
982
|
+
};
|
|
983
|
+
failVerification = async (id, expectedRevision, input) => {
|
|
984
|
+
await this.writeVerificationTransition(id, expectedRevision, input, "failed");
|
|
985
|
+
};
|
|
986
|
+
markBundlePublishable = async (id, expectedRevision, input) => {
|
|
987
|
+
assertId(id);
|
|
988
|
+
const next = parseSession(input, true);
|
|
989
|
+
validateNextRevision(id, expectedRevision, next);
|
|
990
|
+
await this.ensureGenerationRoot();
|
|
991
|
+
await this.withLock(id, async () => {
|
|
992
|
+
const activeDirectory = this.activeDirectory(id);
|
|
993
|
+
if (!await pathExists(activeDirectory)) {
|
|
994
|
+
if (await pathExists(this.finalDirectory(id))) {
|
|
995
|
+
throw new GenerationSessionStoreError("SESSION_PUBLISHED", `Published generation session cannot be marked publishable: ${id}`);
|
|
996
|
+
}
|
|
997
|
+
throw new GenerationSessionStoreError("SESSION_NOT_FOUND", `Generation session does not exist: ${id}`);
|
|
998
|
+
}
|
|
999
|
+
const activeEvidence = await captureStoreDirectory(activeDirectory);
|
|
1000
|
+
const current = await readBoundState(activeDirectory, id, this.hooks.afterStateOpen, activeEvidence);
|
|
1001
|
+
if (current.revision !== expectedRevision) {
|
|
1002
|
+
throw new GenerationSessionStoreError("REVISION_CONFLICT", `Expected generation revision ${String(expectedRevision)}, found ${String(current.revision)}`);
|
|
1003
|
+
}
|
|
1004
|
+
assertBundlePublishableTransition(current, next);
|
|
1005
|
+
await writeStateAtomically(activeDirectory, next, this.syncDirectory, this.hooks.beforeStateRename, activeEvidence);
|
|
1006
|
+
});
|
|
1007
|
+
};
|
|
1008
|
+
writeEvidence = async (id, relativePath, value) => {
|
|
1009
|
+
const canonicalValue = canonicalizeEvidence(value);
|
|
1010
|
+
await this.produceEvidence(id, relativePath, async (temporaryPath) => {
|
|
1011
|
+
const handle = await open(temporaryPath, constants.O_WRONLY
|
|
1012
|
+
| constants.O_CREAT
|
|
1013
|
+
| constants.O_EXCL
|
|
1014
|
+
| constants.O_NOFOLLOW, 0o600);
|
|
1015
|
+
try {
|
|
1016
|
+
await handle.writeFile(serializeJson(canonicalValue), "utf8");
|
|
1017
|
+
await handle.sync();
|
|
1018
|
+
}
|
|
1019
|
+
finally {
|
|
1020
|
+
await handle.close();
|
|
1021
|
+
}
|
|
1022
|
+
});
|
|
1023
|
+
};
|
|
1024
|
+
writeTextEvidence = async (id, relativePath, value) => {
|
|
1025
|
+
if (typeof value !== "string") {
|
|
1026
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE", "Text evidence must be a string");
|
|
1027
|
+
}
|
|
1028
|
+
await this.produceEvidence(id, relativePath, async (temporaryPath) => {
|
|
1029
|
+
const handle = await open(temporaryPath, constants.O_WRONLY
|
|
1030
|
+
| constants.O_CREAT
|
|
1031
|
+
| constants.O_EXCL
|
|
1032
|
+
| constants.O_NOFOLLOW, 0o600);
|
|
1033
|
+
try {
|
|
1034
|
+
await handle.writeFile(value, "utf8");
|
|
1035
|
+
await handle.sync();
|
|
1036
|
+
}
|
|
1037
|
+
finally {
|
|
1038
|
+
await handle.close();
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
};
|
|
1042
|
+
produceEvidence = async (id, relativePath, produce) => {
|
|
1043
|
+
assertId(id);
|
|
1044
|
+
const segments = validateEvidencePath(relativePath);
|
|
1045
|
+
if (typeof produce !== "function") {
|
|
1046
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE", "Evidence producer must be a function");
|
|
1047
|
+
}
|
|
1048
|
+
await this.ensureGenerationRoot();
|
|
1049
|
+
await this.withLock(id, async () => {
|
|
1050
|
+
const activeDirectory = this.activeDirectory(id);
|
|
1051
|
+
if (!await pathExists(activeDirectory)) {
|
|
1052
|
+
if (await pathExists(this.finalDirectory(id))) {
|
|
1053
|
+
throw new GenerationSessionStoreError("SESSION_PUBLISHED", `Published generation session cannot accept evidence: ${id}`);
|
|
1054
|
+
}
|
|
1055
|
+
throw new GenerationSessionStoreError("SESSION_NOT_FOUND", `Generation session does not exist: ${id}`);
|
|
1056
|
+
}
|
|
1057
|
+
const activeEvidence = await captureStoreDirectory(activeDirectory);
|
|
1058
|
+
const state = await readBoundState(activeDirectory, id, this.hooks.afterStateOpen, activeEvidence);
|
|
1059
|
+
if (state.publication.status === "published") {
|
|
1060
|
+
throw new GenerationSessionStoreError("SESSION_PUBLISHED", `Publishable generation session cannot accept evidence: ${id}`);
|
|
1061
|
+
}
|
|
1062
|
+
let parent = activeDirectory;
|
|
1063
|
+
const directoryEvidence = [
|
|
1064
|
+
activeEvidence
|
|
1065
|
+
];
|
|
1066
|
+
for (const segment of segments.slice(0, -1)) {
|
|
1067
|
+
await verifyStoreDirectory(activeEvidence);
|
|
1068
|
+
const parentBeforeCreation = parent;
|
|
1069
|
+
parent = join(parentBeforeCreation, segment);
|
|
1070
|
+
try {
|
|
1071
|
+
const created = await createOrRequireDirectory(parent);
|
|
1072
|
+
if (created) {
|
|
1073
|
+
await this.syncDirectory(parent);
|
|
1074
|
+
await this.syncDirectory(parentBeforeCreation);
|
|
1075
|
+
}
|
|
1076
|
+
directoryEvidence.push(await captureDirectoryEvidence(parent));
|
|
1077
|
+
}
|
|
1078
|
+
catch (error) {
|
|
1079
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE_PATH", `Evidence parent is not a safe directory: ${relativePath}`, { cause: error });
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
const realActive = directoryEvidence[0]?.canonicalPath;
|
|
1083
|
+
if (realActive === undefined) {
|
|
1084
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE_PATH", "Generation active directory identity is unavailable");
|
|
1085
|
+
}
|
|
1086
|
+
await verifyDirectoryEvidence(realActive, directoryEvidence);
|
|
1087
|
+
const outputPath = join(parent, segments.at(-1));
|
|
1088
|
+
assertContained(activeDirectory, outputPath);
|
|
1089
|
+
const temporaryPath = join(activeDirectory, `.producer-${randomUUID()}.tmp`);
|
|
1090
|
+
let installed = false;
|
|
1091
|
+
let committed = false;
|
|
1092
|
+
try {
|
|
1093
|
+
await verifyStoreDirectory(activeEvidence);
|
|
1094
|
+
await produce(temporaryPath);
|
|
1095
|
+
const temporaryIdentity = await fileIdentity(temporaryPath);
|
|
1096
|
+
const handle = await open(temporaryPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
1097
|
+
try {
|
|
1098
|
+
await handle.sync();
|
|
1099
|
+
}
|
|
1100
|
+
finally {
|
|
1101
|
+
await handle.close();
|
|
1102
|
+
}
|
|
1103
|
+
await this.hooks.beforeEvidenceInstall?.();
|
|
1104
|
+
await verifyDirectoryEvidence(realActive, directoryEvidence);
|
|
1105
|
+
await link(temporaryPath, outputPath);
|
|
1106
|
+
installed = true;
|
|
1107
|
+
const installedIdentity = await fileIdentity(outputPath);
|
|
1108
|
+
await verifyDirectoryEvidence(realActive, directoryEvidence);
|
|
1109
|
+
if (installedIdentity.dev !== temporaryIdentity.dev
|
|
1110
|
+
|| installedIdentity.ino !== temporaryIdentity.ino) {
|
|
1111
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE_PATH", `Evidence output identity changed: ${relativePath}`);
|
|
1112
|
+
}
|
|
1113
|
+
await this.syncDirectory(parent);
|
|
1114
|
+
committed = true;
|
|
1115
|
+
await this.hooks.afterEvidenceInstall?.();
|
|
1116
|
+
}
|
|
1117
|
+
catch (error) {
|
|
1118
|
+
if (installed && !committed) {
|
|
1119
|
+
try {
|
|
1120
|
+
const currentIdentity = await fileIdentity(outputPath);
|
|
1121
|
+
const temporaryIdentity = await fileIdentity(temporaryPath);
|
|
1122
|
+
if (currentIdentity.dev === temporaryIdentity.dev
|
|
1123
|
+
&& currentIdentity.ino === temporaryIdentity.ino) {
|
|
1124
|
+
await unlink(outputPath);
|
|
1125
|
+
await this.syncDirectory(parent);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
catch {
|
|
1129
|
+
// Preserve any path that cannot be proven to be our own link.
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
if (isNodeError(error) && error.code === "EEXIST") {
|
|
1133
|
+
throw new GenerationSessionStoreError("EVIDENCE_ALREADY_EXISTS", `Generation evidence already exists: ${relativePath}`, { cause: error });
|
|
1134
|
+
}
|
|
1135
|
+
throw error;
|
|
1136
|
+
}
|
|
1137
|
+
finally {
|
|
1138
|
+
await unlink(temporaryPath).catch(() => undefined);
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
};
|
|
1142
|
+
readEvidence = async (id, relativePath) => {
|
|
1143
|
+
assertId(id);
|
|
1144
|
+
const segments = validateEvidencePath(relativePath);
|
|
1145
|
+
await this.ensureGenerationRoot();
|
|
1146
|
+
return this.withLock(id, async () => {
|
|
1147
|
+
const finalDirectory = this.finalDirectory(id);
|
|
1148
|
+
const activeDirectory = this.activeDirectory(id);
|
|
1149
|
+
const directory = await pathExists(finalDirectory)
|
|
1150
|
+
? finalDirectory
|
|
1151
|
+
: activeDirectory;
|
|
1152
|
+
if (!await pathExists(directory)) {
|
|
1153
|
+
throw new GenerationSessionStoreError("SESSION_NOT_FOUND", `Generation session does not exist: ${id}`);
|
|
1154
|
+
}
|
|
1155
|
+
const rootEvidence = await captureStoreDirectory(directory);
|
|
1156
|
+
await readBoundState(directory, id, this.hooks.afterStateOpen, rootEvidence);
|
|
1157
|
+
let current = directory;
|
|
1158
|
+
const directories = [rootEvidence];
|
|
1159
|
+
for (const segment of segments.slice(0, -1)) {
|
|
1160
|
+
current = join(current, segment);
|
|
1161
|
+
let evidence;
|
|
1162
|
+
try {
|
|
1163
|
+
evidence = await captureDirectoryEvidence(current);
|
|
1164
|
+
}
|
|
1165
|
+
catch (error) {
|
|
1166
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
1167
|
+
throw evidenceNotFound(relativePath, error);
|
|
1168
|
+
}
|
|
1169
|
+
throw error;
|
|
1170
|
+
}
|
|
1171
|
+
assertContained(rootEvidence.canonicalPath, evidence.canonicalPath);
|
|
1172
|
+
directories.push(evidence);
|
|
1173
|
+
}
|
|
1174
|
+
const path = join(current, segments.at(-1));
|
|
1175
|
+
assertContained(directory, path);
|
|
1176
|
+
await verifyDirectoryEvidence(rootEvidence.canonicalPath, directories);
|
|
1177
|
+
let handle;
|
|
1178
|
+
try {
|
|
1179
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
1180
|
+
}
|
|
1181
|
+
catch (error) {
|
|
1182
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
1183
|
+
throw evidenceNotFound(relativePath, error);
|
|
1184
|
+
}
|
|
1185
|
+
throw error;
|
|
1186
|
+
}
|
|
1187
|
+
try {
|
|
1188
|
+
const stats = await handle.stat({ bigint: true });
|
|
1189
|
+
if (!stats.isFile()) {
|
|
1190
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE_PATH", `Evidence is not a regular file: ${relativePath}`);
|
|
1191
|
+
}
|
|
1192
|
+
const openedIdentity = { dev: stats.dev, ino: stats.ino };
|
|
1193
|
+
const pathIdentity = await fileIdentity(path);
|
|
1194
|
+
if (!sameIdentity(openedIdentity, pathIdentity)) {
|
|
1195
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE_PATH", `Evidence file identity changed: ${relativePath}`);
|
|
1196
|
+
}
|
|
1197
|
+
await verifyDirectoryEvidence(rootEvidence.canonicalPath, directories);
|
|
1198
|
+
return await handle.readFile();
|
|
1199
|
+
}
|
|
1200
|
+
finally {
|
|
1201
|
+
await handle.close();
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1204
|
+
};
|
|
1205
|
+
listEvidence = async (id) => {
|
|
1206
|
+
assertId(id);
|
|
1207
|
+
await this.ensureGenerationRoot();
|
|
1208
|
+
return this.withLock(id, async () => {
|
|
1209
|
+
const finalDirectory = this.finalDirectory(id);
|
|
1210
|
+
const activeDirectory = this.activeDirectory(id);
|
|
1211
|
+
const directory = await pathExists(finalDirectory)
|
|
1212
|
+
? finalDirectory
|
|
1213
|
+
: activeDirectory;
|
|
1214
|
+
if (!await pathExists(directory)) {
|
|
1215
|
+
throw new GenerationSessionStoreError("SESSION_NOT_FOUND", `Generation session does not exist: ${id}`);
|
|
1216
|
+
}
|
|
1217
|
+
const rootEvidence = await captureStoreDirectory(directory);
|
|
1218
|
+
await readBoundState(directory, id, this.hooks.afterStateOpen, rootEvidence);
|
|
1219
|
+
const files = [];
|
|
1220
|
+
const visit = async (currentPath, segments, expectedIdentity) => {
|
|
1221
|
+
const initialDirectoryStats = await lstat(currentPath, {
|
|
1222
|
+
bigint: true
|
|
1223
|
+
});
|
|
1224
|
+
if (!initialDirectoryStats.isDirectory()) {
|
|
1225
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE_PATH", `Generation evidence directory changed type: ${segments.join("/")}`);
|
|
1226
|
+
}
|
|
1227
|
+
const initialDirectoryMetadata = snapshotMetadata(initialDirectoryStats);
|
|
1228
|
+
const directoryEvidence = await captureStoreDirectory(currentPath);
|
|
1229
|
+
if (expectedIdentity !== undefined
|
|
1230
|
+
&& !sameIdentity(directoryEvidence.identity, expectedIdentity)) {
|
|
1231
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE_PATH", `Generation evidence directory identity changed: ${segments.join("/")}`);
|
|
1232
|
+
}
|
|
1233
|
+
assertContained(rootEvidence.canonicalPath, directoryEvidence.canonicalPath, true);
|
|
1234
|
+
const entries = (await readdir(currentPath, { withFileTypes: true }))
|
|
1235
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
1236
|
+
const initialEntries = evidenceEntrySnapshot(entries);
|
|
1237
|
+
await this.hooks.afterEvidenceDirectoryRead?.(segments.join("/"), "beforeTraversal");
|
|
1238
|
+
for (const entry of entries) {
|
|
1239
|
+
const childSegments = [...segments, entry.name];
|
|
1240
|
+
const relativePath = childSegments.join("/");
|
|
1241
|
+
if (segments.length === 0
|
|
1242
|
+
&& (entry.name === "state.json" || entry.name === "manifest.json")) {
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
const childPath = join(currentPath, entry.name);
|
|
1246
|
+
let stats;
|
|
1247
|
+
try {
|
|
1248
|
+
stats = await lstat(childPath, { bigint: true });
|
|
1249
|
+
}
|
|
1250
|
+
catch (error) {
|
|
1251
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
1252
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE_PATH", `Generation evidence disappeared during snapshot: ${relativePath}`);
|
|
1253
|
+
}
|
|
1254
|
+
throw error;
|
|
1255
|
+
}
|
|
1256
|
+
if (stats.isSymbolicLink()) {
|
|
1257
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE_PATH", `Generation evidence contains a symbolic link: ${relativePath}`);
|
|
1258
|
+
}
|
|
1259
|
+
if (stats.isDirectory()) {
|
|
1260
|
+
await visit(childPath, childSegments, { dev: stats.dev, ino: stats.ino });
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
if (!stats.isFile()) {
|
|
1264
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE_PATH", `Generation evidence is not a regular file: ${relativePath}`);
|
|
1265
|
+
}
|
|
1266
|
+
const handle = await open(childPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
1267
|
+
try {
|
|
1268
|
+
const opened = await handle.stat({ bigint: true });
|
|
1269
|
+
const initialFileMetadata = snapshotMetadata(opened);
|
|
1270
|
+
if (!opened.isFile()
|
|
1271
|
+
|| !sameSnapshotMetadata(snapshotMetadata(stats), initialFileMetadata)) {
|
|
1272
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE_PATH", `Generation evidence changed before read: ${relativePath}`);
|
|
1273
|
+
}
|
|
1274
|
+
await this.hooks.afterEvidenceOpen?.(relativePath);
|
|
1275
|
+
await verifyStoreDirectory(rootEvidence);
|
|
1276
|
+
await verifyStoreDirectory(directoryEvidence);
|
|
1277
|
+
const bytes = await handle.readFile();
|
|
1278
|
+
await this.hooks.afterEvidenceRead?.(relativePath);
|
|
1279
|
+
const finalDescriptorStats = await handle.stat({ bigint: true });
|
|
1280
|
+
const finalPathStats = await lstat(childPath, { bigint: true });
|
|
1281
|
+
if (!finalDescriptorStats.isFile()
|
|
1282
|
+
|| !finalPathStats.isFile()
|
|
1283
|
+
|| !sameSnapshotMetadata(initialFileMetadata, snapshotMetadata(finalDescriptorStats))
|
|
1284
|
+
|| !sameSnapshotMetadata(initialFileMetadata, snapshotMetadata(finalPathStats))) {
|
|
1285
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE_PATH", `Generation evidence changed during read: ${relativePath}`);
|
|
1286
|
+
}
|
|
1287
|
+
files.push({
|
|
1288
|
+
path: relativePath,
|
|
1289
|
+
contentBase64: bytes.toString("base64"),
|
|
1290
|
+
byteLength: bytes.byteLength
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
finally {
|
|
1294
|
+
await handle.close();
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
await this.hooks.afterEvidenceDirectoryRead?.(segments.join("/"), "afterTraversal");
|
|
1298
|
+
const finalEntriesRaw = await readdir(currentPath, {
|
|
1299
|
+
withFileTypes: true
|
|
1300
|
+
});
|
|
1301
|
+
const finalDirectoryStats = await lstat(currentPath, {
|
|
1302
|
+
bigint: true
|
|
1303
|
+
});
|
|
1304
|
+
const finalEntries = evidenceEntrySnapshot(finalEntriesRaw);
|
|
1305
|
+
if (!finalDirectoryStats.isDirectory()
|
|
1306
|
+
|| !sameSnapshotMetadata(initialDirectoryMetadata, snapshotMetadata(finalDirectoryStats))
|
|
1307
|
+
|| initialEntries.length !== finalEntries.length
|
|
1308
|
+
|| initialEntries.some((entry, index) => entry !== finalEntries[index])) {
|
|
1309
|
+
throw new GenerationSessionStoreError("INVALID_EVIDENCE_PATH", `Generation evidence directory changed during snapshot: ${segments.join("/")}`);
|
|
1310
|
+
}
|
|
1311
|
+
await verifyStoreDirectory(rootEvidence);
|
|
1312
|
+
await verifyStoreDirectory(directoryEvidence);
|
|
1313
|
+
};
|
|
1314
|
+
await visit(directory, []);
|
|
1315
|
+
return files.sort((left, right) => left.path.localeCompare(right.path));
|
|
1316
|
+
});
|
|
1317
|
+
};
|
|
1318
|
+
publish = async (id) => {
|
|
1319
|
+
assertId(id);
|
|
1320
|
+
await this.ensureGenerationRoot();
|
|
1321
|
+
return this.withLock(id, async () => {
|
|
1322
|
+
const generationRootEvidence = await captureStoreDirectory(this.generationRoot);
|
|
1323
|
+
const activeDirectory = this.activeDirectory(id);
|
|
1324
|
+
const finalDirectory = this.finalDirectory(id);
|
|
1325
|
+
const activeExists = await pathExists(activeDirectory);
|
|
1326
|
+
const finalExists = await pathExists(finalDirectory);
|
|
1327
|
+
if (finalExists && !activeExists) {
|
|
1328
|
+
const finalEvidence = await captureStoreDirectory(finalDirectory);
|
|
1329
|
+
const state = await readBoundState(finalDirectory, id, this.hooks.afterStateOpen, finalEvidence);
|
|
1330
|
+
this.assertPublishable(state);
|
|
1331
|
+
await this.syncDirectory(this.generationRoot);
|
|
1332
|
+
return finalDirectory;
|
|
1333
|
+
}
|
|
1334
|
+
if (finalExists) {
|
|
1335
|
+
throw new GenerationSessionStoreError("PUBLISH_DESTINATION_EXISTS", `Generation publish destination already exists: ${finalDirectory}`);
|
|
1336
|
+
}
|
|
1337
|
+
if (!activeExists) {
|
|
1338
|
+
throw new GenerationSessionStoreError("SESSION_NOT_FOUND", `Generation session does not exist: ${id}`);
|
|
1339
|
+
}
|
|
1340
|
+
const activeEvidence = await captureStoreDirectory(activeDirectory);
|
|
1341
|
+
const state = await readBoundState(activeDirectory, id, this.hooks.afterStateOpen, activeEvidence);
|
|
1342
|
+
this.assertPublishable(state);
|
|
1343
|
+
try {
|
|
1344
|
+
await this.hooks.beforePublishRename?.();
|
|
1345
|
+
await verifyStoreDirectory(generationRootEvidence);
|
|
1346
|
+
await verifyStoreDirectory(activeEvidence);
|
|
1347
|
+
await rename(activeDirectory, finalDirectory);
|
|
1348
|
+
const finalEvidence = await captureStoreDirectory(finalDirectory);
|
|
1349
|
+
if (!sameIdentity(activeEvidence.identity, finalEvidence.identity)) {
|
|
1350
|
+
throw new GenerationSessionStoreError("IO_ERROR", "Published generation bundle identity changed");
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
catch (error) {
|
|
1354
|
+
if (isNodeError(error)
|
|
1355
|
+
&& (error.code === "EEXIST"
|
|
1356
|
+
|| error.code === "ENOTEMPTY")) {
|
|
1357
|
+
throw new GenerationSessionStoreError("PUBLISH_DESTINATION_EXISTS", `Generation publish destination already exists: ${finalDirectory}`, { cause: error });
|
|
1358
|
+
}
|
|
1359
|
+
throw error;
|
|
1360
|
+
}
|
|
1361
|
+
await this.syncDirectory(this.generationRoot);
|
|
1362
|
+
return finalDirectory;
|
|
1363
|
+
});
|
|
1364
|
+
};
|
|
1365
|
+
ensureGenerationRoot = async () => {
|
|
1366
|
+
try {
|
|
1367
|
+
await requireRealDirectory(this.projectRoot);
|
|
1368
|
+
const taphoundDirectory = join(this.projectRoot, ".taphound");
|
|
1369
|
+
if (await createOrRequireDirectory(taphoundDirectory)) {
|
|
1370
|
+
await this.syncDirectory(this.projectRoot);
|
|
1371
|
+
}
|
|
1372
|
+
if (await createOrRequireDirectory(this.generationRoot)) {
|
|
1373
|
+
await this.syncDirectory(taphoundDirectory);
|
|
1374
|
+
}
|
|
1375
|
+
if (await createOrRequireDirectory(this.locksRoot)) {
|
|
1376
|
+
await this.syncDirectory(this.generationRoot);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
catch (error) {
|
|
1380
|
+
throw asStoreIoError(error, "initialize its generation directory");
|
|
1381
|
+
}
|
|
1382
|
+
};
|
|
1383
|
+
writeVerificationTransition = async (id, expectedRevision, input, status) => {
|
|
1384
|
+
assertId(id);
|
|
1385
|
+
const next = parseSession(input, true);
|
|
1386
|
+
validateNextRevision(id, expectedRevision, next);
|
|
1387
|
+
await this.ensureGenerationRoot();
|
|
1388
|
+
await this.withLock(id, async () => {
|
|
1389
|
+
const activeDirectory = this.activeDirectory(id);
|
|
1390
|
+
if (!await pathExists(activeDirectory)) {
|
|
1391
|
+
if (await pathExists(this.finalDirectory(id))) {
|
|
1392
|
+
throw new GenerationSessionStoreError("SESSION_PUBLISHED", `Published generation session cannot complete verification: ${id}`);
|
|
1393
|
+
}
|
|
1394
|
+
throw new GenerationSessionStoreError("SESSION_NOT_FOUND", `Generation session does not exist: ${id}`);
|
|
1395
|
+
}
|
|
1396
|
+
const activeEvidence = await captureStoreDirectory(activeDirectory);
|
|
1397
|
+
const current = await readBoundState(activeDirectory, id, this.hooks.afterStateOpen, activeEvidence);
|
|
1398
|
+
if (current.revision !== expectedRevision) {
|
|
1399
|
+
throw new GenerationSessionStoreError("REVISION_CONFLICT", `Expected generation revision ${String(expectedRevision)}, found ${String(current.revision)}`);
|
|
1400
|
+
}
|
|
1401
|
+
assertVerificationCompletionTransition(current, next, status);
|
|
1402
|
+
await writeStateAtomically(activeDirectory, next, this.syncDirectory, this.hooks.beforeStateRename, activeEvidence);
|
|
1403
|
+
});
|
|
1404
|
+
};
|
|
1405
|
+
activeDirectory = (id) => (join(this.generationRoot, `.${id}.work`));
|
|
1406
|
+
finalDirectory = (id) => (join(this.generationRoot, id));
|
|
1407
|
+
lockPath = (id) => (join(this.locksRoot, `${id}.lock`));
|
|
1408
|
+
syncDirectory = async (path) => {
|
|
1409
|
+
await syncDirectory(path);
|
|
1410
|
+
await this.hooks.afterDirectorySync?.(path);
|
|
1411
|
+
};
|
|
1412
|
+
withLock = async (id, operation) => {
|
|
1413
|
+
const token = randomUUID();
|
|
1414
|
+
let generationRootEvidence;
|
|
1415
|
+
let locksRootEvidence;
|
|
1416
|
+
try {
|
|
1417
|
+
generationRootEvidence = await captureStoreDirectory(this.generationRoot);
|
|
1418
|
+
locksRootEvidence = await captureStoreDirectory(this.locksRoot);
|
|
1419
|
+
await this.acquireLock(id, { pid: process.pid, token }, generationRootEvidence, locksRootEvidence);
|
|
1420
|
+
await verifyStoreDirectory(generationRootEvidence);
|
|
1421
|
+
await verifyStoreDirectory(locksRootEvidence);
|
|
1422
|
+
}
|
|
1423
|
+
catch (error) {
|
|
1424
|
+
throw asStoreIoError(error, "acquire its exclusive lock");
|
|
1425
|
+
}
|
|
1426
|
+
let outcome;
|
|
1427
|
+
let releaseError;
|
|
1428
|
+
try {
|
|
1429
|
+
outcome = {
|
|
1430
|
+
status: "succeeded",
|
|
1431
|
+
value: await operation()
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
catch (error) {
|
|
1435
|
+
outcome = { status: "failed", error };
|
|
1436
|
+
}
|
|
1437
|
+
finally {
|
|
1438
|
+
try {
|
|
1439
|
+
await this.releaseLock(id, token);
|
|
1440
|
+
}
|
|
1441
|
+
catch (error) {
|
|
1442
|
+
releaseError = error;
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
if (outcome.status === "failed") {
|
|
1446
|
+
throw asStoreIoError(outcome.error, "complete a locked operation");
|
|
1447
|
+
}
|
|
1448
|
+
if (releaseError !== undefined) {
|
|
1449
|
+
throw asStoreIoError(releaseError, "release its exclusive lock");
|
|
1450
|
+
}
|
|
1451
|
+
return outcome.value;
|
|
1452
|
+
};
|
|
1453
|
+
acquireLock = async (id, owner, generationRootEvidence, locksRootEvidence) => {
|
|
1454
|
+
const lockPath = this.lockPath(id);
|
|
1455
|
+
const deadline = Date.now() + this.options.lockTimeoutMs;
|
|
1456
|
+
for (;;) {
|
|
1457
|
+
if (await this.tryInstallLock(id, lockPath, owner, generationRootEvidence, locksRootEvidence)) {
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
await this.reapDeadOwnerLock(id, lockPath);
|
|
1461
|
+
if (Date.now() >= deadline) {
|
|
1462
|
+
throw new GenerationSessionStoreError("LOCK_TIMEOUT", `Timed out acquiring generation session lock: ${id}`);
|
|
1463
|
+
}
|
|
1464
|
+
await new Promise((resolveDelay) => {
|
|
1465
|
+
setTimeout(resolveDelay, this.options.lockRetryMs);
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
};
|
|
1469
|
+
tryInstallLock = async (id, lockPath, owner, generationRootEvidence, locksRootEvidence) => {
|
|
1470
|
+
const stagingPath = join(this.locksRoot, `.${id}.lock.acquire-${randomUUID()}.tmp`);
|
|
1471
|
+
try {
|
|
1472
|
+
const handle = await open(stagingPath, "wx", 0o600);
|
|
1473
|
+
try {
|
|
1474
|
+
await this.hooks.beforeLockStagingWrite?.();
|
|
1475
|
+
await handle.writeFile(serializeJson(owner), "utf8");
|
|
1476
|
+
await handle.sync();
|
|
1477
|
+
}
|
|
1478
|
+
finally {
|
|
1479
|
+
await handle.close();
|
|
1480
|
+
}
|
|
1481
|
+
await this.hooks.beforeLockInstall?.();
|
|
1482
|
+
await verifyStoreDirectory(generationRootEvidence);
|
|
1483
|
+
await verifyStoreDirectory(locksRootEvidence);
|
|
1484
|
+
try {
|
|
1485
|
+
await link(stagingPath, lockPath);
|
|
1486
|
+
}
|
|
1487
|
+
catch (error) {
|
|
1488
|
+
if (isNodeError(error) && error.code === "EEXIST") {
|
|
1489
|
+
return false;
|
|
1490
|
+
}
|
|
1491
|
+
throw error;
|
|
1492
|
+
}
|
|
1493
|
+
try {
|
|
1494
|
+
await this.syncDirectory(this.locksRoot);
|
|
1495
|
+
await verifyStoreDirectory(generationRootEvidence);
|
|
1496
|
+
await verifyStoreDirectory(locksRootEvidence);
|
|
1497
|
+
}
|
|
1498
|
+
catch (error) {
|
|
1499
|
+
await this.releaseLock(id, owner.token).catch(() => undefined);
|
|
1500
|
+
throw error;
|
|
1501
|
+
}
|
|
1502
|
+
return true;
|
|
1503
|
+
}
|
|
1504
|
+
finally {
|
|
1505
|
+
await unlink(stagingPath).catch(() => undefined);
|
|
1506
|
+
}
|
|
1507
|
+
};
|
|
1508
|
+
reapDeadOwnerLock = async (id, lockPath) => {
|
|
1509
|
+
try {
|
|
1510
|
+
const identity = await this.fileIdentity(lockPath);
|
|
1511
|
+
const owner = await this.readLockOwner(lockPath);
|
|
1512
|
+
if (owner === null || this.isProcessAlive(owner.pid)) {
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
const tombstone = join(this.locksRoot, `.${id}.lock.reap-${randomUUID()}`);
|
|
1516
|
+
await rename(lockPath, tombstone);
|
|
1517
|
+
const movedIdentity = await this.fileIdentity(tombstone);
|
|
1518
|
+
if (movedIdentity.dev !== identity.dev
|
|
1519
|
+
|| movedIdentity.ino !== identity.ino) {
|
|
1520
|
+
await rename(tombstone, lockPath).catch(() => undefined);
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
await this.hooks.afterLockTombstoneRename?.();
|
|
1524
|
+
await unlink(tombstone);
|
|
1525
|
+
await this.syncDirectory(this.locksRoot);
|
|
1526
|
+
}
|
|
1527
|
+
catch (error) {
|
|
1528
|
+
if (!isNodeError(error) || error.code !== "ENOENT") {
|
|
1529
|
+
throw error;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
};
|
|
1533
|
+
releaseLock = async (id, token) => {
|
|
1534
|
+
const lockPath = this.lockPath(id);
|
|
1535
|
+
try {
|
|
1536
|
+
const identity = await this.fileIdentity(lockPath);
|
|
1537
|
+
const current = await this.readLockOwner(lockPath);
|
|
1538
|
+
if (current?.pid === process.pid && current.token === token) {
|
|
1539
|
+
const tombstone = join(this.locksRoot, `.${id}.lock.release-${randomUUID()}`);
|
|
1540
|
+
await rename(lockPath, tombstone);
|
|
1541
|
+
const movedIdentity = await this.fileIdentity(tombstone);
|
|
1542
|
+
if (movedIdentity.dev === identity.dev
|
|
1543
|
+
&& movedIdentity.ino === identity.ino) {
|
|
1544
|
+
await unlink(tombstone);
|
|
1545
|
+
await this.syncDirectory(this.locksRoot);
|
|
1546
|
+
}
|
|
1547
|
+
else {
|
|
1548
|
+
await rename(tombstone, lockPath).catch(() => undefined);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
catch (error) {
|
|
1553
|
+
if (!isNodeError(error) || error.code !== "ENOENT") {
|
|
1554
|
+
throw error;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
};
|
|
1558
|
+
readLockOwner = async (lockPath) => {
|
|
1559
|
+
const handle = await open(lockPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
1560
|
+
try {
|
|
1561
|
+
const value = JSON.parse(await handle.readFile("utf8"));
|
|
1562
|
+
if (value === null
|
|
1563
|
+
|| typeof value !== "object"
|
|
1564
|
+
|| !Number.isSafeInteger(value.pid)
|
|
1565
|
+
|| value.pid <= 0
|
|
1566
|
+
|| typeof value.token !== "string"
|
|
1567
|
+
|| value.token.length === 0) {
|
|
1568
|
+
return null;
|
|
1569
|
+
}
|
|
1570
|
+
return value;
|
|
1571
|
+
}
|
|
1572
|
+
catch {
|
|
1573
|
+
return null;
|
|
1574
|
+
}
|
|
1575
|
+
finally {
|
|
1576
|
+
await handle.close();
|
|
1577
|
+
}
|
|
1578
|
+
};
|
|
1579
|
+
fileIdentity = async (path) => {
|
|
1580
|
+
const stats = await lstat(path, { bigint: true });
|
|
1581
|
+
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
1582
|
+
throw new GenerationSessionStoreError("IO_ERROR", `Generation lock path is not a real file: ${path}`);
|
|
1583
|
+
}
|
|
1584
|
+
return { dev: stats.dev, ino: stats.ino };
|
|
1585
|
+
};
|
|
1586
|
+
isProcessAlive = (pid) => {
|
|
1587
|
+
try {
|
|
1588
|
+
process.kill(pid, 0);
|
|
1589
|
+
return true;
|
|
1590
|
+
}
|
|
1591
|
+
catch (error) {
|
|
1592
|
+
return !isNodeError(error) || error.code !== "ESRCH";
|
|
1593
|
+
}
|
|
1594
|
+
};
|
|
1595
|
+
assertPublishable(session) {
|
|
1596
|
+
if (session.verification.status !== "passed"
|
|
1597
|
+
|| session.publication.status !== "published"
|
|
1598
|
+
|| session.inFlight !== null
|
|
1599
|
+
|| session.pendingConfirmation !== null) {
|
|
1600
|
+
throw new GenerationSessionStoreError("SESSION_NOT_PUBLISHABLE", "Generation session is not ready to publish");
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
}
|