@amalgm/shell 0.1.44 → 0.1.45
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 +16 -13
- package/dist/detection/journal-codec.d.ts +3 -0
- package/dist/detection/journal-codec.js +34 -0
- package/dist/detection/journal-codec.js.map +1 -0
- package/dist/{detection-host.d.ts → detection/portable.d.ts} +19 -1
- package/dist/{detection-host.js → detection/portable.js} +32 -4
- package/dist/detection/portable.js.map +1 -0
- package/dist/detection/runtime.d.ts +58 -0
- package/dist/detection/runtime.js +669 -0
- package/dist/detection/runtime.js.map +1 -0
- package/dist/user-ground-host.d.ts +12 -2
- package/dist/user-ground-host.js +423 -147
- package/dist/user-ground-host.js.map +1 -1
- package/package.json +3 -3
- package/dist/detection-host.js.map +0 -1
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The named Detect lane.
|
|
3
|
+
*
|
|
4
|
+
* A watcher path that still names enrolled, non-repository ground does not
|
|
5
|
+
* justify a workspace walk. This runtime holds one SQLite connection,
|
|
6
|
+
* resolves indexed paths, widens only to a named new folder's subtree, and
|
|
7
|
+
* commits exact Live proposals plus local truth in one transaction. Evidence
|
|
8
|
+
* it cannot prove is declined before mutation and goes to reconciliation in
|
|
9
|
+
* the owning host.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
12
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, readlinkSync } from "node:fs";
|
|
13
|
+
import { join, posix, resolve, sep } from "node:path";
|
|
14
|
+
import { canonicalVersion, checkContentManifest, classifyFile, createUserGroundEnrollmentPolicy, detectedChangeProposal, membershipHash, } from "@amalgm/live";
|
|
15
|
+
import Database from "better-sqlite3";
|
|
16
|
+
import { captureContentFile } from "../content-cache-host.js";
|
|
17
|
+
import { exactTextReplay } from "./portable.js";
|
|
18
|
+
import { encodeMutationOperation } from "./journal-codec.js";
|
|
19
|
+
const ROW_SELECT = `
|
|
20
|
+
SELECT resource_id AS resourceId, root_uuid AS rootUUID,
|
|
21
|
+
uuid, type, parent_uuid AS parentUUID, name, status, version,
|
|
22
|
+
payload_version AS payloadVersion, transport_version AS transportVersion,
|
|
23
|
+
relative_path AS relativePath, absolute_path AS absolutePath,
|
|
24
|
+
device_number AS deviceNumber, inode,
|
|
25
|
+
byte_size AS byteSize, modified_time_ms AS modifiedTimeMs,
|
|
26
|
+
changed_time_ms AS changedTimeMs, filesystem_mode AS filesystemMode,
|
|
27
|
+
content_verified_at_ms AS contentVerifiedAtMs
|
|
28
|
+
FROM detection_notebook`;
|
|
29
|
+
const ROW_COLUMNS = [
|
|
30
|
+
"uuid", "resource_id", "root_uuid", "type", "parent_uuid", "name", "status", "version",
|
|
31
|
+
"payload_version", "transport_version", "relative_path", "absolute_path", "device_number",
|
|
32
|
+
"inode", "byte_size", "modified_time_ms", "changed_time_ms", "filesystem_mode",
|
|
33
|
+
"content_verified_at_ms",
|
|
34
|
+
].join(", ");
|
|
35
|
+
const rowValues = (row) => [
|
|
36
|
+
row.uuid, row.resourceId, row.rootUUID, row.type, row.parentUUID, row.name, row.status,
|
|
37
|
+
row.version, row.payloadVersion, row.transportVersion, row.relativePath, row.absolutePath,
|
|
38
|
+
row.deviceNumber, row.inode, row.byteSize, row.modifiedTimeMs, row.changedTimeMs,
|
|
39
|
+
row.filesystemMode, row.contentVerifiedAtMs,
|
|
40
|
+
];
|
|
41
|
+
const upsert = (database, table) => database.prepare(`
|
|
42
|
+
INSERT INTO ${table}(${ROW_COLUMNS})
|
|
43
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
44
|
+
ON CONFLICT(uuid) DO UPDATE SET
|
|
45
|
+
resource_id = excluded.resource_id,
|
|
46
|
+
root_uuid = excluded.root_uuid,
|
|
47
|
+
type = excluded.type,
|
|
48
|
+
parent_uuid = excluded.parent_uuid,
|
|
49
|
+
name = excluded.name,
|
|
50
|
+
status = excluded.status,
|
|
51
|
+
version = excluded.version,
|
|
52
|
+
payload_version = excluded.payload_version,
|
|
53
|
+
transport_version = excluded.transport_version,
|
|
54
|
+
relative_path = excluded.relative_path,
|
|
55
|
+
absolute_path = excluded.absolute_path,
|
|
56
|
+
device_number = excluded.device_number,
|
|
57
|
+
inode = excluded.inode,
|
|
58
|
+
byte_size = excluded.byte_size,
|
|
59
|
+
modified_time_ms = excluded.modified_time_ms,
|
|
60
|
+
changed_time_ms = excluded.changed_time_ms,
|
|
61
|
+
filesystem_mode = excluded.filesystem_mode,
|
|
62
|
+
content_verified_at_ms = excluded.content_verified_at_ms
|
|
63
|
+
`);
|
|
64
|
+
const portable = (row) => ({
|
|
65
|
+
uuid: row.uuid,
|
|
66
|
+
type: row.type,
|
|
67
|
+
parentUUID: row.parentUUID,
|
|
68
|
+
name: row.name,
|
|
69
|
+
status: row.status,
|
|
70
|
+
version: row.version,
|
|
71
|
+
payloadVersion: row.payloadVersion,
|
|
72
|
+
transportVersion: row.transportVersion,
|
|
73
|
+
});
|
|
74
|
+
const sha256Hex = (input) => createHash("sha256").update(input).digest("hex");
|
|
75
|
+
const sameFingerprint = (row, stats) => row.deviceNumber === stats.dev
|
|
76
|
+
&& row.inode === stats.ino
|
|
77
|
+
&& row.byteSize === stats.size
|
|
78
|
+
&& row.modifiedTimeMs === stats.mtimeMs
|
|
79
|
+
&& row.changedTimeMs === stats.ctimeMs
|
|
80
|
+
&& row.filesystemMode === stats.mode;
|
|
81
|
+
const journalJson = encodeMutationOperation;
|
|
82
|
+
/** One persistent, prepared Detect store. It owns no Watch or Send behavior. */
|
|
83
|
+
export class NamedDetectRuntime {
|
|
84
|
+
database;
|
|
85
|
+
cacheDir;
|
|
86
|
+
userRoot;
|
|
87
|
+
rootAtDirectory;
|
|
88
|
+
rowAtPath;
|
|
89
|
+
rowAtUuid;
|
|
90
|
+
rowsAtPhysicalIdentity;
|
|
91
|
+
notebookChildren;
|
|
92
|
+
materializedByUuid;
|
|
93
|
+
materializedUnderPath;
|
|
94
|
+
writeEntity;
|
|
95
|
+
writeNotebook;
|
|
96
|
+
appendMutation;
|
|
97
|
+
removeEntity;
|
|
98
|
+
userPolicy;
|
|
99
|
+
constructor(databaseFile, cacheDir, userRoot) {
|
|
100
|
+
this.database = new Database(databaseFile);
|
|
101
|
+
this.database.pragma("journal_mode = WAL");
|
|
102
|
+
this.database.pragma("synchronous = FULL");
|
|
103
|
+
this.cacheDir = cacheDir;
|
|
104
|
+
this.userRoot = resolve(userRoot);
|
|
105
|
+
this.userPolicy = this.readUserPolicy();
|
|
106
|
+
this.rootAtDirectory = this.database.prepare(`${ROW_SELECT} WHERE absolute_path = ? AND relative_path = '' AND status = 'active' LIMIT 1`);
|
|
107
|
+
this.rowAtPath = this.database.prepare(`${ROW_SELECT} WHERE root_uuid = ? AND relative_path = ? AND status = 'active' LIMIT 1`);
|
|
108
|
+
this.rowAtUuid = this.database.prepare(`${ROW_SELECT} WHERE uuid = ? LIMIT 1`);
|
|
109
|
+
this.rowsAtPhysicalIdentity = this.database.prepare(`${ROW_SELECT} WHERE device_number = ? AND inode = ? AND status = 'active' ORDER BY uuid`);
|
|
110
|
+
this.notebookChildren = this.database.prepare(`
|
|
111
|
+
SELECT uuid, name FROM detection_notebook
|
|
112
|
+
WHERE parent_uuid = ? AND status = 'active' ORDER BY name, uuid
|
|
113
|
+
`);
|
|
114
|
+
this.materializedByUuid = this.database.prepare("SELECT 1 AS present FROM entities WHERE uuid = ?");
|
|
115
|
+
this.materializedUnderPath = this.database.prepare(`
|
|
116
|
+
SELECT uuid FROM entities
|
|
117
|
+
WHERE root_uuid = ?
|
|
118
|
+
AND (relative_path = ? OR substr(relative_path, 1, length(?) + 1) = ? || '/')
|
|
119
|
+
`);
|
|
120
|
+
this.writeEntity = upsert(this.database, "entities");
|
|
121
|
+
this.writeNotebook = upsert(this.database, "detection_notebook");
|
|
122
|
+
this.appendMutation = this.database.prepare(`
|
|
123
|
+
INSERT INTO mutation_journal(
|
|
124
|
+
mutation_id, resource_id, operation_kind, operation_json, created_at
|
|
125
|
+
) VALUES (?, ?, 'detected.change', ?, ?)
|
|
126
|
+
`);
|
|
127
|
+
this.removeEntity = this.database.prepare("DELETE FROM entities WHERE uuid = ?");
|
|
128
|
+
}
|
|
129
|
+
close() {
|
|
130
|
+
this.database.close();
|
|
131
|
+
}
|
|
132
|
+
refreshEnrollmentPolicy() {
|
|
133
|
+
this.userPolicy = this.readUserPolicy();
|
|
134
|
+
}
|
|
135
|
+
async detect(observations) {
|
|
136
|
+
if (observations.some(({ suspicion }) => suspicion.paths === null)) {
|
|
137
|
+
return { handled: false, evidence: [] };
|
|
138
|
+
}
|
|
139
|
+
const captured = [];
|
|
140
|
+
const materializedRemovals = new Set();
|
|
141
|
+
const membershipParents = new Set();
|
|
142
|
+
const evidence = [];
|
|
143
|
+
const prepared = [];
|
|
144
|
+
const moveSources = [];
|
|
145
|
+
let repositoryBoundary = false;
|
|
146
|
+
for (const observation of observations) {
|
|
147
|
+
const paths = observation.suspicion.paths;
|
|
148
|
+
if (paths.length === 0)
|
|
149
|
+
continue;
|
|
150
|
+
if (paths.some((path) => path === ".amalgmignore"
|
|
151
|
+
|| path === ".git" || path.startsWith(".git/") || path.includes("/.git/")
|
|
152
|
+
|| path.endsWith("/.git"))) {
|
|
153
|
+
return { handled: false, evidence: [] };
|
|
154
|
+
}
|
|
155
|
+
const root = this.rootAtDirectory.get(observation.directory);
|
|
156
|
+
if (!root)
|
|
157
|
+
return { handled: false, evidence: [] };
|
|
158
|
+
const started = performance.now();
|
|
159
|
+
const scopedPaths = paths.filter((path) => !paths.some((candidate) => candidate !== path && path.startsWith(`${candidate}/`)));
|
|
160
|
+
const policy = resolve(observation.directory) === this.userRoot
|
|
161
|
+
? this.userPolicy
|
|
162
|
+
: () => true;
|
|
163
|
+
const entries = scopedPaths.filter(policy).map((path) => {
|
|
164
|
+
const exact = this.rowAtPath.get(root.rootUUID, path);
|
|
165
|
+
const absolutePath = join(observation.directory, ...path.split("/"));
|
|
166
|
+
const stats = tryLstat(absolutePath);
|
|
167
|
+
if (!stats && exact) {
|
|
168
|
+
if (this.enclosedByRepository(exact)) {
|
|
169
|
+
repositoryBoundary = true;
|
|
170
|
+
}
|
|
171
|
+
moveSources.push(absolutePath);
|
|
172
|
+
}
|
|
173
|
+
return { path, absolutePath, exact, stats };
|
|
174
|
+
});
|
|
175
|
+
if (repositoryBoundary)
|
|
176
|
+
return { handled: false, evidence: [] };
|
|
177
|
+
prepared.push({ observation, root, paths, policy, started, entries });
|
|
178
|
+
}
|
|
179
|
+
for (const item of prepared) {
|
|
180
|
+
const { observation, root, paths, policy, started, entries } = item;
|
|
181
|
+
let metadataReads = entries.length;
|
|
182
|
+
let contentReads = 0;
|
|
183
|
+
let journalBytes = 0;
|
|
184
|
+
const beforeCount = captured.length;
|
|
185
|
+
for (const { path, absolutePath, exact, stats } of entries) {
|
|
186
|
+
if (!stats) {
|
|
187
|
+
if (!exact)
|
|
188
|
+
continue;
|
|
189
|
+
const removals = ["workspace", "folder"].includes(exact.type)
|
|
190
|
+
? this.materializedUnderPath.all(root.rootUUID, path, path, path)
|
|
191
|
+
: [{ uuid: exact.uuid }];
|
|
192
|
+
for (const row of removals)
|
|
193
|
+
materializedRemovals.add(row.uuid);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const portableParent = posix.dirname(path);
|
|
197
|
+
const parentPath = portableParent === "." ? "" : portableParent;
|
|
198
|
+
const parent = this.rowAtPath.get(root.rootUUID, parentPath);
|
|
199
|
+
if (!parent || !["workspace", "folder"].includes(parent.type)
|
|
200
|
+
|| this.enclosedByRepository(parent)) {
|
|
201
|
+
return { handled: false, evidence: [] };
|
|
202
|
+
}
|
|
203
|
+
let scope;
|
|
204
|
+
if (stats.isFile()) {
|
|
205
|
+
if (exact && !["file.text", "file.binary"].includes(exact.type)) {
|
|
206
|
+
return { handled: false, evidence: [] };
|
|
207
|
+
}
|
|
208
|
+
const file = await this.captureFile({
|
|
209
|
+
root, path, absolutePath, stats, parent, exact, moveSources,
|
|
210
|
+
});
|
|
211
|
+
scope = {
|
|
212
|
+
items: file.item ? [file.item] : [],
|
|
213
|
+
metadataReads: 1,
|
|
214
|
+
contentReads: file.contentReads,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
else if (stats.isSymbolicLink()) {
|
|
218
|
+
if (exact?.type === "reference"
|
|
219
|
+
|| (resolve(observation.directory) === this.userRoot
|
|
220
|
+
&& (path === "workspaces" || path.startsWith("workspaces/")))) {
|
|
221
|
+
return { handled: false, evidence: [] };
|
|
222
|
+
}
|
|
223
|
+
const link = this.captureLink({
|
|
224
|
+
root, path, absolutePath, stats, parent, exact, moveSources,
|
|
225
|
+
});
|
|
226
|
+
if (!link)
|
|
227
|
+
return { handled: false, evidence: [] };
|
|
228
|
+
scope = {
|
|
229
|
+
items: link.item ? [link.item] : [],
|
|
230
|
+
metadataReads: 1,
|
|
231
|
+
contentReads: link.contentReads,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
else if (stats.isDirectory() && !stats.isSymbolicLink()) {
|
|
235
|
+
const directory = await this.captureDirectory({
|
|
236
|
+
root,
|
|
237
|
+
path,
|
|
238
|
+
absolutePath,
|
|
239
|
+
stats,
|
|
240
|
+
parent,
|
|
241
|
+
policy,
|
|
242
|
+
moveSources,
|
|
243
|
+
});
|
|
244
|
+
if (!directory)
|
|
245
|
+
return { handled: false, evidence: [] };
|
|
246
|
+
scope = directory;
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
return { handled: false, evidence: [] };
|
|
250
|
+
}
|
|
251
|
+
metadataReads += scope.metadataReads - 1;
|
|
252
|
+
contentReads += scope.contentReads;
|
|
253
|
+
for (const item of scope.items) {
|
|
254
|
+
if (item.proposal)
|
|
255
|
+
journalBytes += Buffer.byteLength(journalJson({ proposal: item.proposal }));
|
|
256
|
+
captured.push(item);
|
|
257
|
+
if (item.base === null
|
|
258
|
+
|| item.base.parentUUID !== item.result.parentUUID
|
|
259
|
+
|| item.base.name !== item.result.name) {
|
|
260
|
+
if (item.base?.parentUUID)
|
|
261
|
+
membershipParents.add(item.base.parentUUID);
|
|
262
|
+
if (item.result.parentUUID)
|
|
263
|
+
membershipParents.add(item.result.parentUUID);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
evidence.push({
|
|
268
|
+
directory: observation.directory,
|
|
269
|
+
paths: paths.length,
|
|
270
|
+
metadataReads,
|
|
271
|
+
contentReads,
|
|
272
|
+
plannedRows: captured.length - beforeCount,
|
|
273
|
+
records: captured.slice(beforeCount).filter(({ proposal }) => proposal !== null).length,
|
|
274
|
+
journalBytes,
|
|
275
|
+
durationMs: performance.now() - started,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
this.database.transaction(() => {
|
|
279
|
+
for (const uuid of materializedRemovals)
|
|
280
|
+
this.removeEntity.run(uuid);
|
|
281
|
+
for (const item of captured) {
|
|
282
|
+
this.writeEntity.run(...rowValues(item.result));
|
|
283
|
+
this.writeNotebook.run(...rowValues(item.result));
|
|
284
|
+
if (!item.proposal)
|
|
285
|
+
continue;
|
|
286
|
+
const mutationId = randomUUID();
|
|
287
|
+
this.appendMutation.run(mutationId, item.proposal.resourceId, journalJson({ proposal: item.proposal }), new Date().toISOString());
|
|
288
|
+
}
|
|
289
|
+
for (const parentUUID of membershipParents) {
|
|
290
|
+
const base = this.rowAtUuid.get(parentUUID);
|
|
291
|
+
if (!base || !["workspace", "folder"].includes(base.type))
|
|
292
|
+
continue;
|
|
293
|
+
const children = this.notebookChildren.all(parentUUID);
|
|
294
|
+
const payloadVersion = membershipHash(children, sha256Hex);
|
|
295
|
+
const version = canonicalVersion({
|
|
296
|
+
type: base.type,
|
|
297
|
+
parentUUID: base.parentUUID,
|
|
298
|
+
name: base.name,
|
|
299
|
+
status: base.status,
|
|
300
|
+
payloadVersion,
|
|
301
|
+
}, sha256Hex);
|
|
302
|
+
if (version === base.version)
|
|
303
|
+
continue;
|
|
304
|
+
const result = { ...base, payloadVersion, version };
|
|
305
|
+
this.writeEntity.run(...rowValues(result));
|
|
306
|
+
this.writeNotebook.run(...rowValues(result));
|
|
307
|
+
const proposal = detectedChangeProposal({
|
|
308
|
+
base: portable(base),
|
|
309
|
+
result: portable(result),
|
|
310
|
+
replay: { kind: "structure" },
|
|
311
|
+
});
|
|
312
|
+
if (!proposal)
|
|
313
|
+
continue;
|
|
314
|
+
this.appendMutation.run(randomUUID(), proposal.resourceId, journalJson({ proposal }), new Date().toISOString());
|
|
315
|
+
}
|
|
316
|
+
})();
|
|
317
|
+
return { handled: true, evidence };
|
|
318
|
+
}
|
|
319
|
+
async captureFile(input) {
|
|
320
|
+
const { root, path, absolutePath, stats, parent, exact, moveSources } = input;
|
|
321
|
+
const base = exact ?? this.uniqueMovedBase(stats.dev, stats.ino, absolutePath, moveSources);
|
|
322
|
+
const fingerprintTrusted = base !== null
|
|
323
|
+
&& base.relativePath === path
|
|
324
|
+
&& this.materializedByUuid.get(base.uuid) !== undefined
|
|
325
|
+
&& sameFingerprint(base, stats)
|
|
326
|
+
&& base.contentVerifiedAtMs !== null
|
|
327
|
+
&& stats.mtimeMs < base.contentVerifiedAtMs;
|
|
328
|
+
if (fingerprintTrusted)
|
|
329
|
+
return { item: null, contentReads: 0 };
|
|
330
|
+
const canReuseMovedContent = base !== null
|
|
331
|
+
&& base.relativePath !== path
|
|
332
|
+
&& base.deviceNumber === stats.dev
|
|
333
|
+
&& base.inode === stats.ino
|
|
334
|
+
&& base.byteSize === stats.size
|
|
335
|
+
&& base.modifiedTimeMs === stats.mtimeMs
|
|
336
|
+
&& base.filesystemMode === stats.mode
|
|
337
|
+
&& base.payloadVersion !== null;
|
|
338
|
+
const content = canReuseMovedContent ? null : await captureContentFile(absolutePath, stats, this.cacheDir);
|
|
339
|
+
const type = content ? classifyFile({ binary: content.binary }) : base?.type;
|
|
340
|
+
const payloadVersion = content?.contentHash ?? base?.payloadVersion ?? null;
|
|
341
|
+
if (!type || !["file.text", "file.binary"].includes(type) || !payloadVersion) {
|
|
342
|
+
throw new Error(`named Detect could not capture ${path}`);
|
|
343
|
+
}
|
|
344
|
+
const result = {
|
|
345
|
+
resourceId: root.resourceId,
|
|
346
|
+
rootUUID: root.rootUUID,
|
|
347
|
+
uuid: base?.uuid ?? randomUUID(),
|
|
348
|
+
type,
|
|
349
|
+
parentUUID: parent.uuid,
|
|
350
|
+
name: posix.basename(path),
|
|
351
|
+
status: "active",
|
|
352
|
+
version: canonicalVersion({
|
|
353
|
+
type,
|
|
354
|
+
parentUUID: parent.uuid,
|
|
355
|
+
name: posix.basename(path),
|
|
356
|
+
status: "active",
|
|
357
|
+
payloadVersion,
|
|
358
|
+
}, sha256Hex),
|
|
359
|
+
payloadVersion,
|
|
360
|
+
transportVersion: null,
|
|
361
|
+
relativePath: path,
|
|
362
|
+
absolutePath,
|
|
363
|
+
deviceNumber: stats.dev,
|
|
364
|
+
inode: stats.ino,
|
|
365
|
+
byteSize: stats.size,
|
|
366
|
+
modifiedTimeMs: stats.mtimeMs,
|
|
367
|
+
changedTimeMs: stats.ctimeMs,
|
|
368
|
+
filesystemMode: stats.mode,
|
|
369
|
+
contentVerifiedAtMs: content ? Date.now() : base?.contentVerifiedAtMs ?? null,
|
|
370
|
+
};
|
|
371
|
+
const replay = base !== null
|
|
372
|
+
&& base.type === result.type
|
|
373
|
+
&& base.payloadVersion === result.payloadVersion
|
|
374
|
+
&& base.transportVersion === result.transportVersion
|
|
375
|
+
? { kind: "structure" }
|
|
376
|
+
: this.replayFor(base, result);
|
|
377
|
+
return {
|
|
378
|
+
item: {
|
|
379
|
+
base,
|
|
380
|
+
result,
|
|
381
|
+
proposal: detectedChangeProposal({
|
|
382
|
+
base: base ? portable(base) : null,
|
|
383
|
+
result: portable(result),
|
|
384
|
+
replay,
|
|
385
|
+
}),
|
|
386
|
+
},
|
|
387
|
+
contentReads: content ? 1 : 0,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
async captureDirectory(input) {
|
|
391
|
+
if (existsSync(join(input.absolutePath, ".git")))
|
|
392
|
+
return null;
|
|
393
|
+
const exact = this.rowAtPath.get(input.root.rootUUID, input.path);
|
|
394
|
+
if (exact && !["workspace", "folder"].includes(exact.type))
|
|
395
|
+
return null;
|
|
396
|
+
const base = exact ?? this.uniqueMovedBase(input.stats.dev, input.stats.ino, input.absolutePath, input.moveSources);
|
|
397
|
+
if (base && !["workspace", "folder"].includes(base.type))
|
|
398
|
+
return null;
|
|
399
|
+
const uuid = base?.uuid ?? randomUUID();
|
|
400
|
+
const childItems = [];
|
|
401
|
+
let metadataReads = 1;
|
|
402
|
+
let contentReads = 0;
|
|
403
|
+
const children = [];
|
|
404
|
+
for (const child of readdirSync(input.absolutePath, { withFileTypes: true })
|
|
405
|
+
.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
406
|
+
const path = `${input.path}/${child.name}`;
|
|
407
|
+
if (!input.policy(path))
|
|
408
|
+
continue;
|
|
409
|
+
const absolutePath = join(input.absolutePath, child.name);
|
|
410
|
+
const stats = lstatSync(absolutePath);
|
|
411
|
+
metadataReads += 1;
|
|
412
|
+
const parentRow = {
|
|
413
|
+
resourceId: input.root.resourceId,
|
|
414
|
+
rootUUID: input.root.rootUUID,
|
|
415
|
+
uuid,
|
|
416
|
+
type: "folder",
|
|
417
|
+
parentUUID: input.parent.uuid,
|
|
418
|
+
name: posix.basename(input.path),
|
|
419
|
+
status: "active",
|
|
420
|
+
version: "0".repeat(64),
|
|
421
|
+
payloadVersion: null,
|
|
422
|
+
transportVersion: null,
|
|
423
|
+
relativePath: input.path,
|
|
424
|
+
absolutePath: input.absolutePath,
|
|
425
|
+
deviceNumber: input.stats.dev,
|
|
426
|
+
inode: input.stats.ino,
|
|
427
|
+
byteSize: input.stats.size,
|
|
428
|
+
modifiedTimeMs: input.stats.mtimeMs,
|
|
429
|
+
changedTimeMs: input.stats.ctimeMs,
|
|
430
|
+
filesystemMode: input.stats.mode,
|
|
431
|
+
contentVerifiedAtMs: null,
|
|
432
|
+
};
|
|
433
|
+
if (stats.isSymbolicLink()) {
|
|
434
|
+
const link = this.captureLink({
|
|
435
|
+
root: input.root,
|
|
436
|
+
path,
|
|
437
|
+
absolutePath,
|
|
438
|
+
stats,
|
|
439
|
+
parent: parentRow,
|
|
440
|
+
exact: this.rowAtPath.get(input.root.rootUUID, path),
|
|
441
|
+
moveSources: input.moveSources,
|
|
442
|
+
});
|
|
443
|
+
if (!link)
|
|
444
|
+
return null;
|
|
445
|
+
const identity = link.item?.result
|
|
446
|
+
?? this.rowAtPath.get(input.root.rootUUID, path);
|
|
447
|
+
if (!identity)
|
|
448
|
+
throw new Error(`captured link ${path} has no identity`);
|
|
449
|
+
children.push({ uuid: identity.uuid, name: child.name });
|
|
450
|
+
if (link.item)
|
|
451
|
+
childItems.push(link.item);
|
|
452
|
+
contentReads += link.contentReads;
|
|
453
|
+
}
|
|
454
|
+
else if (stats.isDirectory()) {
|
|
455
|
+
const nested = await this.captureDirectory({
|
|
456
|
+
root: input.root,
|
|
457
|
+
path,
|
|
458
|
+
absolutePath,
|
|
459
|
+
stats,
|
|
460
|
+
parent: parentRow,
|
|
461
|
+
policy: input.policy,
|
|
462
|
+
moveSources: input.moveSources,
|
|
463
|
+
});
|
|
464
|
+
if (!nested)
|
|
465
|
+
return null;
|
|
466
|
+
const nestedRoot = nested.items[0];
|
|
467
|
+
if (!nestedRoot)
|
|
468
|
+
throw new Error(`captured directory ${path} has no root`);
|
|
469
|
+
children.push({ uuid: nestedRoot.result.uuid, name: child.name });
|
|
470
|
+
childItems.push(...nested.items);
|
|
471
|
+
metadataReads += nested.metadataReads - 1;
|
|
472
|
+
contentReads += nested.contentReads;
|
|
473
|
+
}
|
|
474
|
+
else if (stats.isFile()) {
|
|
475
|
+
const file = await this.captureFile({
|
|
476
|
+
root: input.root,
|
|
477
|
+
path,
|
|
478
|
+
absolutePath,
|
|
479
|
+
stats,
|
|
480
|
+
parent: parentRow,
|
|
481
|
+
exact: this.rowAtPath.get(input.root.rootUUID, path),
|
|
482
|
+
moveSources: input.moveSources,
|
|
483
|
+
});
|
|
484
|
+
const identity = file.item?.result
|
|
485
|
+
?? this.rowAtPath.get(input.root.rootUUID, path);
|
|
486
|
+
if (!identity)
|
|
487
|
+
throw new Error(`captured file ${path} has no identity`);
|
|
488
|
+
children.push({ uuid: identity.uuid, name: child.name });
|
|
489
|
+
if (file.item)
|
|
490
|
+
childItems.push(file.item);
|
|
491
|
+
contentReads += file.contentReads;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const result = {
|
|
495
|
+
resourceId: input.root.resourceId,
|
|
496
|
+
rootUUID: input.root.rootUUID,
|
|
497
|
+
uuid,
|
|
498
|
+
type: "folder",
|
|
499
|
+
parentUUID: input.parent.uuid,
|
|
500
|
+
name: posix.basename(input.path),
|
|
501
|
+
status: "active",
|
|
502
|
+
version: canonicalVersion({
|
|
503
|
+
type: "folder",
|
|
504
|
+
parentUUID: input.parent.uuid,
|
|
505
|
+
name: posix.basename(input.path),
|
|
506
|
+
status: "active",
|
|
507
|
+
payloadVersion: membershipHash(children, sha256Hex),
|
|
508
|
+
}, sha256Hex),
|
|
509
|
+
payloadVersion: null,
|
|
510
|
+
transportVersion: null,
|
|
511
|
+
relativePath: input.path,
|
|
512
|
+
absolutePath: input.absolutePath,
|
|
513
|
+
deviceNumber: input.stats.dev,
|
|
514
|
+
inode: input.stats.ino,
|
|
515
|
+
byteSize: input.stats.size,
|
|
516
|
+
modifiedTimeMs: input.stats.mtimeMs,
|
|
517
|
+
changedTimeMs: input.stats.ctimeMs,
|
|
518
|
+
filesystemMode: input.stats.mode,
|
|
519
|
+
contentVerifiedAtMs: null,
|
|
520
|
+
};
|
|
521
|
+
const proposal = detectedChangeProposal({
|
|
522
|
+
base: base ? portable(base) : null,
|
|
523
|
+
result: portable(result),
|
|
524
|
+
replay: { kind: "structure" },
|
|
525
|
+
});
|
|
526
|
+
return {
|
|
527
|
+
items: [{ base, result, proposal }, ...childItems],
|
|
528
|
+
metadataReads,
|
|
529
|
+
contentReads,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
captureLink(input) {
|
|
533
|
+
const { root, path, absolutePath, stats, parent, exact, moveSources } = input;
|
|
534
|
+
const base = exact ?? this.uniqueMovedBase(stats.dev, stats.ino, absolutePath, moveSources);
|
|
535
|
+
if (base && base.type !== "link")
|
|
536
|
+
return null;
|
|
537
|
+
const fingerprintTrusted = base !== null
|
|
538
|
+
&& base.relativePath === path
|
|
539
|
+
&& this.materializedByUuid.get(base.uuid) !== undefined
|
|
540
|
+
&& sameFingerprint(base, stats)
|
|
541
|
+
&& base.contentVerifiedAtMs !== null
|
|
542
|
+
&& stats.mtimeMs < base.contentVerifiedAtMs;
|
|
543
|
+
if (fingerprintTrusted)
|
|
544
|
+
return { item: null, contentReads: 0 };
|
|
545
|
+
const canReuseMovedTarget = base !== null
|
|
546
|
+
&& base.relativePath !== path
|
|
547
|
+
&& base.deviceNumber === stats.dev
|
|
548
|
+
&& base.inode === stats.ino
|
|
549
|
+
&& base.byteSize === stats.size
|
|
550
|
+
&& base.modifiedTimeMs === stats.mtimeMs
|
|
551
|
+
&& base.filesystemMode === stats.mode
|
|
552
|
+
&& base.payloadVersion !== null;
|
|
553
|
+
const target = canReuseMovedTarget ? null : readlinkSync(absolutePath);
|
|
554
|
+
const payloadVersion = target === null
|
|
555
|
+
? base?.payloadVersion ?? null
|
|
556
|
+
: sha256Hex(Buffer.from(target, "utf8"));
|
|
557
|
+
if (!payloadVersion)
|
|
558
|
+
return null;
|
|
559
|
+
const result = {
|
|
560
|
+
resourceId: root.resourceId,
|
|
561
|
+
rootUUID: root.rootUUID,
|
|
562
|
+
uuid: base?.uuid ?? randomUUID(),
|
|
563
|
+
type: "link",
|
|
564
|
+
parentUUID: parent.uuid,
|
|
565
|
+
name: posix.basename(path),
|
|
566
|
+
status: "active",
|
|
567
|
+
version: canonicalVersion({
|
|
568
|
+
type: "link",
|
|
569
|
+
parentUUID: parent.uuid,
|
|
570
|
+
name: posix.basename(path),
|
|
571
|
+
status: "active",
|
|
572
|
+
payloadVersion,
|
|
573
|
+
}, sha256Hex),
|
|
574
|
+
payloadVersion,
|
|
575
|
+
transportVersion: null,
|
|
576
|
+
relativePath: path,
|
|
577
|
+
absolutePath,
|
|
578
|
+
deviceNumber: stats.dev,
|
|
579
|
+
inode: stats.ino,
|
|
580
|
+
byteSize: stats.size,
|
|
581
|
+
modifiedTimeMs: stats.mtimeMs,
|
|
582
|
+
changedTimeMs: stats.ctimeMs,
|
|
583
|
+
filesystemMode: stats.mode,
|
|
584
|
+
contentVerifiedAtMs: target === null ? base?.contentVerifiedAtMs ?? null : Date.now(),
|
|
585
|
+
};
|
|
586
|
+
const replay = base !== null && base.payloadVersion === result.payloadVersion
|
|
587
|
+
? { kind: "structure" }
|
|
588
|
+
: target === null ? { kind: "structure" } : {
|
|
589
|
+
kind: "link",
|
|
590
|
+
resultPayloadVersion: payloadVersion,
|
|
591
|
+
target,
|
|
592
|
+
};
|
|
593
|
+
return {
|
|
594
|
+
item: {
|
|
595
|
+
base,
|
|
596
|
+
result,
|
|
597
|
+
proposal: detectedChangeProposal({
|
|
598
|
+
base: base ? portable(base) : null,
|
|
599
|
+
result: portable(result),
|
|
600
|
+
replay,
|
|
601
|
+
}),
|
|
602
|
+
},
|
|
603
|
+
contentReads: target === null ? 0 : 1,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
enclosedByRepository(row) {
|
|
607
|
+
let cursor = row;
|
|
608
|
+
const seen = new Set();
|
|
609
|
+
while (cursor.parentUUID !== null) {
|
|
610
|
+
if (seen.has(cursor.uuid))
|
|
611
|
+
throw new Error("Detect notebook has a parent cycle");
|
|
612
|
+
seen.add(cursor.uuid);
|
|
613
|
+
const parent = this.rowAtUuid.get(cursor.parentUUID);
|
|
614
|
+
if (!parent)
|
|
615
|
+
throw new Error(`Detect notebook has no parent ${cursor.parentUUID}`);
|
|
616
|
+
if (parent.type === "repo.git")
|
|
617
|
+
return true;
|
|
618
|
+
cursor = parent;
|
|
619
|
+
}
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
readUserPolicy() {
|
|
623
|
+
const ignore = join(this.userRoot, ".amalgmignore");
|
|
624
|
+
return createUserGroundEnrollmentPolicy(existsSync(ignore) ? readFileSync(ignore, "utf8") : "");
|
|
625
|
+
}
|
|
626
|
+
uniqueMovedBase(deviceNumber, inode, absolutePath, moveSources) {
|
|
627
|
+
const candidates = this.rowsAtPhysicalIdentity.all(deviceNumber, inode)
|
|
628
|
+
.filter((row) => row.absolutePath !== absolutePath
|
|
629
|
+
&& moveSources.some((source) => row.absolutePath === source || row.absolutePath.startsWith(`${source}${sep}`))
|
|
630
|
+
&& !existsSync(row.absolutePath));
|
|
631
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
632
|
+
}
|
|
633
|
+
replayFor(base, result) {
|
|
634
|
+
if (result.type === "file.text") {
|
|
635
|
+
const resultBytes = readFileSync(join(this.cacheDir, `${result.payloadVersion}.bin`));
|
|
636
|
+
const baseFile = base?.payloadVersion ? join(this.cacheDir, `${base.payloadVersion}.bin`) : null;
|
|
637
|
+
const baseBytes = baseFile && existsSync(baseFile) ? readFileSync(baseFile) : null;
|
|
638
|
+
return exactTextReplay({
|
|
639
|
+
entityId: result.uuid,
|
|
640
|
+
basePayloadVersion: base?.payloadVersion ?? null,
|
|
641
|
+
baseBytes,
|
|
642
|
+
resultPayloadVersion: result.payloadVersion,
|
|
643
|
+
resultBytes,
|
|
644
|
+
sha256Hex,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
const manifestFile = join(this.cacheDir, "manifests", `${result.payloadVersion}.json`);
|
|
648
|
+
const checked = checkContentManifest(JSON.parse(readFileSync(manifestFile, "utf8")), result.payloadVersion);
|
|
649
|
+
if (!checked.ok)
|
|
650
|
+
throw new Error(checked.error);
|
|
651
|
+
return {
|
|
652
|
+
kind: "file.binary",
|
|
653
|
+
resultPayloadVersion: result.payloadVersion,
|
|
654
|
+
manifest: checked.value,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
function tryLstat(path) {
|
|
659
|
+
try {
|
|
660
|
+
return lstatSync(path);
|
|
661
|
+
}
|
|
662
|
+
catch (error) {
|
|
663
|
+
const code = error.code;
|
|
664
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
665
|
+
return null;
|
|
666
|
+
throw error;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
//# sourceMappingURL=runtime.js.map
|