@amalgm/shell 0.1.43 → 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 +20 -16
- 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 +431 -149
- 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
|
@@ -8,7 +8,9 @@ import Database from "better-sqlite3";
|
|
|
8
8
|
import { atomicCopy, atomicWrite, ensurePrivateDir } from "./filesystem.js";
|
|
9
9
|
import { ContentCacheDownload, captureContentFile, hashContentFile, } 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,38 +737,85 @@ 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
|
-
if (
|
|
699
|
-
|
|
761
|
+
if (this.closing)
|
|
762
|
+
return;
|
|
763
|
+
this.ensureWatchers(identity);
|
|
764
|
+
// This timer may have fired while an older Detect generation was
|
|
765
|
+
// still draining. Awaiting that generation does not consume a newer
|
|
766
|
+
// Watch ring; if suspicion remains, give it its own finite pass.
|
|
767
|
+
if (this.watchDirty || this.watchHost.hasPending)
|
|
768
|
+
this.scheduleRescan();
|
|
700
769
|
}).catch(() => {
|
|
701
|
-
//
|
|
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);
|
|
702
776
|
});
|
|
703
|
-
},
|
|
777
|
+
}, delay);
|
|
704
778
|
this.rescanTimer.unref();
|
|
705
779
|
}
|
|
706
780
|
async syncLocalChanges(identity) {
|
|
707
|
-
|
|
708
|
-
const state = this.cloudState;
|
|
709
|
-
if (!state)
|
|
781
|
+
if (!this.cloudState)
|
|
710
782
|
throw new Error("cloud state is unavailable for user-ground Watch");
|
|
711
783
|
const observations = this.watchHost.observations();
|
|
712
|
-
|
|
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: 0,
|
|
801
|
+
repositoryCaptures: 0,
|
|
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
|
+
}
|
|
713
812
|
let detection;
|
|
813
|
+
let acceptedMaterializedRows;
|
|
814
|
+
let materializedRemovals;
|
|
714
815
|
let acceptedNotebookRows;
|
|
715
816
|
let notebookRemovals;
|
|
716
817
|
try {
|
|
717
|
-
const scanned = await
|
|
818
|
+
const scanned = await reconcileGround({
|
|
718
819
|
identity,
|
|
719
820
|
userRoot: this.userRoot(identity),
|
|
720
821
|
database: this.databasePath(identity),
|
|
@@ -722,8 +823,9 @@ export class UserGroundHost {
|
|
|
722
823
|
onScan: this.options.onDetectScan,
|
|
723
824
|
suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion])),
|
|
724
825
|
});
|
|
725
|
-
localRecords = [...scanned.records];
|
|
726
826
|
detection = scanned.detection;
|
|
827
|
+
acceptedMaterializedRows = scanned.acceptedMaterializedRows;
|
|
828
|
+
materializedRemovals = scanned.materializedRemovals;
|
|
727
829
|
acceptedNotebookRows = scanned.acceptedNotebookRows;
|
|
728
830
|
notebookRemovals = scanned.notebookRemovals;
|
|
729
831
|
}
|
|
@@ -737,45 +839,24 @@ export class UserGroundHost {
|
|
|
737
839
|
const detectedRecords = detection.flatMap((plan) => plan.kind === "ready"
|
|
738
840
|
? plan.proposals.map((proposal) => ({ mutationId: randomUUID(), proposal }))
|
|
739
841
|
: []);
|
|
740
|
-
// The notebook is durable identity evidence, not evidence scoped to only
|
|
741
|
-
// this Watch generation. A later no-op flush must not erase ground that an
|
|
742
|
-
// earlier observation proved missing without lifecycle authority.
|
|
743
|
-
localRecords = recordsRetainingMissingEvidence(localRecords, readDetectionNotebook(this.databasePath(identity))
|
|
744
|
-
.filter((row) => !notebookRemovals.includes(row.uuid))
|
|
745
|
-
.map(portableRecord));
|
|
746
|
-
const snapshot = snapshotFromRecords(travelingRecords(mergeMaterializedRoots(state.records, localRecords)));
|
|
747
|
-
const checksum = sha256Hex(stableJson(snapshot));
|
|
748
|
-
if (checksum === state.checksum) {
|
|
749
|
-
commitDetectedState(this.databasePath(identity), {
|
|
750
|
-
acceptedNotebookRows,
|
|
751
|
-
notebookRemovals,
|
|
752
|
-
});
|
|
753
|
-
this.watchHost.settle(observations);
|
|
754
|
-
return;
|
|
755
|
-
}
|
|
756
842
|
commitDetectedState(this.databasePath(identity), {
|
|
843
|
+
acceptedMaterializedRows,
|
|
844
|
+
materializedRemovals,
|
|
757
845
|
acceptedNotebookRows,
|
|
758
846
|
notebookRemovals,
|
|
759
|
-
|
|
760
|
-
mutationId: randomUUID(),
|
|
761
|
-
resourceId: state.resourceId,
|
|
762
|
-
authorityEpoch: state.authorityEpoch,
|
|
763
|
-
baseVersion: state.headVersion,
|
|
764
|
-
snapshotChecksum: checksum,
|
|
765
|
-
snapshotJson: stableJson(snapshot),
|
|
766
|
-
detectedRecordsJson: stableJson(detectedRecords),
|
|
767
|
-
},
|
|
847
|
+
proposals: detectedRecords,
|
|
768
848
|
});
|
|
769
|
-
|
|
849
|
+
this.namedDetect?.refreshEnrollmentPolicy();
|
|
770
850
|
this.watchHost.settle(observations);
|
|
771
851
|
}
|
|
772
|
-
async
|
|
773
|
-
const pending =
|
|
852
|
+
async publishPendingSnapshotsBeforeLookup(identity, resourceId) {
|
|
853
|
+
const pending = readJournal(this.databasePath(identity))
|
|
854
|
+
.filter((entry) => entry.kind === "entity.snapshot.replace");
|
|
774
855
|
if (pending.length === 0)
|
|
775
856
|
return;
|
|
776
857
|
const first = pending[0];
|
|
777
858
|
if (first.resourceId !== resourceId) {
|
|
778
|
-
throw new Error("local
|
|
859
|
+
throw new Error("local snapshot journal belongs to a different user-ground resource");
|
|
779
860
|
}
|
|
780
861
|
this.cloudState = {
|
|
781
862
|
resourceId: first.resourceId,
|
|
@@ -784,10 +865,56 @@ export class UserGroundHost {
|
|
|
784
865
|
checksum: "",
|
|
785
866
|
records: snapshotFromRecords(JSON.parse(first.snapshotJson).records || []).records,
|
|
786
867
|
};
|
|
787
|
-
await this.
|
|
868
|
+
await this.publishPendingSnapshots(identity);
|
|
788
869
|
}
|
|
789
|
-
|
|
790
|
-
|
|
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);
|
|
913
|
+
}
|
|
914
|
+
async publishPendingSnapshots(identity) {
|
|
915
|
+
for (const pending of readJournal(this.databasePath(identity))) {
|
|
916
|
+
if (pending.kind !== "entity.snapshot.replace")
|
|
917
|
+
continue;
|
|
791
918
|
const snapshot = snapshotFromRecords(JSON.parse(pending.snapshotJson).records || []);
|
|
792
919
|
const locallyMaterializedIds = new Set(readRows(this.databasePath(identity))
|
|
793
920
|
.map((row) => row.uuid));
|
|
@@ -824,7 +951,7 @@ export class UserGroundHost {
|
|
|
824
951
|
checksum: pending.snapshotChecksum,
|
|
825
952
|
records: snapshot.records,
|
|
826
953
|
};
|
|
827
|
-
|
|
954
|
+
deleteJournalEntry(this.databasePath(identity), pending.mutationId);
|
|
828
955
|
}
|
|
829
956
|
}
|
|
830
957
|
}
|
|
@@ -925,15 +1052,7 @@ function initializeDatabase(file) {
|
|
|
925
1052
|
ensurePrivateDir(dirname(file));
|
|
926
1053
|
const database = new Database(file);
|
|
927
1054
|
database.pragma("journal_mode = WAL");
|
|
928
|
-
database.pragma("synchronous =
|
|
929
|
-
const entityColumns = database.prepare("PRAGMA table_info(entities)").all();
|
|
930
|
-
if (entityColumns.length > 0
|
|
931
|
-
&& (!entityColumns.some(({ name }) => name === "resource_id")
|
|
932
|
-
|| !entityColumns.some(({ name }) => name === "root_uuid"))) {
|
|
933
|
-
// Entity rows are a derived local projection. The pre-multi-root table
|
|
934
|
-
// cannot represent the new invariant and is rebuilt from cloud/ground.
|
|
935
|
-
database.exec("DROP TABLE entities");
|
|
936
|
-
}
|
|
1055
|
+
database.pragma("synchronous = FULL");
|
|
937
1056
|
database.exec(`
|
|
938
1057
|
CREATE TABLE IF NOT EXISTS ground_identity (
|
|
939
1058
|
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
@@ -984,15 +1103,12 @@ function initializeDatabase(file) {
|
|
|
984
1103
|
filesystem_mode INTEGER,
|
|
985
1104
|
content_verified_at_ms REAL
|
|
986
1105
|
);
|
|
987
|
-
CREATE TABLE IF NOT EXISTS
|
|
1106
|
+
CREATE TABLE IF NOT EXISTS mutation_journal (
|
|
988
1107
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
989
1108
|
mutation_id TEXT NOT NULL UNIQUE,
|
|
990
1109
|
resource_id TEXT NOT NULL,
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
snapshot_checksum TEXT NOT NULL,
|
|
994
|
-
snapshot_json TEXT NOT NULL,
|
|
995
|
-
detected_records_json TEXT NOT NULL DEFAULT '[]',
|
|
1110
|
+
operation_kind TEXT NOT NULL,
|
|
1111
|
+
operation_json TEXT NOT NULL,
|
|
996
1112
|
created_at TEXT NOT NULL
|
|
997
1113
|
);
|
|
998
1114
|
CREATE TABLE IF NOT EXISTS workspace_add_intents (
|
|
@@ -1004,34 +1120,18 @@ function initializeDatabase(file) {
|
|
|
1004
1120
|
ON entities(absolute_path);
|
|
1005
1121
|
CREATE INDEX IF NOT EXISTS entities_by_physical_identity
|
|
1006
1122
|
ON entities(device_number, inode);
|
|
1123
|
+
CREATE INDEX IF NOT EXISTS entities_by_parent_uuid
|
|
1124
|
+
ON entities(parent_uuid);
|
|
1125
|
+
CREATE INDEX IF NOT EXISTS entities_by_root_path
|
|
1126
|
+
ON entities(root_uuid, relative_path);
|
|
1007
1127
|
CREATE INDEX IF NOT EXISTS detection_notebook_by_root_path
|
|
1008
|
-
ON detection_notebook(
|
|
1128
|
+
ON detection_notebook(root_uuid, relative_path);
|
|
1129
|
+
CREATE INDEX IF NOT EXISTS detection_notebook_by_absolute_path
|
|
1130
|
+
ON detection_notebook(absolute_path);
|
|
1009
1131
|
CREATE INDEX IF NOT EXISTS detection_notebook_by_physical_identity
|
|
1010
1132
|
ON detection_notebook(device_number, inode);
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
.map(({ name }) => name));
|
|
1014
|
-
const fingerprintColumns = [
|
|
1015
|
-
["byte_size", "INTEGER"],
|
|
1016
|
-
["modified_time_ms", "REAL"],
|
|
1017
|
-
["changed_time_ms", "REAL"],
|
|
1018
|
-
["filesystem_mode", "INTEGER"],
|
|
1019
|
-
["content_verified_at_ms", "REAL"],
|
|
1020
|
-
];
|
|
1021
|
-
for (const [name, type] of fingerprintColumns) {
|
|
1022
|
-
if (!currentEntityColumns.has(name))
|
|
1023
|
-
database.exec(`ALTER TABLE entities ADD COLUMN ${name} ${type}`);
|
|
1024
|
-
}
|
|
1025
|
-
const outboxColumns = new Set(database.prepare("PRAGMA table_info(cloud_outbox)").all()
|
|
1026
|
-
.map(({ name }) => name));
|
|
1027
|
-
if (!outboxColumns.has("detected_records_json")) {
|
|
1028
|
-
database.exec("ALTER TABLE cloud_outbox ADD COLUMN detected_records_json TEXT NOT NULL DEFAULT '[]'");
|
|
1029
|
-
}
|
|
1030
|
-
const notebookCount = database.prepare("SELECT COUNT(*) AS count FROM detection_notebook").get().count;
|
|
1031
|
-
if (notebookCount === 0)
|
|
1032
|
-
database.exec(`
|
|
1033
|
-
INSERT INTO detection_notebook(${GROUND_ROW_COLUMNS})
|
|
1034
|
-
SELECT ${GROUND_ROW_COLUMNS} FROM entities
|
|
1133
|
+
CREATE INDEX IF NOT EXISTS mutation_journal_by_kind
|
|
1134
|
+
ON mutation_journal(operation_kind);
|
|
1035
1135
|
`);
|
|
1036
1136
|
return database;
|
|
1037
1137
|
}
|
|
@@ -1189,18 +1289,52 @@ function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
|
|
|
1189
1289
|
database.close();
|
|
1190
1290
|
}
|
|
1191
1291
|
}
|
|
1192
|
-
function
|
|
1292
|
+
function readJournal(file) {
|
|
1193
1293
|
if (!existsSync(file))
|
|
1194
1294
|
return [];
|
|
1195
1295
|
const database = initializeDatabase(file);
|
|
1196
1296
|
try {
|
|
1197
1297
|
return database.prepare(`
|
|
1198
1298
|
SELECT mutation_id AS mutationId, resource_id AS resourceId,
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1299
|
+
operation_kind AS kind, operation_json AS operationJson
|
|
1300
|
+
FROM mutation_journal ORDER BY sequence
|
|
1301
|
+
`).all().map((row) => {
|
|
1302
|
+
const operation = decodeMutationOperation(row.operationJson);
|
|
1303
|
+
if (row.kind === "entity.snapshot.replace") {
|
|
1304
|
+
return {
|
|
1305
|
+
kind: row.kind,
|
|
1306
|
+
mutationId: row.mutationId,
|
|
1307
|
+
resourceId: row.resourceId,
|
|
1308
|
+
authorityEpoch: Number(operation.authorityEpoch),
|
|
1309
|
+
baseVersion: Number(operation.baseVersion),
|
|
1310
|
+
snapshotChecksum: String(operation.snapshotChecksum),
|
|
1311
|
+
snapshotJson: String(operation.snapshotJson),
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
if (row.kind === "detected.change") {
|
|
1315
|
+
return {
|
|
1316
|
+
kind: row.kind,
|
|
1317
|
+
mutationId: row.mutationId,
|
|
1318
|
+
resourceId: row.resourceId,
|
|
1319
|
+
proposal: operation.proposal,
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
throw new Error(`unknown mutation journal operation: ${String(row.kind)}`);
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
finally {
|
|
1326
|
+
database.close();
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
function hasDetectedChanges(file) {
|
|
1330
|
+
if (!existsSync(file))
|
|
1331
|
+
return false;
|
|
1332
|
+
const database = initializeDatabase(file);
|
|
1333
|
+
try {
|
|
1334
|
+
return database.prepare(`
|
|
1335
|
+
SELECT 1 AS present FROM mutation_journal
|
|
1336
|
+
WHERE operation_kind = 'detected.change' LIMIT 1
|
|
1337
|
+
`).get() !== undefined;
|
|
1204
1338
|
}
|
|
1205
1339
|
finally {
|
|
1206
1340
|
database.close();
|
|
@@ -1208,20 +1342,28 @@ function readOutbox(file) {
|
|
|
1208
1342
|
}
|
|
1209
1343
|
/** Record is the SQLite transaction that accepts Detect's exact proposals and
|
|
1210
1344
|
* advances the last-verified notebook. Watch may settle only after this
|
|
1211
|
-
* function returns. The
|
|
1345
|
+
* function returns. The mutation journal is the one durable queue. */
|
|
1212
1346
|
function commitDetectedState(file, input) {
|
|
1213
1347
|
const database = initializeDatabase(file);
|
|
1214
1348
|
try {
|
|
1215
1349
|
database.transaction(() => {
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1350
|
+
const append = database.prepare(`
|
|
1351
|
+
INSERT INTO mutation_journal(
|
|
1352
|
+
mutation_id, resource_id, operation_kind, operation_json, created_at
|
|
1353
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
1354
|
+
`);
|
|
1355
|
+
for (const entry of input.proposals ?? []) {
|
|
1356
|
+
const proposal = entry.proposal;
|
|
1357
|
+
append.run(entry.mutationId, String(proposal.resourceId ?? ""), "detected.change", encodeMutationOperation({ proposal: entry.proposal }), new Date().toISOString());
|
|
1223
1358
|
}
|
|
1224
1359
|
const remove = database.prepare("DELETE FROM detection_notebook WHERE uuid = ?");
|
|
1360
|
+
const removeMaterialized = database.prepare("DELETE FROM entities WHERE uuid = ?");
|
|
1361
|
+
for (const uuid of input.materializedRemovals ?? [])
|
|
1362
|
+
removeMaterialized.run(uuid);
|
|
1363
|
+
const upsertMaterialized = prepareGroundUpsert(database, "entities");
|
|
1364
|
+
for (const row of input.acceptedMaterializedRows ?? []) {
|
|
1365
|
+
upsertMaterialized.run(...groundRowValues(row));
|
|
1366
|
+
}
|
|
1225
1367
|
for (const uuid of input.notebookRemovals)
|
|
1226
1368
|
remove.run(uuid);
|
|
1227
1369
|
const upsertNotebook = prepareGroundUpsert(database, "detection_notebook");
|
|
@@ -1233,10 +1375,28 @@ function commitDetectedState(file, input) {
|
|
|
1233
1375
|
database.close();
|
|
1234
1376
|
}
|
|
1235
1377
|
}
|
|
1236
|
-
function
|
|
1378
|
+
function appendSnapshotJournal(file, entry) {
|
|
1379
|
+
const database = initializeDatabase(file);
|
|
1380
|
+
try {
|
|
1381
|
+
database.prepare(`
|
|
1382
|
+
INSERT INTO mutation_journal(
|
|
1383
|
+
mutation_id, resource_id, operation_kind, operation_json, created_at
|
|
1384
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
1385
|
+
`).run(entry.mutationId, entry.resourceId, entry.kind, stableJson({
|
|
1386
|
+
authorityEpoch: entry.authorityEpoch,
|
|
1387
|
+
baseVersion: entry.baseVersion,
|
|
1388
|
+
snapshotChecksum: entry.snapshotChecksum,
|
|
1389
|
+
snapshotJson: entry.snapshotJson,
|
|
1390
|
+
}), new Date().toISOString());
|
|
1391
|
+
}
|
|
1392
|
+
finally {
|
|
1393
|
+
database.close();
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
function deleteJournalEntry(file, mutationId) {
|
|
1237
1397
|
const database = initializeDatabase(file);
|
|
1238
1398
|
try {
|
|
1239
|
-
database.prepare("DELETE FROM
|
|
1399
|
+
database.prepare("DELETE FROM mutation_journal WHERE mutation_id = ?").run(mutationId);
|
|
1240
1400
|
}
|
|
1241
1401
|
finally {
|
|
1242
1402
|
database.close();
|
|
@@ -1530,10 +1690,12 @@ function materializedWorkspaceBindings(bindingDir) {
|
|
|
1530
1690
|
}
|
|
1531
1691
|
return bindings.sort((left, right) => left.workspaceId.localeCompare(right.workspaceId));
|
|
1532
1692
|
}
|
|
1533
|
-
async function
|
|
1693
|
+
async function reconcileGround(input) {
|
|
1534
1694
|
const { identity, userRoot, database, cacheDir, suspicions, onScan } = input;
|
|
1535
1695
|
const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
|
|
1536
1696
|
const materialized = readRows(database);
|
|
1697
|
+
const materializedByUuid = new Map(materialized.map((row) => [row.uuid, row]));
|
|
1698
|
+
const materializedUUIDs = new Set(materializedByUuid.keys());
|
|
1537
1699
|
const notebook = readDetectionNotebook(database);
|
|
1538
1700
|
const existing = suspicions ? notebook : materialized;
|
|
1539
1701
|
const notebookByUuid = new Map(notebook.map((row) => [row.uuid, row]));
|
|
@@ -1562,6 +1724,8 @@ async function scanAndRegister(input) {
|
|
|
1562
1724
|
const missingPortableReference = [...boundRoots.keys()]
|
|
1563
1725
|
.some((workspaceId) => !existingReferenceIds.has(workspaceId));
|
|
1564
1726
|
const acceptedRecords = [];
|
|
1727
|
+
const acceptedMaterializedRows = [];
|
|
1728
|
+
const materializedRemovals = new Set();
|
|
1565
1729
|
const acceptedNotebookRows = [];
|
|
1566
1730
|
const notebookRemovals = new Set();
|
|
1567
1731
|
const detection = [];
|
|
@@ -1574,23 +1738,33 @@ async function scanAndRegister(input) {
|
|
|
1574
1738
|
repositoryCaptures: 0,
|
|
1575
1739
|
};
|
|
1576
1740
|
const started = performance.now();
|
|
1577
|
-
const result = await
|
|
1741
|
+
const result = await reconcileRoot({ ...parameters, evidence: counters, materializedUUIDs });
|
|
1578
1742
|
if (result.detection)
|
|
1579
1743
|
detection.push(result.detection);
|
|
1580
1744
|
for (const uuid of result.notebookRemovals) {
|
|
1581
1745
|
notebookRemovals.add(uuid);
|
|
1582
1746
|
notebookByUuid.delete(uuid);
|
|
1583
1747
|
}
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1748
|
+
const currentIds = new Set(result.rows.map((row) => row.record.uuid));
|
|
1749
|
+
for (const prior of materialized.filter((row) => row.resourceId === parameters.resourceId && row.rootUUID === parameters.rootUUID)) {
|
|
1750
|
+
if (!currentIds.has(prior.uuid))
|
|
1751
|
+
materializedRemovals.add(prior.uuid);
|
|
1752
|
+
}
|
|
1753
|
+
for (const row of result.rows) {
|
|
1754
|
+
const captured = capturedGroundRow(parameters.resourceId, parameters.rootUUID, row);
|
|
1755
|
+
if (!sameGroundRow(captured, materializedByUuid.get(captured.uuid))) {
|
|
1756
|
+
acceptedMaterializedRows.push(captured);
|
|
1757
|
+
}
|
|
1758
|
+
if (result.transferable) {
|
|
1588
1759
|
if (sameGroundRow(captured, notebookByUuid.get(captured.uuid)))
|
|
1589
1760
|
continue;
|
|
1590
1761
|
acceptedNotebookRows.push(captured);
|
|
1591
1762
|
notebookByUuid.set(captured.uuid, captured);
|
|
1592
1763
|
}
|
|
1593
1764
|
}
|
|
1765
|
+
if (result.transferable)
|
|
1766
|
+
acceptedRecords.push(...result.records);
|
|
1767
|
+
const proposals = result.detection?.kind === "ready" ? result.detection.proposals : [];
|
|
1594
1768
|
onScan?.({
|
|
1595
1769
|
rootId: parameters.rootUUID,
|
|
1596
1770
|
directory: parameters.rootPath,
|
|
@@ -1599,6 +1773,9 @@ async function scanAndRegister(input) {
|
|
|
1599
1773
|
: parameters.suspects.length === 0 ? "none" : "paths",
|
|
1600
1774
|
...counters,
|
|
1601
1775
|
durationMs: performance.now() - started,
|
|
1776
|
+
plannedRows: proposals.length,
|
|
1777
|
+
records: proposals.length,
|
|
1778
|
+
journalBytes: proposals.reduce((bytes, proposal) => bytes + Buffer.byteLength(encodeMutationOperation({ proposal })), 0),
|
|
1602
1779
|
});
|
|
1603
1780
|
// Each root commits its local projection before Detect yields. Native
|
|
1604
1781
|
// callbacks can therefore preserve new suspicion between large roots.
|
|
@@ -1648,12 +1825,21 @@ async function scanAndRegister(input) {
|
|
|
1648
1825
|
return {
|
|
1649
1826
|
records: acceptedRecords,
|
|
1650
1827
|
detection,
|
|
1828
|
+
acceptedMaterializedRows,
|
|
1829
|
+
materializedRemovals: [...materializedRemovals],
|
|
1651
1830
|
acceptedNotebookRows,
|
|
1652
1831
|
notebookRemovals: [...notebookRemovals],
|
|
1653
1832
|
};
|
|
1654
1833
|
}
|
|
1655
|
-
async function
|
|
1834
|
+
async function reconcileRoot(input) {
|
|
1656
1835
|
const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, evidence, } = input;
|
|
1836
|
+
const scanSuspects = suspects !== null && rootType === "repo.git"
|
|
1837
|
+
? [...new Set(suspects.map((path) => {
|
|
1838
|
+
const segments = path.split("/");
|
|
1839
|
+
const marker = segments.indexOf(".git");
|
|
1840
|
+
return marker < 0 ? path : segments.slice(0, marker).join("/");
|
|
1841
|
+
}))].sort()
|
|
1842
|
+
: suspects;
|
|
1657
1843
|
const existingRows = input.existingRows
|
|
1658
1844
|
?? readRows(database).filter((row) => row.resourceId === resourceId && row.rootUUID === rootUUID);
|
|
1659
1845
|
const existingByPath = new Map(existingRows.flatMap((row) => {
|
|
@@ -1703,6 +1889,32 @@ async function scanRoot(input) {
|
|
|
1703
1889
|
const priorRootType = existingByPath.get("")?.type;
|
|
1704
1890
|
const rootRailChanged = priorRootType !== undefined && priorRootType !== rootType;
|
|
1705
1891
|
evidence && (evidence.entries += 1);
|
|
1892
|
+
if (scanSuspects !== null && rootType === "repo.git") {
|
|
1893
|
+
for (const row of existingRows) {
|
|
1894
|
+
if (row.relativePath === "")
|
|
1895
|
+
continue;
|
|
1896
|
+
if (scanSuspects.some((path) => path !== ""
|
|
1897
|
+
&& (row.relativePath === path || row.relativePath.startsWith(`${path}/`))))
|
|
1898
|
+
continue;
|
|
1899
|
+
entries.push({
|
|
1900
|
+
uuid: row.uuid,
|
|
1901
|
+
type: row.type,
|
|
1902
|
+
parentUUID: row.parentUUID,
|
|
1903
|
+
name: row.name,
|
|
1904
|
+
relativePath: row.relativePath,
|
|
1905
|
+
absolutePath: row.absolutePath,
|
|
1906
|
+
payloadVersion: row.payloadVersion,
|
|
1907
|
+
transportVersion: row.transportVersion,
|
|
1908
|
+
deviceNumber: row.deviceNumber,
|
|
1909
|
+
inode: row.inode,
|
|
1910
|
+
byteSize: row.byteSize,
|
|
1911
|
+
modifiedTimeMs: row.modifiedTimeMs,
|
|
1912
|
+
changedTimeMs: row.changedTimeMs,
|
|
1913
|
+
filesystemMode: row.filesystemMode,
|
|
1914
|
+
contentVerifiedAtMs: row.contentVerifiedAtMs,
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1706
1918
|
entries.push({
|
|
1707
1919
|
uuid: rootUUID,
|
|
1708
1920
|
fixedUUID: rootUUID,
|
|
@@ -1716,15 +1928,15 @@ async function scanRoot(input) {
|
|
|
1716
1928
|
contentVerifiedAtMs: null,
|
|
1717
1929
|
...statFingerprint(rootStats),
|
|
1718
1930
|
});
|
|
1719
|
-
const touchesSuspicion = (path) =>
|
|
1720
|
-
|| pathIsSuspect(path,
|
|
1721
|
-
||
|
|
1931
|
+
const touchesSuspicion = (path) => scanSuspects === null
|
|
1932
|
+
|| pathIsSuspect(path, scanSuspects)
|
|
1933
|
+
|| scanSuspects.some((suspect) => suspect.startsWith(`${path}/`));
|
|
1722
1934
|
const repositoryEvidencePaths = (repository) => {
|
|
1723
|
-
if (
|
|
1935
|
+
if (scanSuspects === null)
|
|
1724
1936
|
return null;
|
|
1725
1937
|
const prefix = slashPath(relative(rootPath, repository));
|
|
1726
1938
|
const paths = [];
|
|
1727
|
-
for (const suspect of
|
|
1939
|
+
for (const suspect of scanSuspects) {
|
|
1728
1940
|
if (suspect === prefix || (prefix && prefix.startsWith(`${suspect}/`)))
|
|
1729
1941
|
return null;
|
|
1730
1942
|
const local = prefix
|
|
@@ -1749,6 +1961,9 @@ async function scanRoot(input) {
|
|
|
1749
1961
|
if (isRepositoryMetadataEntry(parentType, child.name))
|
|
1750
1962
|
continue;
|
|
1751
1963
|
const relativePath = base ? `${base}/${child.name}` : child.name;
|
|
1964
|
+
if (scanSuspects !== null && rootType === "repo.git"
|
|
1965
|
+
&& !forceObserve && !touchesSuspicion(relativePath))
|
|
1966
|
+
continue;
|
|
1752
1967
|
if (!policy(relativePath))
|
|
1753
1968
|
continue;
|
|
1754
1969
|
const absolutePath = join(directory, child.name);
|
|
@@ -1762,12 +1977,12 @@ async function scanRoot(input) {
|
|
|
1762
1977
|
throw new Error(`registered boundary ${absolutePath} conflicts with entity ${existing.uuid}`);
|
|
1763
1978
|
}
|
|
1764
1979
|
const uuid = registeredBoundaryId || existing?.uuid || randomUUID();
|
|
1765
|
-
const stableDuringCompleteCatchUp =
|
|
1980
|
+
const stableDuringCompleteCatchUp = scanSuspects === null
|
|
1766
1981
|
&& existing !== undefined
|
|
1767
1982
|
&& reusableDuringCompleteCatchUp(existing, stats);
|
|
1768
1983
|
const observe = forceObserve || existing === undefined
|
|
1769
|
-
|| (
|
|
1770
|
-
if (!observe &&
|
|
1984
|
+
|| (scanSuspects === null ? !stableDuringCompleteCatchUp : touchesSuspicion(relativePath));
|
|
1985
|
+
if (!observe && scanSuspects === null)
|
|
1771
1986
|
evidence && (evidence.metadataReused += 1);
|
|
1772
1987
|
const repositoryPath = activeRepository
|
|
1773
1988
|
? slashPath(relative(activeRepository.root, absolutePath))
|
|
@@ -1878,6 +2093,7 @@ async function scanRoot(input) {
|
|
|
1878
2093
|
relativePath: row.relativePath,
|
|
1879
2094
|
deviceNumber: row.deviceNumber,
|
|
1880
2095
|
inode: row.inode,
|
|
2096
|
+
physicalContinuity: input.materializedUUIDs?.has(row.uuid) ?? true,
|
|
1881
2097
|
})), entries.map((entry) => ({
|
|
1882
2098
|
key: entry.relativePath,
|
|
1883
2099
|
type: entry.type,
|
|
@@ -1940,12 +2156,15 @@ async function scanRoot(input) {
|
|
|
1940
2156
|
});
|
|
1941
2157
|
const persistCurrentRows = () => {
|
|
1942
2158
|
const rows = rowsFromEntries();
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
}
|
|
2159
|
+
if (input.suspicion === undefined) {
|
|
2160
|
+
persistRows(database, identity, resourceId, rootUUID, rows, existingRows);
|
|
2161
|
+
}
|
|
1946
2162
|
return rows;
|
|
1947
2163
|
};
|
|
1948
|
-
|
|
2164
|
+
// Nested repository state is an input to its enclosing identity map. Seal
|
|
2165
|
+
// children first so one capture pass is already the fixed point.
|
|
2166
|
+
const repositories = entries.filter((entry) => entry.type === "repo.git")
|
|
2167
|
+
.sort((left, right) => right.relativePath.length - left.relativePath.length);
|
|
1949
2168
|
const replayByUuid = new Map();
|
|
1950
2169
|
const properDescendantOf = (candidate, ancestor) => ancestor === "" ? candidate !== "" : candidate.startsWith(`${ancestor}/`);
|
|
1951
2170
|
const repositoryOwner = (entry) => repositories
|
|
@@ -2044,7 +2263,7 @@ async function scanRoot(input) {
|
|
|
2044
2263
|
catch {
|
|
2045
2264
|
// Type and identity are filesystem facts, so they settle locally even
|
|
2046
2265
|
// when the selected repository rail cannot yet capture its artifact.
|
|
2047
|
-
// Cloud publication remains commit-last because
|
|
2266
|
+
// Cloud publication remains commit-last because reconciliation omits the
|
|
2048
2267
|
// complete materialized root until every repository parcel is capturable.
|
|
2049
2268
|
// Git unavailability is local to this root; Watch and unrelated roots stay
|
|
2050
2269
|
// live and the next suspicion or resume retries the same ordinary scan.
|
|
@@ -2058,15 +2277,58 @@ async function scanRoot(input) {
|
|
|
2058
2277
|
detection: null,
|
|
2059
2278
|
};
|
|
2060
2279
|
}
|
|
2061
|
-
|
|
2280
|
+
let rows = persistCurrentRows();
|
|
2062
2281
|
const currentUUIDs = new Set(rows.map((row) => row.record.uuid));
|
|
2282
|
+
const retainedMissingRows = input.suspicion === undefined ? [] : existingRows
|
|
2283
|
+
.filter((row) => !currentUUIDs.has(row.uuid) && !existsSync(row.absolutePath));
|
|
2284
|
+
if (retainedMissingRows.length > 0) {
|
|
2285
|
+
// Missing ground changes this machine's materialization evidence, not the
|
|
2286
|
+
// portable entity graph. Keep those identities in the logical membership
|
|
2287
|
+
// used by Record while still removing their materialized SQLite rows.
|
|
2288
|
+
const logicalRecords = [
|
|
2289
|
+
...rows.map((row) => row.record),
|
|
2290
|
+
...retainedMissingRows.map(portableRecord),
|
|
2291
|
+
];
|
|
2292
|
+
const logicalChildren = new Map();
|
|
2293
|
+
for (const record of logicalRecords) {
|
|
2294
|
+
if (record.parentUUID === null || record.status !== "active")
|
|
2295
|
+
continue;
|
|
2296
|
+
const group = logicalChildren.get(record.parentUUID) ?? [];
|
|
2297
|
+
group.push({ uuid: record.uuid, name: record.name });
|
|
2298
|
+
logicalChildren.set(record.parentUUID, group);
|
|
2299
|
+
}
|
|
2300
|
+
rows = rows.map((row) => {
|
|
2301
|
+
if (row.record.type !== "workspace" && row.record.type !== "folder")
|
|
2302
|
+
return row;
|
|
2303
|
+
const version = canonicalVersion({
|
|
2304
|
+
type: row.record.type,
|
|
2305
|
+
parentUUID: row.record.parentUUID,
|
|
2306
|
+
name: row.record.name,
|
|
2307
|
+
status: row.record.status,
|
|
2308
|
+
payloadVersion: membershipHash(logicalChildren.get(row.record.uuid) ?? [], sha256Hex),
|
|
2309
|
+
}, sha256Hex);
|
|
2310
|
+
return version === row.record.version ? row : {
|
|
2311
|
+
...row,
|
|
2312
|
+
record: { ...row.record, version },
|
|
2313
|
+
};
|
|
2314
|
+
});
|
|
2315
|
+
}
|
|
2063
2316
|
const notebookRemovals = input.suspicion === undefined ? [] : existingRows
|
|
2064
2317
|
.filter((row) => !currentUUIDs.has(row.uuid) && existsSync(row.absolutePath))
|
|
2065
2318
|
.map((row) => row.uuid);
|
|
2066
2319
|
const before = existingRows.map(portableRecord);
|
|
2067
2320
|
const beforeByUuid = new Map(before.map((record) => [record.uuid, record]));
|
|
2321
|
+
const entryByUuid = new Map(entries.map((entry) => [entry.uuid, entry]));
|
|
2068
2322
|
const detected = rows.map((row) => {
|
|
2069
2323
|
const base = beforeByUuid.get(row.record.uuid) ?? null;
|
|
2324
|
+
const entry = entryByUuid.get(row.record.uuid);
|
|
2325
|
+
if (row.record.type !== "repo.git" && entry && repositoryOwner(entry)) {
|
|
2326
|
+
return {
|
|
2327
|
+
record: row.record,
|
|
2328
|
+
relativePath: row.relativePath,
|
|
2329
|
+
replay: { kind: "structure" },
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2070
2332
|
const contentChanged = base === null
|
|
2071
2333
|
|| base.type !== row.record.type
|
|
2072
2334
|
|| base.payloadVersion !== row.record.payloadVersion
|
|
@@ -2077,15 +2339,25 @@ async function scanRoot(input) {
|
|
|
2077
2339
|
case "file.text":
|
|
2078
2340
|
if (!row.record.payloadVersion)
|
|
2079
2341
|
throw new Error("detected text file has no content head");
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2342
|
+
{
|
|
2343
|
+
const resultBytes = readFileSync(contentCacheFile(cacheDir, row.record.payloadVersion));
|
|
2344
|
+
const baseBytes = base?.payloadVersion
|
|
2345
|
+
&& /^[0-9a-f]{64}$/.test(base.payloadVersion)
|
|
2346
|
+
&& existsSync(contentCacheFile(cacheDir, base.payloadVersion))
|
|
2347
|
+
? readFileSync(contentCacheFile(cacheDir, base.payloadVersion))
|
|
2348
|
+
: null;
|
|
2349
|
+
replay = exactTextReplay({
|
|
2350
|
+
entityId: row.record.uuid,
|
|
2351
|
+
basePayloadVersion: base?.payloadVersion
|
|
2352
|
+
&& /^[0-9a-f]{64}$/.test(base.payloadVersion)
|
|
2353
|
+
? base.payloadVersion
|
|
2354
|
+
: null,
|
|
2355
|
+
baseBytes,
|
|
2356
|
+
resultPayloadVersion: row.record.payloadVersion,
|
|
2357
|
+
resultBytes,
|
|
2358
|
+
sha256Hex,
|
|
2359
|
+
});
|
|
2360
|
+
}
|
|
2089
2361
|
break;
|
|
2090
2362
|
case "file.binary": {
|
|
2091
2363
|
if (!row.record.payloadVersion)
|
|
@@ -2125,9 +2397,19 @@ async function scanRoot(input) {
|
|
|
2125
2397
|
}
|
|
2126
2398
|
}
|
|
2127
2399
|
return { record: row.record, relativePath: row.relativePath, replay };
|
|
2128
|
-
})
|
|
2400
|
+
}).concat(retainedMissingRows.map((row) => ({
|
|
2401
|
+
record: portableRecord(row),
|
|
2402
|
+
relativePath: row.relativePath,
|
|
2403
|
+
replay: { kind: "structure" },
|
|
2404
|
+
})));
|
|
2405
|
+
const plannedBefore = rootType === "repo.git"
|
|
2406
|
+
? before.filter((record) => record.type === "repo.git")
|
|
2407
|
+
: before;
|
|
2408
|
+
const plannedAfter = rootType === "repo.git"
|
|
2409
|
+
? detected.filter(({ record }) => record.type === "repo.git")
|
|
2410
|
+
: detected;
|
|
2129
2411
|
return {
|
|
2130
|
-
records: rows.map((row) => row.record),
|
|
2412
|
+
records: [...rows.map((row) => row.record), ...retainedMissingRows.map(portableRecord)],
|
|
2131
2413
|
rows,
|
|
2132
2414
|
referenceWorkspaceIds: [...referenceWorkspaceIds],
|
|
2133
2415
|
transferable: true,
|
|
@@ -2135,8 +2417,8 @@ async function scanRoot(input) {
|
|
|
2135
2417
|
detection: input.suspicion ? planGroundDetection({
|
|
2136
2418
|
rootId: rootUUID,
|
|
2137
2419
|
suspicion: input.suspicion,
|
|
2138
|
-
before,
|
|
2139
|
-
after:
|
|
2420
|
+
before: plannedBefore,
|
|
2421
|
+
after: plannedAfter,
|
|
2140
2422
|
}) : null,
|
|
2141
2423
|
};
|
|
2142
2424
|
}
|