@amalgm/shell 0.1.53 → 0.1.55
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/PURPOSE.md +65 -9
- package/dist/detection/runtime.js +83 -6
- package/dist/detection/runtime.js.map +1 -1
- package/dist/entity-apply-host.d.ts +28 -0
- package/dist/entity-apply-host.js +670 -0
- package/dist/entity-apply-host.js.map +1 -0
- package/dist/entity-apply-store.d.ts +42 -0
- package/dist/entity-apply-store.js +367 -0
- package/dist/entity-apply-store.js.map +1 -0
- package/dist/entity-record-store.d.ts +17 -0
- package/dist/entity-record-store.js +144 -0
- package/dist/entity-record-store.js.map +1 -0
- package/dist/git-registration-host.d.ts +8 -1
- package/dist/git-registration-host.js +21 -0
- package/dist/git-registration-host.js.map +1 -1
- package/dist/git-repository-host.js +16 -4
- package/dist/git-repository-host.js.map +1 -1
- package/dist/materialized-graph.js +27 -4
- package/dist/materialized-graph.js.map +1 -1
- package/dist/user-ground-host.d.ts +9 -8
- package/dist/user-ground-host.js +305 -363
- package/dist/user-ground-host.js.map +1 -1
- package/dist/wire-client.d.ts +6 -0
- package/dist/wire-client.js +19 -2
- package/dist/wire-client.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
/** Node filesystem, immutable-cache, Git, and Verify effects for Live Apply. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { chmodSync, copyFileSync, existsSync, lstatSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, } from "node:fs";
|
|
4
|
+
import { dirname, join, relative } from "node:path";
|
|
5
|
+
import { applyRepositoryIdentity, buildEntityApplyEffects, createUserGroundEnrollmentPolicy, repositoryIdentityHash, } from "@amalgm/live";
|
|
6
|
+
import Database from "better-sqlite3";
|
|
7
|
+
import { ensurePrivateDir } from "./filesystem.js";
|
|
8
|
+
import { applyRepositoryFiles, captureRepository, hasGitMarker, inspectRepositoryTransportFile, } from "./git-repository-host.js";
|
|
9
|
+
import { hashContentFile } from "./content-cache-host.js";
|
|
10
|
+
import { projectMaterializedGraph } from "./materialized-graph.js";
|
|
11
|
+
import { EntityApplySqliteStore } from "./entity-apply-store.js";
|
|
12
|
+
const ROW_SELECT = `
|
|
13
|
+
SELECT resource_id AS resourceId, root_uuid AS rootUUID,
|
|
14
|
+
uuid, type, parent_uuid AS parentUUID, name, status, version,
|
|
15
|
+
payload_version AS payloadVersion, transport_version AS transportVersion,
|
|
16
|
+
relative_path AS relativePath, absolute_path AS absolutePath,
|
|
17
|
+
device_number AS deviceNumber, inode,
|
|
18
|
+
byte_size AS byteSize, modified_time_ms AS modifiedTimeMs,
|
|
19
|
+
changed_time_ms AS changedTimeMs, filesystem_mode AS filesystemMode,
|
|
20
|
+
content_verified_at_ms AS contentVerifiedAtMs
|
|
21
|
+
FROM detection_notebook`;
|
|
22
|
+
const sha256Hex = (input) => createHash("sha256").update(input).digest("hex");
|
|
23
|
+
const portable = (row) => ({
|
|
24
|
+
uuid: row.uuid,
|
|
25
|
+
type: row.type,
|
|
26
|
+
parentUUID: row.parentUUID,
|
|
27
|
+
name: row.name,
|
|
28
|
+
status: row.status,
|
|
29
|
+
version: row.version,
|
|
30
|
+
payloadVersion: row.payloadVersion,
|
|
31
|
+
transportVersion: row.transportVersion,
|
|
32
|
+
});
|
|
33
|
+
const tryLstat = (path) => {
|
|
34
|
+
try {
|
|
35
|
+
return lstatSync(path);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
const code = error.code;
|
|
39
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
40
|
+
return null;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const exactRecord = (left, right) => left.uuid === right.uuid
|
|
45
|
+
&& left.type === right.type
|
|
46
|
+
&& left.parentUUID === right.parentUUID
|
|
47
|
+
&& left.name === right.name
|
|
48
|
+
&& left.status === right.status
|
|
49
|
+
&& left.version === right.version
|
|
50
|
+
&& left.payloadVersion === right.payloadVersion
|
|
51
|
+
&& left.transportVersion === right.transportVersion;
|
|
52
|
+
/** One persistent machine-effect owner for the Apply rail lifetime. */
|
|
53
|
+
export class EntityApplyHost {
|
|
54
|
+
store;
|
|
55
|
+
effects;
|
|
56
|
+
#database;
|
|
57
|
+
#userRoot;
|
|
58
|
+
#cacheDir;
|
|
59
|
+
#bindingDir;
|
|
60
|
+
#readContent;
|
|
61
|
+
#acquireGround;
|
|
62
|
+
#rowByUuid;
|
|
63
|
+
#entityByUuid;
|
|
64
|
+
#rowsUnderRoot;
|
|
65
|
+
#prepared = new Map();
|
|
66
|
+
#groundReleases = new Map();
|
|
67
|
+
constructor(options) {
|
|
68
|
+
this.#database = new Database(options.databaseFile);
|
|
69
|
+
this.#userRoot = options.userRoot;
|
|
70
|
+
this.store = new EntityApplySqliteStore(this.#database, {
|
|
71
|
+
now: options.now,
|
|
72
|
+
onIntentFinished: (intent) => this.#releaseGround(intent),
|
|
73
|
+
isLocallyEligible: (target) => this.#isLocallyEligible(target),
|
|
74
|
+
});
|
|
75
|
+
this.#cacheDir = options.cacheDir;
|
|
76
|
+
this.#bindingDir = options.bindingDir;
|
|
77
|
+
this.#readContent = options.readContent;
|
|
78
|
+
this.#acquireGround = options.acquireGround;
|
|
79
|
+
this.#rowByUuid = this.#database.prepare(`${ROW_SELECT} WHERE uuid = ? LIMIT 1`);
|
|
80
|
+
this.#entityByUuid = this.#database.prepare(`
|
|
81
|
+
SELECT resource_id AS resourceId, root_uuid AS rootUUID,
|
|
82
|
+
uuid, type, parent_uuid AS parentUUID, name, status, version,
|
|
83
|
+
payload_version AS payloadVersion, transport_version AS transportVersion,
|
|
84
|
+
relative_path AS relativePath, absolute_path AS absolutePath,
|
|
85
|
+
device_number AS deviceNumber, inode,
|
|
86
|
+
byte_size AS byteSize, modified_time_ms AS modifiedTimeMs,
|
|
87
|
+
changed_time_ms AS changedTimeMs, filesystem_mode AS filesystemMode,
|
|
88
|
+
content_verified_at_ms AS contentVerifiedAtMs
|
|
89
|
+
FROM entities WHERE uuid = ? LIMIT 1
|
|
90
|
+
`);
|
|
91
|
+
this.#rowsUnderRoot = this.#database.prepare(`
|
|
92
|
+
SELECT resource_id AS resourceId, root_uuid AS rootUUID,
|
|
93
|
+
uuid, type, parent_uuid AS parentUUID, name, status, version,
|
|
94
|
+
payload_version AS payloadVersion, transport_version AS transportVersion,
|
|
95
|
+
relative_path AS relativePath, absolute_path AS absolutePath,
|
|
96
|
+
device_number AS deviceNumber, inode,
|
|
97
|
+
byte_size AS byteSize, modified_time_ms AS modifiedTimeMs,
|
|
98
|
+
changed_time_ms AS changedTimeMs, filesystem_mode AS filesystemMode,
|
|
99
|
+
content_verified_at_ms AS contentVerifiedAtMs
|
|
100
|
+
FROM entities WHERE root_uuid = ? ORDER BY relative_path
|
|
101
|
+
`);
|
|
102
|
+
const adapters = [
|
|
103
|
+
"workspace", "folder", "repo.git", "file.text", "file.binary", "link", "reference",
|
|
104
|
+
].map((type) => this.#adapter(type));
|
|
105
|
+
this.effects = buildEntityApplyEffects(adapters, options.captureLocal);
|
|
106
|
+
}
|
|
107
|
+
close() {
|
|
108
|
+
for (const release of this.#groundReleases.values())
|
|
109
|
+
release();
|
|
110
|
+
this.#groundReleases.clear();
|
|
111
|
+
this.store.close();
|
|
112
|
+
}
|
|
113
|
+
#adapter(type) {
|
|
114
|
+
const apply = Object.freeze({
|
|
115
|
+
prepare: (intent, plan) => this.#prepare(intent, plan),
|
|
116
|
+
guard: (intent, plan) => this.#guard(intent, plan),
|
|
117
|
+
reveal: (intent, plan) => this.#reveal(intent, plan),
|
|
118
|
+
discard: (intent, plan) => this.#discard(intent, plan),
|
|
119
|
+
});
|
|
120
|
+
const verify = Object.freeze({
|
|
121
|
+
verify: (intent, plan) => this.#verify(intent, plan),
|
|
122
|
+
});
|
|
123
|
+
return Object.freeze({
|
|
124
|
+
name: `${type}-apply`,
|
|
125
|
+
types: Object.freeze({ [type]: `${type} truth is reread from its native filesystem representation` }),
|
|
126
|
+
rails: Object.freeze({ apply, verify }),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** The portable Inbox contains cloud truth for every entity. Shell alone
|
|
130
|
+
* decides whether that accepted entity belongs on this machine. External
|
|
131
|
+
* selected roots are fully materialized; core-ground descendants obey the
|
|
132
|
+
* portable `.amalgmignore` policy before any Apply intent or disk write. */
|
|
133
|
+
#isLocallyEligible(target) {
|
|
134
|
+
const context = this.#context(target);
|
|
135
|
+
const boundary = context.current ?? context.parent;
|
|
136
|
+
if (!boundary)
|
|
137
|
+
return false;
|
|
138
|
+
const root = this.#current(boundary.rootUUID);
|
|
139
|
+
if (!root || root.absolutePath !== this.#userRoot)
|
|
140
|
+
return true;
|
|
141
|
+
const relativePath = relative(this.#userRoot, context.targetPath).split("\\").join("/");
|
|
142
|
+
if (!relativePath)
|
|
143
|
+
return true;
|
|
144
|
+
const ignore = join(this.#userRoot, ".amalgmignore");
|
|
145
|
+
const policy = createUserGroundEnrollmentPolicy(existsSync(ignore) ? readFileSync(ignore, "utf8") : "");
|
|
146
|
+
return policy(relativePath);
|
|
147
|
+
}
|
|
148
|
+
async #prepare(intent, plan) {
|
|
149
|
+
await this.#ensurePrepared(intent, plan);
|
|
150
|
+
}
|
|
151
|
+
async #ensurePrepared(intent, plan) {
|
|
152
|
+
const key = this.#key(intent);
|
|
153
|
+
const existing = this.#prepared.get(key);
|
|
154
|
+
if (existing)
|
|
155
|
+
return existing;
|
|
156
|
+
let prepared = {};
|
|
157
|
+
if (plan.kind === "file.text" || plan.kind === "file.binary" || plan.kind === "link") {
|
|
158
|
+
prepared = { artifact: await this.#readContent(plan.artifact) };
|
|
159
|
+
}
|
|
160
|
+
else if (plan.kind === "structure" && plan.repairArtifact) {
|
|
161
|
+
prepared = { artifact: await this.#readContent(plan.repairArtifact) };
|
|
162
|
+
}
|
|
163
|
+
else if (plan.kind === "repo.git") {
|
|
164
|
+
prepared = { repository: await this.#repositoryChain(plan.artifact) };
|
|
165
|
+
}
|
|
166
|
+
this.#prepared.set(key, prepared);
|
|
167
|
+
return prepared;
|
|
168
|
+
}
|
|
169
|
+
async #guard(intent, plan) {
|
|
170
|
+
await this.#holdGround(intent);
|
|
171
|
+
try {
|
|
172
|
+
const context = this.#context(plan.target);
|
|
173
|
+
const current = context.current;
|
|
174
|
+
if (current
|
|
175
|
+
&& exactRecord(portable(current), plan.target)
|
|
176
|
+
&& await this.#matches(plan.target, context.targetPath, plan, current)) {
|
|
177
|
+
return { kind: "already-target" };
|
|
178
|
+
}
|
|
179
|
+
// A rail transition can change an entity's registered payload witness
|
|
180
|
+
// without changing its bytes: clean repo leaves use Git object ids,
|
|
181
|
+
// while the ordinary file rail uses SHA-256. At the same durable UUID
|
|
182
|
+
// and address, exact target bytes prove that no write is required; Verify
|
|
183
|
+
// can advance the row directly. Equal bytes at a different address are
|
|
184
|
+
// deliberately not identity evidence.
|
|
185
|
+
if (current?.absolutePath === context.targetPath
|
|
186
|
+
&& await this.#matches(plan.target, context.targetPath, plan)) {
|
|
187
|
+
return { kind: "already-target" };
|
|
188
|
+
}
|
|
189
|
+
if (!current) {
|
|
190
|
+
if (!tryLstat(context.targetPath)) {
|
|
191
|
+
if (intent.phase === "declared")
|
|
192
|
+
this.#releaseGround(intent);
|
|
193
|
+
return { kind: "safe" };
|
|
194
|
+
}
|
|
195
|
+
const result = await this.#matches(plan.target, context.targetPath, plan)
|
|
196
|
+
? { kind: "already-target" }
|
|
197
|
+
: { kind: "local-change" };
|
|
198
|
+
if (result.kind === "local-change")
|
|
199
|
+
this.#releaseGround(intent);
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
// A move/rename may never erase a different thing already occupying the
|
|
203
|
+
// accepted destination. Detect must first give that ground identity.
|
|
204
|
+
if (current.absolutePath !== context.targetPath && tryLstat(context.targetPath)) {
|
|
205
|
+
this.#releaseGround(intent);
|
|
206
|
+
return { kind: "local-change" };
|
|
207
|
+
}
|
|
208
|
+
if (await this.#matches(portable(current), current.absolutePath, plan, current)) {
|
|
209
|
+
if (intent.phase === "declared")
|
|
210
|
+
this.#releaseGround(intent);
|
|
211
|
+
return { kind: "safe" };
|
|
212
|
+
}
|
|
213
|
+
const displaced = this.#displacedPath(intent, context);
|
|
214
|
+
if (!tryLstat(current.absolutePath)
|
|
215
|
+
&& tryLstat(displaced)
|
|
216
|
+
&& await this.#matches(portable(current), displaced, plan, current)) {
|
|
217
|
+
if (intent.phase === "declared")
|
|
218
|
+
this.#releaseGround(intent);
|
|
219
|
+
return { kind: "safe" };
|
|
220
|
+
}
|
|
221
|
+
this.#releaseGround(intent);
|
|
222
|
+
return { kind: "local-change" };
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
this.#releaseGround(intent);
|
|
226
|
+
return { kind: "retry", reason: error instanceof Error ? error.message : String(error) };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async #reveal(intent, plan) {
|
|
230
|
+
try {
|
|
231
|
+
const context = this.#context(plan.target);
|
|
232
|
+
const prepared = await this.#ensurePrepared(intent, plan);
|
|
233
|
+
if (plan.kind === "lifecycle") {
|
|
234
|
+
this.#removeWithRecovery(intent, context);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (plan.kind === "repo.git") {
|
|
238
|
+
if (context.current && context.current.absolutePath !== context.targetPath) {
|
|
239
|
+
this.#moveWithRecovery(intent, context.current.absolutePath, context.targetPath, context);
|
|
240
|
+
}
|
|
241
|
+
ensurePrivateDir(context.targetPath);
|
|
242
|
+
const repository = prepared.repository;
|
|
243
|
+
if (!repository)
|
|
244
|
+
throw new Error("repository Apply has no prepared transport chain");
|
|
245
|
+
await applyRepositoryFiles(context.targetPath, repository.chain);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (plan.kind === "structure") {
|
|
249
|
+
if (context.current && context.current.absolutePath !== context.targetPath) {
|
|
250
|
+
this.#moveWithRecovery(intent, context.current.absolutePath, context.targetPath, context);
|
|
251
|
+
}
|
|
252
|
+
else if (!tryLstat(context.targetPath)) {
|
|
253
|
+
if (plan.target.type === "workspace" || plan.target.type === "folder") {
|
|
254
|
+
ensurePrivateDir(context.targetPath);
|
|
255
|
+
}
|
|
256
|
+
else if (prepared.artifact) {
|
|
257
|
+
this.#installArtifact(intent, context, plan.target, prepared.artifact);
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
throw new Error("structural leaf repair has no immutable artifact");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if ((plan.target.type === "workspace" || plan.target.type === "folder")
|
|
264
|
+
&& hasGitMarker(context.targetPath)) {
|
|
265
|
+
rmSync(join(context.targetPath, ".git"), { recursive: true, force: true });
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (plan.kind === "reference") {
|
|
270
|
+
this.#installLink(intent, context, relative(dirname(context.targetPath), join(this.#bindingDir, plan.targetEntityId)));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (plan.kind === "link") {
|
|
274
|
+
this.#installLink(intent, context, plan.targetText);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const artifact = prepared.artifact;
|
|
278
|
+
if (!artifact)
|
|
279
|
+
throw new Error(`${plan.kind} Apply has no prepared immutable artifact`);
|
|
280
|
+
this.#installArtifact(intent, context, plan.target, artifact);
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
this.#releaseGround(intent);
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
async #verify(intent, plan) {
|
|
288
|
+
try {
|
|
289
|
+
const context = this.#context(plan.target);
|
|
290
|
+
if (await this.#restoreRacedLocal(intent, plan, context)) {
|
|
291
|
+
this.#releaseGround(intent);
|
|
292
|
+
return { kind: "local-change" };
|
|
293
|
+
}
|
|
294
|
+
if (!await this.#matches(plan.target, context.targetPath, plan)) {
|
|
295
|
+
this.#releaseGround(intent);
|
|
296
|
+
return { kind: "local-change" };
|
|
297
|
+
}
|
|
298
|
+
if (plan.target.type === "repo.git" && plan.target.status === "active") {
|
|
299
|
+
const repository = await this.#repositoryRows(intent, plan, context);
|
|
300
|
+
this.store.stageVerification(intent, repository.rows, repository.removals);
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
this.store.stageVerification(intent, [this.#verifiedRow(plan.target, context)]);
|
|
304
|
+
}
|
|
305
|
+
return { kind: "match" };
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
this.#releaseGround(intent);
|
|
309
|
+
return { kind: "retry", reason: error instanceof Error ? error.message : String(error) };
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
async #discard(intent, _plan) {
|
|
313
|
+
try {
|
|
314
|
+
this.#prepared.delete(this.#key(intent));
|
|
315
|
+
const current = this.#current(intent.entityId);
|
|
316
|
+
if (!current)
|
|
317
|
+
return;
|
|
318
|
+
rmSync(this.#controlDirectory(intent, {
|
|
319
|
+
current,
|
|
320
|
+
parent: current.parentUUID ? this.#current(current.parentUUID) : null,
|
|
321
|
+
}), { recursive: true, force: true });
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
this.#releaseGround(intent);
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async #holdGround(intent) {
|
|
329
|
+
const key = this.#key(intent);
|
|
330
|
+
if (this.#groundReleases.has(key))
|
|
331
|
+
return;
|
|
332
|
+
this.#groundReleases.set(key, await this.#acquireGround());
|
|
333
|
+
}
|
|
334
|
+
#releaseGround(intent) {
|
|
335
|
+
const key = this.#key(intent);
|
|
336
|
+
const release = this.#groundReleases.get(key);
|
|
337
|
+
if (!release)
|
|
338
|
+
return;
|
|
339
|
+
this.#groundReleases.delete(key);
|
|
340
|
+
release();
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* A save can land after the final read guard but before the replacement
|
|
344
|
+
* syscall. Replacement keeps the displaced bytes private; Verify compares
|
|
345
|
+
* those bytes with the last notebook observation. A difference is restored
|
|
346
|
+
* so Live's ordinary Detect + Record capture path can make it durable.
|
|
347
|
+
*/
|
|
348
|
+
async #restoreRacedLocal(intent, plan, context) {
|
|
349
|
+
const current = context.current;
|
|
350
|
+
if (!current)
|
|
351
|
+
return false;
|
|
352
|
+
const displaced = this.#displacedPath(intent, context);
|
|
353
|
+
if (!tryLstat(displaced))
|
|
354
|
+
return false;
|
|
355
|
+
if (await this.#matches(portable(current), displaced, plan, current))
|
|
356
|
+
return false;
|
|
357
|
+
const applied = join(this.#controlDirectory(intent, context), "applied");
|
|
358
|
+
rmSync(applied, { recursive: true, force: true });
|
|
359
|
+
if (tryLstat(context.targetPath))
|
|
360
|
+
renameSync(context.targetPath, applied);
|
|
361
|
+
ensurePrivateDir(dirname(current.absolutePath));
|
|
362
|
+
if (tryLstat(current.absolutePath))
|
|
363
|
+
rmSync(current.absolutePath, { recursive: true, force: true });
|
|
364
|
+
renameSync(displaced, current.absolutePath);
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
#installArtifact(intent, context, target, artifact) {
|
|
368
|
+
const control = this.#controlDirectory(intent, context);
|
|
369
|
+
ensurePrivateDir(control);
|
|
370
|
+
const staged = join(control, "prepared");
|
|
371
|
+
rmSync(staged, { recursive: true, force: true });
|
|
372
|
+
copyFileSync(artifact.file, staged);
|
|
373
|
+
const currentMode = context.current && tryLstat(context.current.absolutePath)?.mode;
|
|
374
|
+
chmodSync(staged, currentMode ? currentMode & 0o777 : 0o644);
|
|
375
|
+
this.#replacePrepared(intent, context, staged);
|
|
376
|
+
}
|
|
377
|
+
#installLink(intent, context, targetText) {
|
|
378
|
+
const control = this.#controlDirectory(intent, context);
|
|
379
|
+
ensurePrivateDir(control);
|
|
380
|
+
const staged = join(control, "prepared");
|
|
381
|
+
rmSync(staged, { recursive: true, force: true });
|
|
382
|
+
symlinkSync(targetText, staged);
|
|
383
|
+
this.#replacePrepared(intent, context, staged);
|
|
384
|
+
}
|
|
385
|
+
#replacePrepared(intent, context, staged) {
|
|
386
|
+
ensurePrivateDir(dirname(context.targetPath));
|
|
387
|
+
const displaced = this.#displacedPath(intent, context);
|
|
388
|
+
if (tryLstat(context.targetPath)) {
|
|
389
|
+
rmSync(displaced, { recursive: true, force: true });
|
|
390
|
+
renameSync(context.targetPath, displaced);
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
renameSync(staged, context.targetPath);
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
if (!tryLstat(context.targetPath) && tryLstat(displaced)) {
|
|
397
|
+
renameSync(displaced, context.targetPath);
|
|
398
|
+
}
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
#moveWithRecovery(intent, source, destination, context) {
|
|
403
|
+
ensurePrivateDir(dirname(destination));
|
|
404
|
+
if (tryLstat(destination)) {
|
|
405
|
+
const displaced = this.#displacedPath(intent, context);
|
|
406
|
+
rmSync(displaced, { recursive: true, force: true });
|
|
407
|
+
renameSync(destination, displaced);
|
|
408
|
+
}
|
|
409
|
+
renameSync(source, destination);
|
|
410
|
+
}
|
|
411
|
+
#removeWithRecovery(intent, context) {
|
|
412
|
+
const source = context.current?.absolutePath ?? context.targetPath;
|
|
413
|
+
if (!tryLstat(source))
|
|
414
|
+
return;
|
|
415
|
+
const displaced = this.#displacedPath(intent, context);
|
|
416
|
+
rmSync(displaced, { recursive: true, force: true });
|
|
417
|
+
ensurePrivateDir(dirname(displaced));
|
|
418
|
+
renameSync(source, displaced);
|
|
419
|
+
}
|
|
420
|
+
async #matches(record, path, plan, stored) {
|
|
421
|
+
const stats = tryLstat(path);
|
|
422
|
+
if (record.status !== "active")
|
|
423
|
+
return stats === null;
|
|
424
|
+
if (!stats)
|
|
425
|
+
return false;
|
|
426
|
+
if (record.type === "file.text" || record.type === "file.binary") {
|
|
427
|
+
return stats.isFile() && !stats.isSymbolicLink()
|
|
428
|
+
&& record.payloadVersion !== null
|
|
429
|
+
&& await this.#hashFile(path) === record.payloadVersion;
|
|
430
|
+
}
|
|
431
|
+
if (record.type === "link") {
|
|
432
|
+
return stats.isSymbolicLink()
|
|
433
|
+
&& record.payloadVersion !== null
|
|
434
|
+
&& sha256Hex(Buffer.from(readlinkSync(path), "utf8")) === record.payloadVersion;
|
|
435
|
+
}
|
|
436
|
+
if (record.type === "reference") {
|
|
437
|
+
if (!stats.isSymbolicLink() || !record.payloadVersion)
|
|
438
|
+
return false;
|
|
439
|
+
return readlinkSync(path) === relative(dirname(path), join(this.#bindingDir, record.payloadVersion));
|
|
440
|
+
}
|
|
441
|
+
if (record.type === "repo.git") {
|
|
442
|
+
if (!stats.isDirectory() || !hasGitMarker(path)
|
|
443
|
+
|| !record.payloadVersion || !record.transportVersion) {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
const identity = plan.kind === "repo.git"
|
|
447
|
+
&& (!stored || stored.transportVersion !== record.transportVersion)
|
|
448
|
+
? (await this.#repositoryChain(plan.artifact)).identity
|
|
449
|
+
: this.#repositoryIdentity(stored ?? this.#current(record.uuid));
|
|
450
|
+
const file = join(this.#cacheDir, `${record.transportVersion}.bin`);
|
|
451
|
+
if (!existsSync(file))
|
|
452
|
+
return false;
|
|
453
|
+
const layout = inspectRepositoryTransportFile(file);
|
|
454
|
+
const prior = {
|
|
455
|
+
stateId: record.payloadVersion,
|
|
456
|
+
transportVersion: record.transportVersion,
|
|
457
|
+
card: layout.card,
|
|
458
|
+
identityHash: layout.identityHash,
|
|
459
|
+
};
|
|
460
|
+
const captured = captureRepository(path, identity, prior, identity);
|
|
461
|
+
return captured.bytes === null
|
|
462
|
+
&& captured.stateId === record.payloadVersion
|
|
463
|
+
&& captured.transportVersion === record.transportVersion;
|
|
464
|
+
}
|
|
465
|
+
if (!stats.isDirectory() || stats.isSymbolicLink())
|
|
466
|
+
return false;
|
|
467
|
+
if (hasGitMarker(path))
|
|
468
|
+
return false;
|
|
469
|
+
// Containers do not ship or overwrite a child list. Their physical Apply
|
|
470
|
+
// proof is only the accepted address/type/lifecycle result; independently
|
|
471
|
+
// ordered child UUIDs establish membership as they materialize.
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
#verifiedRow(target, context) {
|
|
475
|
+
const prior = context.current ?? context.parent;
|
|
476
|
+
if (!prior)
|
|
477
|
+
throw new Error(`entity ${target.uuid} has no local materialization boundary`);
|
|
478
|
+
const stats = tryLstat(context.targetPath);
|
|
479
|
+
if (target.status === "active" && !stats)
|
|
480
|
+
throw new Error(`entity ${target.uuid} vanished before Verify`);
|
|
481
|
+
const evidence = stats ?? prior;
|
|
482
|
+
return {
|
|
483
|
+
resourceId: prior.resourceId,
|
|
484
|
+
rootUUID: prior.rootUUID,
|
|
485
|
+
record: target,
|
|
486
|
+
relativePath: target.parentUUID === null
|
|
487
|
+
? prior.relativePath
|
|
488
|
+
: [context.parent?.relativePath, target.name].filter(Boolean).join("/"),
|
|
489
|
+
absolutePath: context.targetPath,
|
|
490
|
+
deviceNumber: "dev" in evidence ? evidence.dev : evidence.deviceNumber,
|
|
491
|
+
inode: "ino" in evidence ? evidence.ino : evidence.inode,
|
|
492
|
+
byteSize: "size" in evidence ? evidence.size : evidence.byteSize,
|
|
493
|
+
modifiedTimeMs: "mtimeMs" in evidence ? evidence.mtimeMs : evidence.modifiedTimeMs,
|
|
494
|
+
changedTimeMs: "ctimeMs" in evidence ? evidence.ctimeMs : evidence.changedTimeMs,
|
|
495
|
+
filesystemMode: "mode" in evidence ? evidence.mode : evidence.filesystemMode,
|
|
496
|
+
contentVerifiedAtMs: ["file.text", "file.binary", "link", "reference"]
|
|
497
|
+
.includes(target.type) ? Date.now() : null,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
async #repositoryRows(intent, plan, context) {
|
|
501
|
+
if (plan.kind !== "repo.git")
|
|
502
|
+
throw new Error("repository Verify received a non-repository plan");
|
|
503
|
+
const prepared = await this.#ensurePrepared(intent, plan);
|
|
504
|
+
const repository = prepared.repository;
|
|
505
|
+
if (!repository)
|
|
506
|
+
throw new Error("repository Verify has no transport identity");
|
|
507
|
+
const root = this.#verifiedRow(plan.target, context);
|
|
508
|
+
const existing = this.#rowsUnderRoot.all(root.rootUUID);
|
|
509
|
+
const repositoryPrefix = root.relativePath;
|
|
510
|
+
const identityByPath = new Map(repository.identity.map((entry) => [entry.path, entry]));
|
|
511
|
+
const nestedRepositoryPaths = repository.identity
|
|
512
|
+
.filter((entry) => entry.type === "repo.git")
|
|
513
|
+
.map((entry) => entry.path);
|
|
514
|
+
const localRepositoryPath = (row) => {
|
|
515
|
+
if (row.uuid === plan.target.uuid)
|
|
516
|
+
return "";
|
|
517
|
+
if (!repositoryPrefix)
|
|
518
|
+
return row.relativePath;
|
|
519
|
+
return row.relativePath.startsWith(`${repositoryPrefix}/`)
|
|
520
|
+
? row.relativePath.slice(repositoryPrefix.length + 1)
|
|
521
|
+
: null;
|
|
522
|
+
};
|
|
523
|
+
const removals = existing.filter((row) => {
|
|
524
|
+
const path = localRepositoryPath(row);
|
|
525
|
+
if (path === null || path === "")
|
|
526
|
+
return false;
|
|
527
|
+
if (nestedRepositoryPaths.some((nested) => path.startsWith(`${nested}/`)))
|
|
528
|
+
return false;
|
|
529
|
+
return identityByPath.get(path)?.uuid !== row.uuid;
|
|
530
|
+
}).map((row) => row.uuid);
|
|
531
|
+
const removed = new Set(removals);
|
|
532
|
+
const base = existing.filter((row) => !removed.has(row.uuid)).map((row) => ({
|
|
533
|
+
record: row.uuid === plan.target.uuid ? plan.target : portable(row),
|
|
534
|
+
relativePath: row.uuid === plan.target.uuid ? root.relativePath : row.relativePath,
|
|
535
|
+
absolutePath: row.uuid === plan.target.uuid ? root.absolutePath : row.absolutePath,
|
|
536
|
+
deviceNumber: row.uuid === plan.target.uuid ? root.deviceNumber : row.deviceNumber,
|
|
537
|
+
inode: row.uuid === plan.target.uuid ? root.inode : row.inode,
|
|
538
|
+
byteSize: row.uuid === plan.target.uuid ? root.byteSize ?? 0 : row.byteSize ?? 0,
|
|
539
|
+
modifiedTimeMs: row.uuid === plan.target.uuid ? root.modifiedTimeMs ?? 0 : row.modifiedTimeMs ?? 0,
|
|
540
|
+
changedTimeMs: row.uuid === plan.target.uuid ? root.changedTimeMs ?? 0 : row.changedTimeMs ?? 0,
|
|
541
|
+
filesystemMode: row.uuid === plan.target.uuid ? root.filesystemMode ?? 0 : row.filesystemMode ?? 0,
|
|
542
|
+
contentVerifiedAtMs: row.uuid === plan.target.uuid
|
|
543
|
+
? root.contentVerifiedAtMs : row.contentVerifiedAtMs,
|
|
544
|
+
}));
|
|
545
|
+
if (!base.some((row) => row.record.uuid === plan.target.uuid))
|
|
546
|
+
base.push({
|
|
547
|
+
record: root.record,
|
|
548
|
+
relativePath: root.relativePath,
|
|
549
|
+
absolutePath: root.absolutePath,
|
|
550
|
+
deviceNumber: root.deviceNumber,
|
|
551
|
+
inode: root.inode,
|
|
552
|
+
byteSize: root.byteSize ?? 0,
|
|
553
|
+
modifiedTimeMs: root.modifiedTimeMs ?? 0,
|
|
554
|
+
changedTimeMs: root.changedTimeMs ?? 0,
|
|
555
|
+
filesystemMode: root.filesystemMode ?? 0,
|
|
556
|
+
contentVerifiedAtMs: root.contentVerifiedAtMs,
|
|
557
|
+
});
|
|
558
|
+
const rows = projectMaterializedGraph(base, [{
|
|
559
|
+
record: plan.target,
|
|
560
|
+
identity: repository.identity,
|
|
561
|
+
}], sha256Hex).map((row) => ({
|
|
562
|
+
resourceId: root.resourceId,
|
|
563
|
+
rootUUID: root.rootUUID,
|
|
564
|
+
...row,
|
|
565
|
+
}));
|
|
566
|
+
const repositories = rows.filter((row) => row.record.type === "repo.git");
|
|
567
|
+
const owner = (row) => repositories
|
|
568
|
+
.filter((candidate) => candidate.record.uuid !== row.record.uuid
|
|
569
|
+
&& (candidate.relativePath === ""
|
|
570
|
+
|| row.relativePath.startsWith(`${candidate.relativePath}/`)))
|
|
571
|
+
.sort((left, right) => right.relativePath.length - left.relativePath.length)[0] ?? null;
|
|
572
|
+
const projectedIdentity = rows
|
|
573
|
+
.filter((row) => row.record.uuid !== plan.target.uuid
|
|
574
|
+
&& owner(row)?.record.uuid === plan.target.uuid)
|
|
575
|
+
.map((row) => ({
|
|
576
|
+
path: root.relativePath
|
|
577
|
+
? row.relativePath.slice(root.relativePath.length + 1)
|
|
578
|
+
: row.relativePath,
|
|
579
|
+
uuid: row.record.uuid,
|
|
580
|
+
type: row.record.type,
|
|
581
|
+
payloadVersion: row.record.payloadVersion,
|
|
582
|
+
}))
|
|
583
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
584
|
+
if (repositoryIdentityHash(projectedIdentity, sha256Hex)
|
|
585
|
+
!== repositoryIdentityHash(repository.identity, sha256Hex)) {
|
|
586
|
+
throw new Error("repository Apply projection does not reproduce its transport identity map");
|
|
587
|
+
}
|
|
588
|
+
return { rows, removals };
|
|
589
|
+
}
|
|
590
|
+
async #repositoryChain(artifact) {
|
|
591
|
+
const reversed = [];
|
|
592
|
+
const versions = new Set();
|
|
593
|
+
let version = artifact.contentHash;
|
|
594
|
+
while (version) {
|
|
595
|
+
if (versions.has(version))
|
|
596
|
+
throw new Error("repository Apply transport chain has a cycle");
|
|
597
|
+
versions.add(version);
|
|
598
|
+
const local = await this.#readContent({ ...artifact, contentHash: version });
|
|
599
|
+
if (local.contentHash !== version)
|
|
600
|
+
throw new Error("repository Apply cache returned wrong transport");
|
|
601
|
+
reversed.push({ file: local.file, transportVersion: version });
|
|
602
|
+
version = inspectRepositoryTransportFile(local.file).parentTransportVersion;
|
|
603
|
+
}
|
|
604
|
+
const chain = reversed.reverse();
|
|
605
|
+
let identity = null;
|
|
606
|
+
for (const sealed of chain) {
|
|
607
|
+
identity = applyRepositoryIdentity(identity, inspectRepositoryTransportFile(sealed.file), sha256Hex);
|
|
608
|
+
}
|
|
609
|
+
return { chain, identity: identity ?? [] };
|
|
610
|
+
}
|
|
611
|
+
#repositoryIdentity(repository) {
|
|
612
|
+
if (!repository)
|
|
613
|
+
return [];
|
|
614
|
+
const rows = this.#rowsUnderRoot.all(repository.rootUUID);
|
|
615
|
+
const repositories = rows.filter((row) => row.type === "repo.git");
|
|
616
|
+
const owner = (row) => repositories
|
|
617
|
+
.filter((candidate) => candidate.uuid !== row.uuid
|
|
618
|
+
&& (candidate.relativePath === ""
|
|
619
|
+
|| row.relativePath.startsWith(`${candidate.relativePath}/`)))
|
|
620
|
+
.sort((left, right) => right.relativePath.length - left.relativePath.length)[0] ?? null;
|
|
621
|
+
return rows.filter((row) => row.uuid !== repository.uuid && owner(row)?.uuid === repository.uuid)
|
|
622
|
+
.map((row) => ({
|
|
623
|
+
path: repository.relativePath
|
|
624
|
+
? row.relativePath.slice(repository.relativePath.length + 1)
|
|
625
|
+
: row.relativePath,
|
|
626
|
+
uuid: row.uuid,
|
|
627
|
+
type: row.type,
|
|
628
|
+
payloadVersion: row.payloadVersion,
|
|
629
|
+
}))
|
|
630
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
631
|
+
}
|
|
632
|
+
#context(target) {
|
|
633
|
+
const current = this.#current(target.uuid);
|
|
634
|
+
const parent = target.parentUUID ? this.#current(target.parentUUID) : null;
|
|
635
|
+
if (target.parentUUID !== null && !parent) {
|
|
636
|
+
throw new Error(`entity ${target.uuid} waits for materialized parent ${target.parentUUID}`);
|
|
637
|
+
}
|
|
638
|
+
const targetPath = parent ? join(parent.absolutePath, target.name) : current?.absolutePath;
|
|
639
|
+
if (!targetPath)
|
|
640
|
+
throw new Error(`root entity ${target.uuid} has no machine binding`);
|
|
641
|
+
return { current, parent, targetPath };
|
|
642
|
+
}
|
|
643
|
+
#current(entityId) {
|
|
644
|
+
return this.#rowByUuid.get(entityId)
|
|
645
|
+
?? this.#entityByUuid.get(entityId)
|
|
646
|
+
?? null;
|
|
647
|
+
}
|
|
648
|
+
#root(current) {
|
|
649
|
+
const root = this.#current(current.rootUUID);
|
|
650
|
+
if (!root)
|
|
651
|
+
throw new Error(`entity ${current.uuid} has no materialized root ${current.rootUUID}`);
|
|
652
|
+
return root;
|
|
653
|
+
}
|
|
654
|
+
#controlDirectory(intent, context) {
|
|
655
|
+
const anchor = context.current ?? context.parent;
|
|
656
|
+
if (!anchor)
|
|
657
|
+
throw new Error(`entity ${intent.entityId} has no Apply control root`);
|
|
658
|
+
return join(this.#root(anchor).absolutePath, ".amalgm", "apply", `${intent.entityId}-${intent.throughSequence}`);
|
|
659
|
+
}
|
|
660
|
+
#displacedPath(intent, context) {
|
|
661
|
+
return join(this.#controlDirectory(intent, context), "displaced");
|
|
662
|
+
}
|
|
663
|
+
#key(intent) {
|
|
664
|
+
return `${intent.entityId}:${intent.throughSequence}`;
|
|
665
|
+
}
|
|
666
|
+
async #hashFile(file) {
|
|
667
|
+
return hashContentFile(file);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
//# sourceMappingURL=entity-apply-host.js.map
|