@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.
@@ -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