@amalgm/shell 0.1.44 → 0.1.46
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/content-cache-host.d.ts +3 -0
- package/dist/content-cache-host.js +21 -3
- package/dist/content-cache-host.js.map +1 -1
- 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 +67 -0
- package/dist/detection/runtime.js +919 -0
- package/dist/detection/runtime.js.map +1 -0
- package/dist/user-ground-host.d.ts +12 -2
- package/dist/user-ground-host.js +428 -169
- package/dist/user-ground-host.js.map +1 -1
- package/package.json +3 -3
- package/dist/detection-host.js.map +0 -1
package/dist/user-ground-host.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync,
|
|
2
|
+
import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, } from "node:fs";
|
|
3
3
|
import { open } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { buildUserHomeManifest, buildUserManifest, liveMachineStateDir, scopedAmalgmDir, shippedUserHomeDeclaration, } from "@amalgm/core/identity";
|
|
6
|
-
import { CHUNK_BYTES, CONTENT_CONTRACT, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, download as downloadArtifact,
|
|
6
|
+
import { CHUNK_BYTES, CONTENT_CONTRACT, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, download as downloadArtifact, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
|
|
7
7
|
import Database from "better-sqlite3";
|
|
8
8
|
import { atomicCopy, atomicWrite, ensurePrivateDir } from "./filesystem.js";
|
|
9
|
-
import { ContentCacheDownload, captureContentFile, hashContentFile, } from "./content-cache-host.js";
|
|
9
|
+
import { ContentCacheDownload, captureContentFile, hashContentFile, sealCapturedArtifact, } from "./content-cache-host.js";
|
|
10
10
|
import { decodeContentWireBytes, encodeContentWireBytes } from "./content-wire-codec.js";
|
|
11
|
-
import { planGroundDetection, reconcileGroundUUIDs, } from "./detection
|
|
11
|
+
import { exactTextReplay, planGroundDetection, reconcileGroundUUIDs, } from "./detection/portable.js";
|
|
12
|
+
import { NamedDetectRuntime } from "./detection/runtime.js";
|
|
13
|
+
import { decodeMutationOperation, encodeMutationOperation, } from "./detection/journal-codec.js";
|
|
12
14
|
import { WORKSPACE_UUID as UUID, createFilesRegisterPorts, ensureWorkspaceBinding, ensureWorkspaceReference, pathExists, pathWithin, referenceWorkspaceId, selectKnownRegistrationId, workspaceBindingDir, } from "./files-register-host.js";
|
|
13
15
|
import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, hasGitMarker, } from "./git-repository-host.js";
|
|
14
16
|
import { inspectGitRegistration, } from "./git-registration-host.js";
|
|
@@ -22,6 +24,9 @@ const PORTABLE_FIELDS = [
|
|
|
22
24
|
const SMALL_CONTENT_UPLOAD_CONCURRENCY = 16;
|
|
23
25
|
const FILE_CONTENT_DOWNLOAD_CONCURRENCY = 4;
|
|
24
26
|
const FILE_BATCH_DOWNLOAD_CONCURRENCY = 2;
|
|
27
|
+
const DETECT_QUIET_MS = 12;
|
|
28
|
+
const DETECT_MAX_DEFERRAL_MS = 75;
|
|
29
|
+
const DETECT_RETRY_MS = 250;
|
|
25
30
|
const GROUND_ROW_COLUMNS = [
|
|
26
31
|
"uuid", "resource_id", "root_uuid", "type", "parent_uuid", "name", "status", "version",
|
|
27
32
|
"payload_version", "transport_version", "relative_path", "absolute_path", "device_number",
|
|
@@ -36,10 +41,13 @@ export class UserGroundHost {
|
|
|
36
41
|
watchHost;
|
|
37
42
|
activeIdentity = null;
|
|
38
43
|
rescanTimer = null;
|
|
44
|
+
rescanGenerationStartedAt = null;
|
|
39
45
|
watchDirty = false;
|
|
40
46
|
cloudState = null;
|
|
41
47
|
converged = false;
|
|
42
48
|
syncing = null;
|
|
49
|
+
namedDetect = null;
|
|
50
|
+
coldOperation = false;
|
|
43
51
|
closing = false;
|
|
44
52
|
port;
|
|
45
53
|
constructor(options) {
|
|
@@ -65,6 +73,7 @@ export class UserGroundHost {
|
|
|
65
73
|
local,
|
|
66
74
|
});
|
|
67
75
|
await this.resumeWorkspaceAdds(this.activeIdentity);
|
|
76
|
+
this.namedDetect ??= new NamedDetectRuntime(this.databasePath(this.activeIdentity), this.cacheDir(this.activeIdentity), this.userRoot(this.activeIdentity));
|
|
68
77
|
const watchHealth = this.watchHost.health();
|
|
69
78
|
if (watchHealth.state !== "healthy") {
|
|
70
79
|
throw new Error(`Watch coverage is degraded: ${watchHealth.reason ?? "unknown failure"}`);
|
|
@@ -234,6 +243,11 @@ export class UserGroundHost {
|
|
|
234
243
|
if (!identity || !this.converged || !this.cloudState) {
|
|
235
244
|
throw new Error("Files register requires a converged authenticated machine");
|
|
236
245
|
}
|
|
246
|
+
this.coldOperation = true;
|
|
247
|
+
if (this.rescanTimer)
|
|
248
|
+
clearTimeout(this.rescanTimer);
|
|
249
|
+
this.rescanTimer = null;
|
|
250
|
+
this.rescanGenerationStartedAt = null;
|
|
237
251
|
let watch;
|
|
238
252
|
try {
|
|
239
253
|
this.ensureWatchers(identity);
|
|
@@ -241,11 +255,13 @@ export class UserGroundHost {
|
|
|
241
255
|
assertHealthyWatch(watch, workspaceId);
|
|
242
256
|
}
|
|
243
257
|
catch (error) {
|
|
258
|
+
this.coldOperation = false;
|
|
244
259
|
throw stageError("Watch coverage", error);
|
|
245
260
|
}
|
|
246
261
|
let rows = readRows(this.databasePath(identity));
|
|
247
262
|
if (rows.some((row) => row.uuid === workspaceId)
|
|
248
263
|
&& this.cloudState.records.some((record) => record.uuid === workspaceId)) {
|
|
264
|
+
this.coldOperation = false;
|
|
249
265
|
return {
|
|
250
266
|
registered: true,
|
|
251
267
|
entityCount: descendantRows(rows, workspaceId).length,
|
|
@@ -253,20 +269,35 @@ export class UserGroundHost {
|
|
|
253
269
|
watch,
|
|
254
270
|
};
|
|
255
271
|
}
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
272
|
+
// Register is a cold declaration, not a filesystem edit. It performs one
|
|
273
|
+
// complete reconciliation and publishes one graph head; it must not mint
|
|
274
|
+
// a parallel stream of Detect records for the same initial contents.
|
|
275
|
+
const observations = this.watchHost.observations();
|
|
259
276
|
this.watchDirty = false;
|
|
260
277
|
try {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
this.
|
|
268
|
-
|
|
278
|
+
if (this.syncing)
|
|
279
|
+
await this.syncing;
|
|
280
|
+
const scanned = await reconcileGround({
|
|
281
|
+
identity,
|
|
282
|
+
userRoot: this.userRoot(identity),
|
|
283
|
+
database: this.databasePath(identity),
|
|
284
|
+
cacheDir: this.cacheDir(identity),
|
|
285
|
+
onScan: this.options.onDetectScan,
|
|
286
|
+
suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion])),
|
|
287
|
+
});
|
|
288
|
+
const retry = scanned.detection.find((plan) => plan.kind === "retry");
|
|
289
|
+
if (retry?.kind === "retry") {
|
|
290
|
+
throw new Error(retry.reasons.join("; "));
|
|
269
291
|
}
|
|
292
|
+
commitDetectedState(this.databasePath(identity), {
|
|
293
|
+
acceptedMaterializedRows: scanned.acceptedMaterializedRows,
|
|
294
|
+
materializedRemovals: scanned.materializedRemovals,
|
|
295
|
+
acceptedNotebookRows: scanned.acceptedNotebookRows,
|
|
296
|
+
notebookRemovals: scanned.notebookRemovals,
|
|
297
|
+
});
|
|
298
|
+
this.namedDetect?.refreshEnrollmentPolicy();
|
|
299
|
+
await this.publishMaterializedSnapshot(identity);
|
|
300
|
+
this.watchHost.settle(observations);
|
|
270
301
|
}
|
|
271
302
|
catch (error) {
|
|
272
303
|
const failedStage = filesPipelineStage(error);
|
|
@@ -275,6 +306,9 @@ export class UserGroundHost {
|
|
|
275
306
|
}
|
|
276
307
|
throw stageError("entity registration or cloud publication", error);
|
|
277
308
|
}
|
|
309
|
+
finally {
|
|
310
|
+
this.coldOperation = false;
|
|
311
|
+
}
|
|
278
312
|
try {
|
|
279
313
|
this.ensureWatchers(identity);
|
|
280
314
|
watch = this.watchHost.evidence();
|
|
@@ -348,7 +382,10 @@ export class UserGroundHost {
|
|
|
348
382
|
if (this.rescanTimer)
|
|
349
383
|
clearTimeout(this.rescanTimer);
|
|
350
384
|
this.rescanTimer = null;
|
|
385
|
+
this.rescanGenerationStartedAt = null;
|
|
351
386
|
this.watchHost.close({ catchUp: false });
|
|
387
|
+
this.namedDetect?.close();
|
|
388
|
+
this.namedDetect = null;
|
|
352
389
|
await this.wire.close();
|
|
353
390
|
return;
|
|
354
391
|
}
|
|
@@ -360,12 +397,15 @@ export class UserGroundHost {
|
|
|
360
397
|
if (this.rescanTimer)
|
|
361
398
|
clearTimeout(this.rescanTimer);
|
|
362
399
|
this.rescanTimer = null;
|
|
400
|
+
this.rescanGenerationStartedAt = null;
|
|
363
401
|
// Full suspicion already contains every callback that could still be
|
|
364
402
|
// queued. Retire physical coverage before the final observation so native
|
|
365
403
|
// backends cannot continuously widen a finite shutdown pass. Changes
|
|
366
404
|
// after this cutoff belong to the next startup blind interval.
|
|
367
405
|
this.watchHost.close();
|
|
368
406
|
await this.flushObservedChanges();
|
|
407
|
+
this.namedDetect?.close();
|
|
408
|
+
this.namedDetect = null;
|
|
369
409
|
await this.wire.close();
|
|
370
410
|
}
|
|
371
411
|
userRoot(identity) {
|
|
@@ -377,7 +417,7 @@ export class UserGroundHost {
|
|
|
377
417
|
cloudPort(identity) {
|
|
378
418
|
return {
|
|
379
419
|
lookup: async (resourceId) => {
|
|
380
|
-
await this.
|
|
420
|
+
await this.publishPendingSnapshotsBeforeLookup(identity, resourceId);
|
|
381
421
|
const frame = await this.wire.request({
|
|
382
422
|
type: "private.resource.lookup",
|
|
383
423
|
resource_id: resourceId,
|
|
@@ -420,7 +460,21 @@ export class UserGroundHost {
|
|
|
420
460
|
return null;
|
|
421
461
|
if (readRows(database).length === 0)
|
|
422
462
|
return null;
|
|
423
|
-
|
|
463
|
+
const local = localValue(identity, userRoot, database);
|
|
464
|
+
if (!hasDetectedChanges(database))
|
|
465
|
+
return local;
|
|
466
|
+
if (!this.cloudState) {
|
|
467
|
+
throw new Error("pending local mutations have no accepted cloud baseline");
|
|
468
|
+
}
|
|
469
|
+
// The official local projection remains the accepted cloud graph;
|
|
470
|
+
// Detect's newer materialization is represented exactly once by its
|
|
471
|
+
// durable journal records. Returning that baseline prevents login
|
|
472
|
+
// convergence from overwriting saved-local work before Send accepts it.
|
|
473
|
+
return {
|
|
474
|
+
...local,
|
|
475
|
+
records: this.cloudState.records,
|
|
476
|
+
pendingMutations: true,
|
|
477
|
+
};
|
|
424
478
|
},
|
|
425
479
|
initialize: async () => {
|
|
426
480
|
createDeclaredHome({
|
|
@@ -429,7 +483,7 @@ export class UserGroundHost {
|
|
|
429
483
|
declaration: this.options.declaration ?? shippedUserHomeDeclaration,
|
|
430
484
|
now: this.options.now?.() ?? new Date(),
|
|
431
485
|
});
|
|
432
|
-
await
|
|
486
|
+
await reconcileGround({
|
|
433
487
|
identity, userRoot, database, cacheDir,
|
|
434
488
|
onScan: this.options.onDetectScan,
|
|
435
489
|
});
|
|
@@ -683,17 +737,26 @@ export class UserGroundHost {
|
|
|
683
737
|
}
|
|
684
738
|
return health;
|
|
685
739
|
}
|
|
686
|
-
scheduleRescan() {
|
|
740
|
+
scheduleRescan(retryDelayMs) {
|
|
687
741
|
if (this.closing)
|
|
688
742
|
return;
|
|
689
743
|
this.watchDirty = true;
|
|
744
|
+
const now = performance.now();
|
|
745
|
+
this.rescanGenerationStartedAt ??= now;
|
|
746
|
+
const remaining = Math.max(0, DETECT_MAX_DEFERRAL_MS - (now - this.rescanGenerationStartedAt));
|
|
747
|
+
const delay = retryDelayMs ?? Math.min(DETECT_QUIET_MS, remaining);
|
|
690
748
|
if (this.rescanTimer)
|
|
691
749
|
clearTimeout(this.rescanTimer);
|
|
692
750
|
this.rescanTimer = setTimeout(() => {
|
|
693
751
|
this.rescanTimer = null;
|
|
752
|
+
this.rescanGenerationStartedAt = null;
|
|
694
753
|
const identity = this.activeIdentity;
|
|
695
754
|
if (!identity || this.closing)
|
|
696
755
|
return;
|
|
756
|
+
if (this.coldOperation) {
|
|
757
|
+
this.scheduleRescan();
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
697
760
|
void this.flushObservedChanges().then(() => {
|
|
698
761
|
if (this.closing)
|
|
699
762
|
return;
|
|
@@ -704,23 +767,55 @@ export class UserGroundHost {
|
|
|
704
767
|
if (this.watchDirty || this.watchHost.hasPending)
|
|
705
768
|
this.scheduleRescan();
|
|
706
769
|
}).catch(() => {
|
|
707
|
-
//
|
|
770
|
+
// Unsettled or unreadable truth keeps its Watch generation pending.
|
|
771
|
+
// Retry without requiring another user edit, but never hot-loop on a
|
|
772
|
+
// persistently unavailable path. A fresh ring replaces this timer
|
|
773
|
+
// with the ordinary low-latency settle window.
|
|
774
|
+
if (!this.closing)
|
|
775
|
+
this.scheduleRescan(DETECT_RETRY_MS);
|
|
708
776
|
});
|
|
709
|
-
},
|
|
777
|
+
}, delay);
|
|
710
778
|
this.rescanTimer.unref();
|
|
711
779
|
}
|
|
712
780
|
async syncLocalChanges(identity) {
|
|
713
|
-
|
|
714
|
-
const state = this.cloudState;
|
|
715
|
-
if (!state)
|
|
781
|
+
if (!this.cloudState)
|
|
716
782
|
throw new Error("cloud state is unavailable for user-ground Watch");
|
|
717
783
|
const observations = this.watchHost.observations();
|
|
718
|
-
|
|
784
|
+
if (this.namedDetect) {
|
|
785
|
+
// The named lane is an optimization over the same settled evidence.
|
|
786
|
+
// Any proof failure occurs before its SQLite transaction and widens to
|
|
787
|
+
// reconciliation; it must never poison a Watch generation in a timer
|
|
788
|
+
// rejection that only another filesystem event can revive.
|
|
789
|
+
const named = await this.namedDetect.detect(observations).catch(() => null);
|
|
790
|
+
if (named?.handled) {
|
|
791
|
+
for (const evidence of named.evidence) {
|
|
792
|
+
const observation = observations.find((candidate) => candidate.directory === evidence.directory);
|
|
793
|
+
this.options.onDetectScan?.({
|
|
794
|
+
rootId: observation?.rootId ?? evidence.directory,
|
|
795
|
+
directory: evidence.directory,
|
|
796
|
+
scope: "paths",
|
|
797
|
+
entries: evidence.metadataReads,
|
|
798
|
+
metadataReused: evidence.paths - evidence.contentReads,
|
|
799
|
+
contentReads: evidence.contentReads,
|
|
800
|
+
gitInspections: evidence.gitInspections,
|
|
801
|
+
repositoryCaptures: evidence.repositoryCaptures,
|
|
802
|
+
durationMs: evidence.durationMs,
|
|
803
|
+
plannedRows: evidence.plannedRows,
|
|
804
|
+
records: evidence.records,
|
|
805
|
+
journalBytes: evidence.journalBytes,
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
this.watchHost.settle(observations);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
719
812
|
let detection;
|
|
813
|
+
let acceptedMaterializedRows;
|
|
814
|
+
let materializedRemovals;
|
|
720
815
|
let acceptedNotebookRows;
|
|
721
816
|
let notebookRemovals;
|
|
722
817
|
try {
|
|
723
|
-
const scanned = await
|
|
818
|
+
const scanned = await reconcileGround({
|
|
724
819
|
identity,
|
|
725
820
|
userRoot: this.userRoot(identity),
|
|
726
821
|
database: this.databasePath(identity),
|
|
@@ -728,8 +823,9 @@ export class UserGroundHost {
|
|
|
728
823
|
onScan: this.options.onDetectScan,
|
|
729
824
|
suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion])),
|
|
730
825
|
});
|
|
731
|
-
localRecords = [...scanned.records];
|
|
732
826
|
detection = scanned.detection;
|
|
827
|
+
acceptedMaterializedRows = scanned.acceptedMaterializedRows;
|
|
828
|
+
materializedRemovals = scanned.materializedRemovals;
|
|
733
829
|
acceptedNotebookRows = scanned.acceptedNotebookRows;
|
|
734
830
|
notebookRemovals = scanned.notebookRemovals;
|
|
735
831
|
}
|
|
@@ -743,45 +839,24 @@ export class UserGroundHost {
|
|
|
743
839
|
const detectedRecords = detection.flatMap((plan) => plan.kind === "ready"
|
|
744
840
|
? plan.proposals.map((proposal) => ({ mutationId: randomUUID(), proposal }))
|
|
745
841
|
: []);
|
|
746
|
-
// The notebook is durable identity evidence, not evidence scoped to only
|
|
747
|
-
// this Watch generation. A later no-op flush must not erase ground that an
|
|
748
|
-
// earlier observation proved missing without lifecycle authority.
|
|
749
|
-
localRecords = recordsRetainingMissingEvidence(localRecords, readDetectionNotebook(this.databasePath(identity))
|
|
750
|
-
.filter((row) => !notebookRemovals.includes(row.uuid))
|
|
751
|
-
.map(portableRecord));
|
|
752
|
-
const snapshot = snapshotFromRecords(travelingRecords(mergeMaterializedRoots(state.records, localRecords)));
|
|
753
|
-
const checksum = sha256Hex(stableJson(snapshot));
|
|
754
|
-
if (checksum === state.checksum) {
|
|
755
|
-
commitDetectedState(this.databasePath(identity), {
|
|
756
|
-
acceptedNotebookRows,
|
|
757
|
-
notebookRemovals,
|
|
758
|
-
});
|
|
759
|
-
this.watchHost.settle(observations);
|
|
760
|
-
return;
|
|
761
|
-
}
|
|
762
842
|
commitDetectedState(this.databasePath(identity), {
|
|
843
|
+
acceptedMaterializedRows,
|
|
844
|
+
materializedRemovals,
|
|
763
845
|
acceptedNotebookRows,
|
|
764
846
|
notebookRemovals,
|
|
765
|
-
|
|
766
|
-
mutationId: randomUUID(),
|
|
767
|
-
resourceId: state.resourceId,
|
|
768
|
-
authorityEpoch: state.authorityEpoch,
|
|
769
|
-
baseVersion: state.headVersion,
|
|
770
|
-
snapshotChecksum: checksum,
|
|
771
|
-
snapshotJson: stableJson(snapshot),
|
|
772
|
-
detectedRecordsJson: stableJson(detectedRecords),
|
|
773
|
-
},
|
|
847
|
+
proposals: detectedRecords,
|
|
774
848
|
});
|
|
775
|
-
|
|
849
|
+
this.namedDetect?.refreshEnrollmentPolicy();
|
|
776
850
|
this.watchHost.settle(observations);
|
|
777
851
|
}
|
|
778
|
-
async
|
|
779
|
-
const pending =
|
|
852
|
+
async publishPendingSnapshotsBeforeLookup(identity, resourceId) {
|
|
853
|
+
const pending = readJournal(this.databasePath(identity))
|
|
854
|
+
.filter((entry) => entry.kind === "entity.snapshot.replace");
|
|
780
855
|
if (pending.length === 0)
|
|
781
856
|
return;
|
|
782
857
|
const first = pending[0];
|
|
783
858
|
if (first.resourceId !== resourceId) {
|
|
784
|
-
throw new Error("local
|
|
859
|
+
throw new Error("local snapshot journal belongs to a different user-ground resource");
|
|
785
860
|
}
|
|
786
861
|
this.cloudState = {
|
|
787
862
|
resourceId: first.resourceId,
|
|
@@ -790,10 +865,56 @@ export class UserGroundHost {
|
|
|
790
865
|
checksum: "",
|
|
791
866
|
records: snapshotFromRecords(JSON.parse(first.snapshotJson).records || []).records,
|
|
792
867
|
};
|
|
793
|
-
await this.
|
|
868
|
+
await this.publishPendingSnapshots(identity);
|
|
869
|
+
}
|
|
870
|
+
/** Register is the deliberate cold graph-publication boundary. Ordinary
|
|
871
|
+
* Watch detection stops at a durable exact record and never rebuilds this
|
|
872
|
+
* resource snapshot. */
|
|
873
|
+
async publishMaterializedSnapshot(identity) {
|
|
874
|
+
const state = this.cloudState;
|
|
875
|
+
if (!state)
|
|
876
|
+
throw new Error("cloud state is unavailable for graph publication");
|
|
877
|
+
const notebook = readDetectionNotebook(this.databasePath(identity));
|
|
878
|
+
const localRecords = recordsRetainingMissingEvidence(portableRecords(this.databasePath(identity)), notebook.map(portableRecord));
|
|
879
|
+
const merged = mergeMaterializedRoots(state.records, localRecords);
|
|
880
|
+
const traveling = travelingRecords(merged);
|
|
881
|
+
const impossibleGitLeaf = traveling.find((record) => (record.type === "file.text" || record.type === "file.binary" || record.type === "link")
|
|
882
|
+
&& record.payloadVersion?.startsWith("git:"));
|
|
883
|
+
if (impossibleGitLeaf) {
|
|
884
|
+
const parent = merged.find((record) => record.uuid === impossibleGitLeaf.parentUUID);
|
|
885
|
+
throw new Error(`repository-owned leaf escaped its rail: ${impossibleGitLeaf.name} parent=${parent?.type ?? "missing"}`);
|
|
886
|
+
}
|
|
887
|
+
const snapshot = snapshotFromRecords(traveling);
|
|
888
|
+
const snapshotJson = stableJson(snapshot);
|
|
889
|
+
const checksum = sha256Hex(snapshotJson);
|
|
890
|
+
if (checksum === state.checksum)
|
|
891
|
+
return;
|
|
892
|
+
const pending = readJournal(this.databasePath(identity))
|
|
893
|
+
.filter((entry) => entry.kind === "entity.snapshot.replace");
|
|
894
|
+
const identical = pending.find((entry) => entry.resourceId === state.resourceId
|
|
895
|
+
&& entry.snapshotChecksum === checksum);
|
|
896
|
+
if (identical) {
|
|
897
|
+
await this.publishPendingSnapshots(identity);
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
if (pending.length > 0) {
|
|
901
|
+
throw new Error("a different graph snapshot is already pending publication");
|
|
902
|
+
}
|
|
903
|
+
appendSnapshotJournal(this.databasePath(identity), {
|
|
904
|
+
kind: "entity.snapshot.replace",
|
|
905
|
+
mutationId: randomUUID(),
|
|
906
|
+
resourceId: state.resourceId,
|
|
907
|
+
authorityEpoch: state.authorityEpoch,
|
|
908
|
+
baseVersion: state.headVersion,
|
|
909
|
+
snapshotChecksum: checksum,
|
|
910
|
+
snapshotJson,
|
|
911
|
+
});
|
|
912
|
+
await this.publishPendingSnapshots(identity);
|
|
794
913
|
}
|
|
795
|
-
async
|
|
796
|
-
for (const pending of
|
|
914
|
+
async publishPendingSnapshots(identity) {
|
|
915
|
+
for (const pending of readJournal(this.databasePath(identity))) {
|
|
916
|
+
if (pending.kind !== "entity.snapshot.replace")
|
|
917
|
+
continue;
|
|
797
918
|
const snapshot = snapshotFromRecords(JSON.parse(pending.snapshotJson).records || []);
|
|
798
919
|
const locallyMaterializedIds = new Set(readRows(this.databasePath(identity))
|
|
799
920
|
.map((row) => row.uuid));
|
|
@@ -830,7 +951,7 @@ export class UserGroundHost {
|
|
|
830
951
|
checksum: pending.snapshotChecksum,
|
|
831
952
|
records: snapshot.records,
|
|
832
953
|
};
|
|
833
|
-
|
|
954
|
+
deleteJournalEntry(this.databasePath(identity), pending.mutationId);
|
|
834
955
|
}
|
|
835
956
|
}
|
|
836
957
|
}
|
|
@@ -910,36 +1031,11 @@ function assertHealthyWatch(evidence, rootId) {
|
|
|
910
1031
|
throw new Error(`Watch coverage is not healthy for workspace ${rootId}: ${evidence.health.reason ?? "incomplete handle evidence"}`);
|
|
911
1032
|
}
|
|
912
1033
|
}
|
|
913
|
-
function immutableWrite(file, bytes) {
|
|
914
|
-
ensurePrivateDir(dirname(file));
|
|
915
|
-
try {
|
|
916
|
-
writeFileSync(file, bytes, { mode: 0o600, flag: "wx" });
|
|
917
|
-
}
|
|
918
|
-
catch (error) {
|
|
919
|
-
if (error.code !== "EEXIST")
|
|
920
|
-
throw error;
|
|
921
|
-
if (!readFileSync(file).equals(Buffer.from(bytes)))
|
|
922
|
-
throw new Error(`immutable object conflict at ${file}`);
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
function immutableWriteArtifact(cacheDir, artifact, bytes) {
|
|
926
|
-
const manifest = deriveUploadManifest(artifact, bytes, sha256Hex);
|
|
927
|
-
immutableWrite(contentCacheFile(cacheDir, artifact.contentHash), bytes);
|
|
928
|
-
atomicWrite(manifestCacheFile(cacheDir, artifact.contentHash), stableJson(manifest));
|
|
929
|
-
}
|
|
930
1034
|
function initializeDatabase(file) {
|
|
931
1035
|
ensurePrivateDir(dirname(file));
|
|
932
1036
|
const database = new Database(file);
|
|
933
1037
|
database.pragma("journal_mode = WAL");
|
|
934
|
-
database.pragma("synchronous =
|
|
935
|
-
const entityColumns = database.prepare("PRAGMA table_info(entities)").all();
|
|
936
|
-
if (entityColumns.length > 0
|
|
937
|
-
&& (!entityColumns.some(({ name }) => name === "resource_id")
|
|
938
|
-
|| !entityColumns.some(({ name }) => name === "root_uuid"))) {
|
|
939
|
-
// Entity rows are a derived local projection. The pre-multi-root table
|
|
940
|
-
// cannot represent the new invariant and is rebuilt from cloud/ground.
|
|
941
|
-
database.exec("DROP TABLE entities");
|
|
942
|
-
}
|
|
1038
|
+
database.pragma("synchronous = FULL");
|
|
943
1039
|
database.exec(`
|
|
944
1040
|
CREATE TABLE IF NOT EXISTS ground_identity (
|
|
945
1041
|
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
@@ -990,15 +1086,12 @@ function initializeDatabase(file) {
|
|
|
990
1086
|
filesystem_mode INTEGER,
|
|
991
1087
|
content_verified_at_ms REAL
|
|
992
1088
|
);
|
|
993
|
-
CREATE TABLE IF NOT EXISTS
|
|
1089
|
+
CREATE TABLE IF NOT EXISTS mutation_journal (
|
|
994
1090
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
995
1091
|
mutation_id TEXT NOT NULL UNIQUE,
|
|
996
1092
|
resource_id TEXT NOT NULL,
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
snapshot_checksum TEXT NOT NULL,
|
|
1000
|
-
snapshot_json TEXT NOT NULL,
|
|
1001
|
-
detected_records_json TEXT NOT NULL DEFAULT '[]',
|
|
1093
|
+
operation_kind TEXT NOT NULL,
|
|
1094
|
+
operation_json TEXT NOT NULL,
|
|
1002
1095
|
created_at TEXT NOT NULL
|
|
1003
1096
|
);
|
|
1004
1097
|
CREATE TABLE IF NOT EXISTS workspace_add_intents (
|
|
@@ -1010,34 +1103,18 @@ function initializeDatabase(file) {
|
|
|
1010
1103
|
ON entities(absolute_path);
|
|
1011
1104
|
CREATE INDEX IF NOT EXISTS entities_by_physical_identity
|
|
1012
1105
|
ON entities(device_number, inode);
|
|
1106
|
+
CREATE INDEX IF NOT EXISTS entities_by_parent_uuid
|
|
1107
|
+
ON entities(parent_uuid);
|
|
1108
|
+
CREATE INDEX IF NOT EXISTS entities_by_root_path
|
|
1109
|
+
ON entities(root_uuid, relative_path);
|
|
1013
1110
|
CREATE INDEX IF NOT EXISTS detection_notebook_by_root_path
|
|
1014
|
-
ON detection_notebook(
|
|
1111
|
+
ON detection_notebook(root_uuid, relative_path);
|
|
1112
|
+
CREATE INDEX IF NOT EXISTS detection_notebook_by_absolute_path
|
|
1113
|
+
ON detection_notebook(absolute_path);
|
|
1015
1114
|
CREATE INDEX IF NOT EXISTS detection_notebook_by_physical_identity
|
|
1016
1115
|
ON detection_notebook(device_number, inode);
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
.map(({ name }) => name));
|
|
1020
|
-
const fingerprintColumns = [
|
|
1021
|
-
["byte_size", "INTEGER"],
|
|
1022
|
-
["modified_time_ms", "REAL"],
|
|
1023
|
-
["changed_time_ms", "REAL"],
|
|
1024
|
-
["filesystem_mode", "INTEGER"],
|
|
1025
|
-
["content_verified_at_ms", "REAL"],
|
|
1026
|
-
];
|
|
1027
|
-
for (const [name, type] of fingerprintColumns) {
|
|
1028
|
-
if (!currentEntityColumns.has(name))
|
|
1029
|
-
database.exec(`ALTER TABLE entities ADD COLUMN ${name} ${type}`);
|
|
1030
|
-
}
|
|
1031
|
-
const outboxColumns = new Set(database.prepare("PRAGMA table_info(cloud_outbox)").all()
|
|
1032
|
-
.map(({ name }) => name));
|
|
1033
|
-
if (!outboxColumns.has("detected_records_json")) {
|
|
1034
|
-
database.exec("ALTER TABLE cloud_outbox ADD COLUMN detected_records_json TEXT NOT NULL DEFAULT '[]'");
|
|
1035
|
-
}
|
|
1036
|
-
const notebookCount = database.prepare("SELECT COUNT(*) AS count FROM detection_notebook").get().count;
|
|
1037
|
-
if (notebookCount === 0)
|
|
1038
|
-
database.exec(`
|
|
1039
|
-
INSERT INTO detection_notebook(${GROUND_ROW_COLUMNS})
|
|
1040
|
-
SELECT ${GROUND_ROW_COLUMNS} FROM entities
|
|
1116
|
+
CREATE INDEX IF NOT EXISTS mutation_journal_by_kind
|
|
1117
|
+
ON mutation_journal(operation_kind);
|
|
1041
1118
|
`);
|
|
1042
1119
|
return database;
|
|
1043
1120
|
}
|
|
@@ -1195,18 +1272,52 @@ function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
|
|
|
1195
1272
|
database.close();
|
|
1196
1273
|
}
|
|
1197
1274
|
}
|
|
1198
|
-
function
|
|
1275
|
+
function readJournal(file) {
|
|
1199
1276
|
if (!existsSync(file))
|
|
1200
1277
|
return [];
|
|
1201
1278
|
const database = initializeDatabase(file);
|
|
1202
1279
|
try {
|
|
1203
1280
|
return database.prepare(`
|
|
1204
1281
|
SELECT mutation_id AS mutationId, resource_id AS resourceId,
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1282
|
+
operation_kind AS kind, operation_json AS operationJson
|
|
1283
|
+
FROM mutation_journal ORDER BY sequence
|
|
1284
|
+
`).all().map((row) => {
|
|
1285
|
+
const operation = decodeMutationOperation(row.operationJson);
|
|
1286
|
+
if (row.kind === "entity.snapshot.replace") {
|
|
1287
|
+
return {
|
|
1288
|
+
kind: row.kind,
|
|
1289
|
+
mutationId: row.mutationId,
|
|
1290
|
+
resourceId: row.resourceId,
|
|
1291
|
+
authorityEpoch: Number(operation.authorityEpoch),
|
|
1292
|
+
baseVersion: Number(operation.baseVersion),
|
|
1293
|
+
snapshotChecksum: String(operation.snapshotChecksum),
|
|
1294
|
+
snapshotJson: String(operation.snapshotJson),
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
if (row.kind === "detected.change") {
|
|
1298
|
+
return {
|
|
1299
|
+
kind: row.kind,
|
|
1300
|
+
mutationId: row.mutationId,
|
|
1301
|
+
resourceId: row.resourceId,
|
|
1302
|
+
proposal: operation.proposal,
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
throw new Error(`unknown mutation journal operation: ${String(row.kind)}`);
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
finally {
|
|
1309
|
+
database.close();
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
function hasDetectedChanges(file) {
|
|
1313
|
+
if (!existsSync(file))
|
|
1314
|
+
return false;
|
|
1315
|
+
const database = initializeDatabase(file);
|
|
1316
|
+
try {
|
|
1317
|
+
return database.prepare(`
|
|
1318
|
+
SELECT 1 AS present FROM mutation_journal
|
|
1319
|
+
WHERE operation_kind = 'detected.change' LIMIT 1
|
|
1320
|
+
`).get() !== undefined;
|
|
1210
1321
|
}
|
|
1211
1322
|
finally {
|
|
1212
1323
|
database.close();
|
|
@@ -1214,20 +1325,28 @@ function readOutbox(file) {
|
|
|
1214
1325
|
}
|
|
1215
1326
|
/** Record is the SQLite transaction that accepts Detect's exact proposals and
|
|
1216
1327
|
* advances the last-verified notebook. Watch may settle only after this
|
|
1217
|
-
* function returns. The
|
|
1328
|
+
* function returns. The mutation journal is the one durable queue. */
|
|
1218
1329
|
function commitDetectedState(file, input) {
|
|
1219
1330
|
const database = initializeDatabase(file);
|
|
1220
1331
|
try {
|
|
1221
1332
|
database.transaction(() => {
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1333
|
+
const append = database.prepare(`
|
|
1334
|
+
INSERT INTO mutation_journal(
|
|
1335
|
+
mutation_id, resource_id, operation_kind, operation_json, created_at
|
|
1336
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
1337
|
+
`);
|
|
1338
|
+
for (const entry of input.proposals ?? []) {
|
|
1339
|
+
const proposal = entry.proposal;
|
|
1340
|
+
append.run(entry.mutationId, String(proposal.resourceId ?? ""), "detected.change", encodeMutationOperation({ proposal: entry.proposal }), new Date().toISOString());
|
|
1229
1341
|
}
|
|
1230
1342
|
const remove = database.prepare("DELETE FROM detection_notebook WHERE uuid = ?");
|
|
1343
|
+
const removeMaterialized = database.prepare("DELETE FROM entities WHERE uuid = ?");
|
|
1344
|
+
for (const uuid of input.materializedRemovals ?? [])
|
|
1345
|
+
removeMaterialized.run(uuid);
|
|
1346
|
+
const upsertMaterialized = prepareGroundUpsert(database, "entities");
|
|
1347
|
+
for (const row of input.acceptedMaterializedRows ?? []) {
|
|
1348
|
+
upsertMaterialized.run(...groundRowValues(row));
|
|
1349
|
+
}
|
|
1231
1350
|
for (const uuid of input.notebookRemovals)
|
|
1232
1351
|
remove.run(uuid);
|
|
1233
1352
|
const upsertNotebook = prepareGroundUpsert(database, "detection_notebook");
|
|
@@ -1239,10 +1358,28 @@ function commitDetectedState(file, input) {
|
|
|
1239
1358
|
database.close();
|
|
1240
1359
|
}
|
|
1241
1360
|
}
|
|
1242
|
-
function
|
|
1361
|
+
function appendSnapshotJournal(file, entry) {
|
|
1362
|
+
const database = initializeDatabase(file);
|
|
1363
|
+
try {
|
|
1364
|
+
database.prepare(`
|
|
1365
|
+
INSERT INTO mutation_journal(
|
|
1366
|
+
mutation_id, resource_id, operation_kind, operation_json, created_at
|
|
1367
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
1368
|
+
`).run(entry.mutationId, entry.resourceId, entry.kind, stableJson({
|
|
1369
|
+
authorityEpoch: entry.authorityEpoch,
|
|
1370
|
+
baseVersion: entry.baseVersion,
|
|
1371
|
+
snapshotChecksum: entry.snapshotChecksum,
|
|
1372
|
+
snapshotJson: entry.snapshotJson,
|
|
1373
|
+
}), new Date().toISOString());
|
|
1374
|
+
}
|
|
1375
|
+
finally {
|
|
1376
|
+
database.close();
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
function deleteJournalEntry(file, mutationId) {
|
|
1243
1380
|
const database = initializeDatabase(file);
|
|
1244
1381
|
try {
|
|
1245
|
-
database.prepare("DELETE FROM
|
|
1382
|
+
database.prepare("DELETE FROM mutation_journal WHERE mutation_id = ?").run(mutationId);
|
|
1246
1383
|
}
|
|
1247
1384
|
finally {
|
|
1248
1385
|
database.close();
|
|
@@ -1536,10 +1673,12 @@ function materializedWorkspaceBindings(bindingDir) {
|
|
|
1536
1673
|
}
|
|
1537
1674
|
return bindings.sort((left, right) => left.workspaceId.localeCompare(right.workspaceId));
|
|
1538
1675
|
}
|
|
1539
|
-
async function
|
|
1676
|
+
async function reconcileGround(input) {
|
|
1540
1677
|
const { identity, userRoot, database, cacheDir, suspicions, onScan } = input;
|
|
1541
1678
|
const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
|
|
1542
1679
|
const materialized = readRows(database);
|
|
1680
|
+
const materializedByUuid = new Map(materialized.map((row) => [row.uuid, row]));
|
|
1681
|
+
const materializedUUIDs = new Set(materializedByUuid.keys());
|
|
1543
1682
|
const notebook = readDetectionNotebook(database);
|
|
1544
1683
|
const existing = suspicions ? notebook : materialized;
|
|
1545
1684
|
const notebookByUuid = new Map(notebook.map((row) => [row.uuid, row]));
|
|
@@ -1568,6 +1707,8 @@ async function scanAndRegister(input) {
|
|
|
1568
1707
|
const missingPortableReference = [...boundRoots.keys()]
|
|
1569
1708
|
.some((workspaceId) => !existingReferenceIds.has(workspaceId));
|
|
1570
1709
|
const acceptedRecords = [];
|
|
1710
|
+
const acceptedMaterializedRows = [];
|
|
1711
|
+
const materializedRemovals = new Set();
|
|
1571
1712
|
const acceptedNotebookRows = [];
|
|
1572
1713
|
const notebookRemovals = new Set();
|
|
1573
1714
|
const detection = [];
|
|
@@ -1580,23 +1721,33 @@ async function scanAndRegister(input) {
|
|
|
1580
1721
|
repositoryCaptures: 0,
|
|
1581
1722
|
};
|
|
1582
1723
|
const started = performance.now();
|
|
1583
|
-
const result = await
|
|
1724
|
+
const result = await reconcileRoot({ ...parameters, evidence: counters, materializedUUIDs });
|
|
1584
1725
|
if (result.detection)
|
|
1585
1726
|
detection.push(result.detection);
|
|
1586
1727
|
for (const uuid of result.notebookRemovals) {
|
|
1587
1728
|
notebookRemovals.add(uuid);
|
|
1588
1729
|
notebookByUuid.delete(uuid);
|
|
1589
1730
|
}
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1731
|
+
const currentIds = new Set(result.rows.map((row) => row.record.uuid));
|
|
1732
|
+
for (const prior of materialized.filter((row) => row.resourceId === parameters.resourceId && row.rootUUID === parameters.rootUUID)) {
|
|
1733
|
+
if (!currentIds.has(prior.uuid))
|
|
1734
|
+
materializedRemovals.add(prior.uuid);
|
|
1735
|
+
}
|
|
1736
|
+
for (const row of result.rows) {
|
|
1737
|
+
const captured = capturedGroundRow(parameters.resourceId, parameters.rootUUID, row);
|
|
1738
|
+
if (!sameGroundRow(captured, materializedByUuid.get(captured.uuid))) {
|
|
1739
|
+
acceptedMaterializedRows.push(captured);
|
|
1740
|
+
}
|
|
1741
|
+
if (result.transferable) {
|
|
1594
1742
|
if (sameGroundRow(captured, notebookByUuid.get(captured.uuid)))
|
|
1595
1743
|
continue;
|
|
1596
1744
|
acceptedNotebookRows.push(captured);
|
|
1597
1745
|
notebookByUuid.set(captured.uuid, captured);
|
|
1598
1746
|
}
|
|
1599
1747
|
}
|
|
1748
|
+
if (result.transferable)
|
|
1749
|
+
acceptedRecords.push(...result.records);
|
|
1750
|
+
const proposals = result.detection?.kind === "ready" ? result.detection.proposals : [];
|
|
1600
1751
|
onScan?.({
|
|
1601
1752
|
rootId: parameters.rootUUID,
|
|
1602
1753
|
directory: parameters.rootPath,
|
|
@@ -1605,6 +1756,9 @@ async function scanAndRegister(input) {
|
|
|
1605
1756
|
: parameters.suspects.length === 0 ? "none" : "paths",
|
|
1606
1757
|
...counters,
|
|
1607
1758
|
durationMs: performance.now() - started,
|
|
1759
|
+
plannedRows: proposals.length,
|
|
1760
|
+
records: proposals.length,
|
|
1761
|
+
journalBytes: proposals.reduce((bytes, proposal) => bytes + Buffer.byteLength(encodeMutationOperation({ proposal })), 0),
|
|
1608
1762
|
});
|
|
1609
1763
|
// Each root commits its local projection before Detect yields. Native
|
|
1610
1764
|
// callbacks can therefore preserve new suspicion between large roots.
|
|
@@ -1654,12 +1808,21 @@ async function scanAndRegister(input) {
|
|
|
1654
1808
|
return {
|
|
1655
1809
|
records: acceptedRecords,
|
|
1656
1810
|
detection,
|
|
1811
|
+
acceptedMaterializedRows,
|
|
1812
|
+
materializedRemovals: [...materializedRemovals],
|
|
1657
1813
|
acceptedNotebookRows,
|
|
1658
1814
|
notebookRemovals: [...notebookRemovals],
|
|
1659
1815
|
};
|
|
1660
1816
|
}
|
|
1661
|
-
async function
|
|
1817
|
+
async function reconcileRoot(input) {
|
|
1662
1818
|
const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, evidence, } = input;
|
|
1819
|
+
const scanSuspects = suspects !== null && rootType === "repo.git"
|
|
1820
|
+
? [...new Set(suspects.map((path) => {
|
|
1821
|
+
const segments = path.split("/");
|
|
1822
|
+
const marker = segments.indexOf(".git");
|
|
1823
|
+
return marker < 0 ? path : segments.slice(0, marker).join("/");
|
|
1824
|
+
}))].sort()
|
|
1825
|
+
: suspects;
|
|
1663
1826
|
const existingRows = input.existingRows
|
|
1664
1827
|
?? readRows(database).filter((row) => row.resourceId === resourceId && row.rootUUID === rootUUID);
|
|
1665
1828
|
const existingByPath = new Map(existingRows.flatMap((row) => {
|
|
@@ -1709,6 +1872,32 @@ async function scanRoot(input) {
|
|
|
1709
1872
|
const priorRootType = existingByPath.get("")?.type;
|
|
1710
1873
|
const rootRailChanged = priorRootType !== undefined && priorRootType !== rootType;
|
|
1711
1874
|
evidence && (evidence.entries += 1);
|
|
1875
|
+
if (scanSuspects !== null && rootType === "repo.git") {
|
|
1876
|
+
for (const row of existingRows) {
|
|
1877
|
+
if (row.relativePath === "")
|
|
1878
|
+
continue;
|
|
1879
|
+
if (scanSuspects.some((path) => path !== ""
|
|
1880
|
+
&& (row.relativePath === path || row.relativePath.startsWith(`${path}/`))))
|
|
1881
|
+
continue;
|
|
1882
|
+
entries.push({
|
|
1883
|
+
uuid: row.uuid,
|
|
1884
|
+
type: row.type,
|
|
1885
|
+
parentUUID: row.parentUUID,
|
|
1886
|
+
name: row.name,
|
|
1887
|
+
relativePath: row.relativePath,
|
|
1888
|
+
absolutePath: row.absolutePath,
|
|
1889
|
+
payloadVersion: row.payloadVersion,
|
|
1890
|
+
transportVersion: row.transportVersion,
|
|
1891
|
+
deviceNumber: row.deviceNumber,
|
|
1892
|
+
inode: row.inode,
|
|
1893
|
+
byteSize: row.byteSize,
|
|
1894
|
+
modifiedTimeMs: row.modifiedTimeMs,
|
|
1895
|
+
changedTimeMs: row.changedTimeMs,
|
|
1896
|
+
filesystemMode: row.filesystemMode,
|
|
1897
|
+
contentVerifiedAtMs: row.contentVerifiedAtMs,
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1712
1901
|
entries.push({
|
|
1713
1902
|
uuid: rootUUID,
|
|
1714
1903
|
fixedUUID: rootUUID,
|
|
@@ -1722,15 +1911,15 @@ async function scanRoot(input) {
|
|
|
1722
1911
|
contentVerifiedAtMs: null,
|
|
1723
1912
|
...statFingerprint(rootStats),
|
|
1724
1913
|
});
|
|
1725
|
-
const touchesSuspicion = (path) =>
|
|
1726
|
-
|| pathIsSuspect(path,
|
|
1727
|
-
||
|
|
1914
|
+
const touchesSuspicion = (path) => scanSuspects === null
|
|
1915
|
+
|| pathIsSuspect(path, scanSuspects)
|
|
1916
|
+
|| scanSuspects.some((suspect) => suspect.startsWith(`${path}/`));
|
|
1728
1917
|
const repositoryEvidencePaths = (repository) => {
|
|
1729
|
-
if (
|
|
1918
|
+
if (scanSuspects === null)
|
|
1730
1919
|
return null;
|
|
1731
1920
|
const prefix = slashPath(relative(rootPath, repository));
|
|
1732
1921
|
const paths = [];
|
|
1733
|
-
for (const suspect of
|
|
1922
|
+
for (const suspect of scanSuspects) {
|
|
1734
1923
|
if (suspect === prefix || (prefix && prefix.startsWith(`${suspect}/`)))
|
|
1735
1924
|
return null;
|
|
1736
1925
|
const local = prefix
|
|
@@ -1755,6 +1944,9 @@ async function scanRoot(input) {
|
|
|
1755
1944
|
if (isRepositoryMetadataEntry(parentType, child.name))
|
|
1756
1945
|
continue;
|
|
1757
1946
|
const relativePath = base ? `${base}/${child.name}` : child.name;
|
|
1947
|
+
if (scanSuspects !== null && rootType === "repo.git"
|
|
1948
|
+
&& !forceObserve && !touchesSuspicion(relativePath))
|
|
1949
|
+
continue;
|
|
1758
1950
|
if (!policy(relativePath))
|
|
1759
1951
|
continue;
|
|
1760
1952
|
const absolutePath = join(directory, child.name);
|
|
@@ -1768,12 +1960,12 @@ async function scanRoot(input) {
|
|
|
1768
1960
|
throw new Error(`registered boundary ${absolutePath} conflicts with entity ${existing.uuid}`);
|
|
1769
1961
|
}
|
|
1770
1962
|
const uuid = registeredBoundaryId || existing?.uuid || randomUUID();
|
|
1771
|
-
const stableDuringCompleteCatchUp =
|
|
1963
|
+
const stableDuringCompleteCatchUp = scanSuspects === null
|
|
1772
1964
|
&& existing !== undefined
|
|
1773
1965
|
&& reusableDuringCompleteCatchUp(existing, stats);
|
|
1774
1966
|
const observe = forceObserve || existing === undefined
|
|
1775
|
-
|| (
|
|
1776
|
-
if (!observe &&
|
|
1967
|
+
|| (scanSuspects === null ? !stableDuringCompleteCatchUp : touchesSuspicion(relativePath));
|
|
1968
|
+
if (!observe && scanSuspects === null)
|
|
1777
1969
|
evidence && (evidence.metadataReused += 1);
|
|
1778
1970
|
const repositoryPath = activeRepository
|
|
1779
1971
|
? slashPath(relative(activeRepository.root, absolutePath))
|
|
@@ -1822,7 +2014,7 @@ async function scanRoot(input) {
|
|
|
1822
2014
|
const bytes = Buffer.from(linkTarget, "utf8");
|
|
1823
2015
|
payloadVersion = sha256Hex(bytes);
|
|
1824
2016
|
if (!activeRepository)
|
|
1825
|
-
|
|
2017
|
+
sealCapturedArtifact(cacheDir, {
|
|
1826
2018
|
entityId: uuid,
|
|
1827
2019
|
entityType: type,
|
|
1828
2020
|
kind: "link",
|
|
@@ -1884,6 +2076,7 @@ async function scanRoot(input) {
|
|
|
1884
2076
|
relativePath: row.relativePath,
|
|
1885
2077
|
deviceNumber: row.deviceNumber,
|
|
1886
2078
|
inode: row.inode,
|
|
2079
|
+
physicalContinuity: input.materializedUUIDs?.has(row.uuid) ?? true,
|
|
1887
2080
|
})), entries.map((entry) => ({
|
|
1888
2081
|
key: entry.relativePath,
|
|
1889
2082
|
type: entry.type,
|
|
@@ -1946,12 +2139,15 @@ async function scanRoot(input) {
|
|
|
1946
2139
|
});
|
|
1947
2140
|
const persistCurrentRows = () => {
|
|
1948
2141
|
const rows = rowsFromEntries();
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
}
|
|
2142
|
+
if (input.suspicion === undefined) {
|
|
2143
|
+
persistRows(database, identity, resourceId, rootUUID, rows, existingRows);
|
|
2144
|
+
}
|
|
1952
2145
|
return rows;
|
|
1953
2146
|
};
|
|
1954
|
-
|
|
2147
|
+
// Nested repository state is an input to its enclosing identity map. Seal
|
|
2148
|
+
// children first so one capture pass is already the fixed point.
|
|
2149
|
+
const repositories = entries.filter((entry) => entry.type === "repo.git")
|
|
2150
|
+
.sort((left, right) => right.relativePath.length - left.relativePath.length);
|
|
1955
2151
|
const replayByUuid = new Map();
|
|
1956
2152
|
const properDescendantOf = (candidate, ancestor) => ancestor === "" ? candidate !== "" : candidate.startsWith(`${ancestor}/`);
|
|
1957
2153
|
const repositoryOwner = (entry) => repositories
|
|
@@ -2027,7 +2223,7 @@ async function scanRoot(input) {
|
|
|
2027
2223
|
repository.payloadVersion = captured.stateId;
|
|
2028
2224
|
repository.transportVersion = captured.transportVersion;
|
|
2029
2225
|
if (captured.bytes) {
|
|
2030
|
-
|
|
2226
|
+
sealCapturedArtifact(cacheDir, {
|
|
2031
2227
|
entityId: repository.uuid,
|
|
2032
2228
|
entityType: "repo.git",
|
|
2033
2229
|
kind: "git",
|
|
@@ -2050,7 +2246,7 @@ async function scanRoot(input) {
|
|
|
2050
2246
|
catch {
|
|
2051
2247
|
// Type and identity are filesystem facts, so they settle locally even
|
|
2052
2248
|
// when the selected repository rail cannot yet capture its artifact.
|
|
2053
|
-
// Cloud publication remains commit-last because
|
|
2249
|
+
// Cloud publication remains commit-last because reconciliation omits the
|
|
2054
2250
|
// complete materialized root until every repository parcel is capturable.
|
|
2055
2251
|
// Git unavailability is local to this root; Watch and unrelated roots stay
|
|
2056
2252
|
// live and the next suspicion or resume retries the same ordinary scan.
|
|
@@ -2064,15 +2260,58 @@ async function scanRoot(input) {
|
|
|
2064
2260
|
detection: null,
|
|
2065
2261
|
};
|
|
2066
2262
|
}
|
|
2067
|
-
|
|
2263
|
+
let rows = persistCurrentRows();
|
|
2068
2264
|
const currentUUIDs = new Set(rows.map((row) => row.record.uuid));
|
|
2265
|
+
const retainedMissingRows = input.suspicion === undefined ? [] : existingRows
|
|
2266
|
+
.filter((row) => !currentUUIDs.has(row.uuid) && !existsSync(row.absolutePath));
|
|
2267
|
+
if (retainedMissingRows.length > 0) {
|
|
2268
|
+
// Missing ground changes this machine's materialization evidence, not the
|
|
2269
|
+
// portable entity graph. Keep those identities in the logical membership
|
|
2270
|
+
// used by Record while still removing their materialized SQLite rows.
|
|
2271
|
+
const logicalRecords = [
|
|
2272
|
+
...rows.map((row) => row.record),
|
|
2273
|
+
...retainedMissingRows.map(portableRecord),
|
|
2274
|
+
];
|
|
2275
|
+
const logicalChildren = new Map();
|
|
2276
|
+
for (const record of logicalRecords) {
|
|
2277
|
+
if (record.parentUUID === null || record.status !== "active")
|
|
2278
|
+
continue;
|
|
2279
|
+
const group = logicalChildren.get(record.parentUUID) ?? [];
|
|
2280
|
+
group.push({ uuid: record.uuid, name: record.name });
|
|
2281
|
+
logicalChildren.set(record.parentUUID, group);
|
|
2282
|
+
}
|
|
2283
|
+
rows = rows.map((row) => {
|
|
2284
|
+
if (row.record.type !== "workspace" && row.record.type !== "folder")
|
|
2285
|
+
return row;
|
|
2286
|
+
const version = canonicalVersion({
|
|
2287
|
+
type: row.record.type,
|
|
2288
|
+
parentUUID: row.record.parentUUID,
|
|
2289
|
+
name: row.record.name,
|
|
2290
|
+
status: row.record.status,
|
|
2291
|
+
payloadVersion: membershipHash(logicalChildren.get(row.record.uuid) ?? [], sha256Hex),
|
|
2292
|
+
}, sha256Hex);
|
|
2293
|
+
return version === row.record.version ? row : {
|
|
2294
|
+
...row,
|
|
2295
|
+
record: { ...row.record, version },
|
|
2296
|
+
};
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2069
2299
|
const notebookRemovals = input.suspicion === undefined ? [] : existingRows
|
|
2070
2300
|
.filter((row) => !currentUUIDs.has(row.uuid) && existsSync(row.absolutePath))
|
|
2071
2301
|
.map((row) => row.uuid);
|
|
2072
2302
|
const before = existingRows.map(portableRecord);
|
|
2073
2303
|
const beforeByUuid = new Map(before.map((record) => [record.uuid, record]));
|
|
2304
|
+
const entryByUuid = new Map(entries.map((entry) => [entry.uuid, entry]));
|
|
2074
2305
|
const detected = rows.map((row) => {
|
|
2075
2306
|
const base = beforeByUuid.get(row.record.uuid) ?? null;
|
|
2307
|
+
const entry = entryByUuid.get(row.record.uuid);
|
|
2308
|
+
if (row.record.type !== "repo.git" && entry && repositoryOwner(entry)) {
|
|
2309
|
+
return {
|
|
2310
|
+
record: row.record,
|
|
2311
|
+
relativePath: row.relativePath,
|
|
2312
|
+
replay: { kind: "structure" },
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
2076
2315
|
const contentChanged = base === null
|
|
2077
2316
|
|| base.type !== row.record.type
|
|
2078
2317
|
|| base.payloadVersion !== row.record.payloadVersion
|
|
@@ -2083,15 +2322,25 @@ async function scanRoot(input) {
|
|
|
2083
2322
|
case "file.text":
|
|
2084
2323
|
if (!row.record.payloadVersion)
|
|
2085
2324
|
throw new Error("detected text file has no content head");
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2325
|
+
{
|
|
2326
|
+
const resultBytes = readFileSync(contentCacheFile(cacheDir, row.record.payloadVersion));
|
|
2327
|
+
const baseBytes = base?.payloadVersion
|
|
2328
|
+
&& /^[0-9a-f]{64}$/.test(base.payloadVersion)
|
|
2329
|
+
&& existsSync(contentCacheFile(cacheDir, base.payloadVersion))
|
|
2330
|
+
? readFileSync(contentCacheFile(cacheDir, base.payloadVersion))
|
|
2331
|
+
: null;
|
|
2332
|
+
replay = exactTextReplay({
|
|
2333
|
+
entityId: row.record.uuid,
|
|
2334
|
+
basePayloadVersion: base?.payloadVersion
|
|
2335
|
+
&& /^[0-9a-f]{64}$/.test(base.payloadVersion)
|
|
2336
|
+
? base.payloadVersion
|
|
2337
|
+
: null,
|
|
2338
|
+
baseBytes,
|
|
2339
|
+
resultPayloadVersion: row.record.payloadVersion,
|
|
2340
|
+
resultBytes,
|
|
2341
|
+
sha256Hex,
|
|
2342
|
+
});
|
|
2343
|
+
}
|
|
2095
2344
|
break;
|
|
2096
2345
|
case "file.binary": {
|
|
2097
2346
|
if (!row.record.payloadVersion)
|
|
@@ -2131,9 +2380,19 @@ async function scanRoot(input) {
|
|
|
2131
2380
|
}
|
|
2132
2381
|
}
|
|
2133
2382
|
return { record: row.record, relativePath: row.relativePath, replay };
|
|
2134
|
-
})
|
|
2383
|
+
}).concat(retainedMissingRows.map((row) => ({
|
|
2384
|
+
record: portableRecord(row),
|
|
2385
|
+
relativePath: row.relativePath,
|
|
2386
|
+
replay: { kind: "structure" },
|
|
2387
|
+
})));
|
|
2388
|
+
const plannedBefore = rootType === "repo.git"
|
|
2389
|
+
? before.filter((record) => record.type === "repo.git")
|
|
2390
|
+
: before;
|
|
2391
|
+
const plannedAfter = rootType === "repo.git"
|
|
2392
|
+
? detected.filter(({ record }) => record.type === "repo.git")
|
|
2393
|
+
: detected;
|
|
2135
2394
|
return {
|
|
2136
|
-
records: rows.map((row) => row.record),
|
|
2395
|
+
records: [...rows.map((row) => row.record), ...retainedMissingRows.map(portableRecord)],
|
|
2137
2396
|
rows,
|
|
2138
2397
|
referenceWorkspaceIds: [...referenceWorkspaceIds],
|
|
2139
2398
|
transferable: true,
|
|
@@ -2141,8 +2400,8 @@ async function scanRoot(input) {
|
|
|
2141
2400
|
detection: input.suspicion ? planGroundDetection({
|
|
2142
2401
|
rootId: rootUUID,
|
|
2143
2402
|
suspicion: input.suspicion,
|
|
2144
|
-
before,
|
|
2145
|
-
after:
|
|
2403
|
+
before: plannedBefore,
|
|
2404
|
+
after: plannedAfter,
|
|
2146
2405
|
}) : null,
|
|
2147
2406
|
};
|
|
2148
2407
|
}
|