@amalgm/shell 0.1.41 → 0.1.43
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 +32 -0
- package/dist/detection-host.d.ts +48 -0
- package/dist/detection-host.js +124 -0
- package/dist/detection-host.js.map +1 -0
- package/dist/files-register-host.d.ts +8 -0
- package/dist/files-register-host.js +15 -0
- package/dist/files-register-host.js.map +1 -1
- package/dist/user-ground-host.d.ts +3 -2
- package/dist/user-ground-host.js +471 -107
- package/dist/user-ground-host.js.map +1 -1
- package/package.json +2 -2
package/dist/user-ground-host.js
CHANGED
|
@@ -8,7 +8,8 @@ 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 {
|
|
11
|
+
import { planGroundDetection, reconcileGroundUUIDs, } from "./detection-host.js";
|
|
12
|
+
import { WORKSPACE_UUID as UUID, createFilesRegisterPorts, ensureWorkspaceBinding, ensureWorkspaceReference, pathExists, pathWithin, referenceWorkspaceId, selectKnownRegistrationId, workspaceBindingDir, } from "./files-register-host.js";
|
|
12
13
|
import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, hasGitMarker, } from "./git-repository-host.js";
|
|
13
14
|
import { inspectGitRegistration, } from "./git-registration-host.js";
|
|
14
15
|
import { projectMaterializedGraph } from "./materialized-graph.js";
|
|
@@ -21,6 +22,12 @@ const PORTABLE_FIELDS = [
|
|
|
21
22
|
const SMALL_CONTENT_UPLOAD_CONCURRENCY = 16;
|
|
22
23
|
const FILE_CONTENT_DOWNLOAD_CONCURRENCY = 4;
|
|
23
24
|
const FILE_BATCH_DOWNLOAD_CONCURRENCY = 2;
|
|
25
|
+
const GROUND_ROW_COLUMNS = [
|
|
26
|
+
"uuid", "resource_id", "root_uuid", "type", "parent_uuid", "name", "status", "version",
|
|
27
|
+
"payload_version", "transport_version", "relative_path", "absolute_path", "device_number",
|
|
28
|
+
"inode", "byte_size", "modified_time_ms", "changed_time_ms", "filesystem_mode",
|
|
29
|
+
"content_verified_at_ms",
|
|
30
|
+
].join(", ");
|
|
24
31
|
/** The production Files port injected into Core. Portable identity remains in
|
|
25
32
|
* Live; this class owns only Node filesystem, SQLite, Watch, and wire effects. */
|
|
26
33
|
export class UserGroundHost {
|
|
@@ -290,21 +297,21 @@ export class UserGroundHost {
|
|
|
290
297
|
watch,
|
|
291
298
|
};
|
|
292
299
|
}
|
|
293
|
-
/**
|
|
294
|
-
*
|
|
300
|
+
/** Prove one finite observation boundary in SQLite, immutable content
|
|
301
|
+
* storage, and the official cloud head. Rings received during that proof
|
|
302
|
+
* remain pending and already own their next debounced Detect pass. */
|
|
295
303
|
async flush() {
|
|
296
304
|
if (!this.converged || !this.activeIdentity || !this.cloudState)
|
|
297
305
|
return;
|
|
298
306
|
const identity = this.activeIdentity;
|
|
299
307
|
this.watchHost.suspectAll();
|
|
300
|
-
this.
|
|
308
|
+
// The complete suspicion above contains everything known at this cutoff.
|
|
309
|
+
// A native callback after this assignment sets watchDirty again and must
|
|
310
|
+
// remain a later generation; flush never waits for global Watch silence.
|
|
311
|
+
this.watchDirty = false;
|
|
301
312
|
await this.syncNow();
|
|
302
313
|
if (!this.closing)
|
|
303
314
|
this.ensureWatchers(identity);
|
|
304
|
-
await this.flushObservedChanges();
|
|
305
|
-
if (!this.closing)
|
|
306
|
-
this.ensureWatchers(identity);
|
|
307
|
-
await this.flushObservedChanges();
|
|
308
315
|
}
|
|
309
316
|
async syncNow() {
|
|
310
317
|
if (!this.activeIdentity)
|
|
@@ -317,20 +324,14 @@ export class UserGroundHost {
|
|
|
317
324
|
await this.syncing;
|
|
318
325
|
}
|
|
319
326
|
async flushObservedChanges() {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
this.
|
|
324
|
-
if (dirty) {
|
|
325
|
-
await this.syncNow();
|
|
326
|
-
continue;
|
|
327
|
-
}
|
|
328
|
-
if (this.syncing) {
|
|
329
|
-
await this.syncing;
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
327
|
+
const dirty = this.watchDirty || this.watchHost.hasPending;
|
|
328
|
+
this.watchDirty = false;
|
|
329
|
+
if (dirty) {
|
|
330
|
+
await this.syncNow();
|
|
332
331
|
return;
|
|
333
332
|
}
|
|
333
|
+
if (this.syncing)
|
|
334
|
+
await this.syncing;
|
|
334
335
|
}
|
|
335
336
|
async activateRuntimeTunnel(gatewayPort, runtimeToken) {
|
|
336
337
|
// Files convergence and Watch/Detect own ground currency. Advertising an
|
|
@@ -709,37 +710,62 @@ export class UserGroundHost {
|
|
|
709
710
|
throw new Error("cloud state is unavailable for user-ground Watch");
|
|
710
711
|
const observations = this.watchHost.observations();
|
|
711
712
|
let localRecords;
|
|
713
|
+
let detection;
|
|
714
|
+
let acceptedNotebookRows;
|
|
715
|
+
let notebookRemovals;
|
|
712
716
|
try {
|
|
713
|
-
|
|
717
|
+
const scanned = await scanAndRegister({
|
|
714
718
|
identity,
|
|
715
719
|
userRoot: this.userRoot(identity),
|
|
716
720
|
database: this.databasePath(identity),
|
|
717
721
|
cacheDir: this.cacheDir(identity),
|
|
718
722
|
onScan: this.options.onDetectScan,
|
|
719
|
-
suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion
|
|
723
|
+
suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion])),
|
|
720
724
|
});
|
|
725
|
+
localRecords = [...scanned.records];
|
|
726
|
+
detection = scanned.detection;
|
|
727
|
+
acceptedNotebookRows = scanned.acceptedNotebookRows;
|
|
728
|
+
notebookRemovals = scanned.notebookRemovals;
|
|
721
729
|
}
|
|
722
730
|
catch (error) {
|
|
723
731
|
throw pipelineStageError("entity registration", error);
|
|
724
732
|
}
|
|
733
|
+
const retry = detection.find((plan) => plan.kind === "retry");
|
|
734
|
+
if (retry?.kind === "retry") {
|
|
735
|
+
throw pipelineStageError("detection", new Error(retry.reasons.join("; ")));
|
|
736
|
+
}
|
|
737
|
+
const detectedRecords = detection.flatMap((plan) => plan.kind === "ready"
|
|
738
|
+
? plan.proposals.map((proposal) => ({ mutationId: randomUUID(), proposal }))
|
|
739
|
+
: []);
|
|
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));
|
|
725
746
|
const snapshot = snapshotFromRecords(travelingRecords(mergeMaterializedRoots(state.records, localRecords)));
|
|
726
747
|
const checksum = sha256Hex(stableJson(snapshot));
|
|
727
748
|
if (checksum === state.checksum) {
|
|
749
|
+
commitDetectedState(this.databasePath(identity), {
|
|
750
|
+
acceptedNotebookRows,
|
|
751
|
+
notebookRemovals,
|
|
752
|
+
});
|
|
728
753
|
this.watchHost.settle(observations);
|
|
729
754
|
return;
|
|
730
755
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
756
|
+
commitDetectedState(this.databasePath(identity), {
|
|
757
|
+
acceptedNotebookRows,
|
|
758
|
+
notebookRemovals,
|
|
759
|
+
outbox: {
|
|
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
|
+
},
|
|
768
|
+
});
|
|
743
769
|
await this.drainOutbox(identity);
|
|
744
770
|
this.watchHost.settle(observations);
|
|
745
771
|
}
|
|
@@ -937,6 +963,27 @@ function initializeDatabase(file) {
|
|
|
937
963
|
content_verified_at_ms REAL,
|
|
938
964
|
UNIQUE(resource_id, root_uuid, relative_path)
|
|
939
965
|
);
|
|
966
|
+
CREATE TABLE IF NOT EXISTS detection_notebook (
|
|
967
|
+
uuid TEXT PRIMARY KEY,
|
|
968
|
+
resource_id TEXT NOT NULL,
|
|
969
|
+
root_uuid TEXT NOT NULL,
|
|
970
|
+
type TEXT NOT NULL,
|
|
971
|
+
parent_uuid TEXT,
|
|
972
|
+
name TEXT NOT NULL,
|
|
973
|
+
status TEXT NOT NULL,
|
|
974
|
+
version TEXT NOT NULL,
|
|
975
|
+
payload_version TEXT,
|
|
976
|
+
transport_version TEXT,
|
|
977
|
+
relative_path TEXT NOT NULL,
|
|
978
|
+
absolute_path TEXT NOT NULL,
|
|
979
|
+
device_number INTEGER NOT NULL,
|
|
980
|
+
inode INTEGER NOT NULL,
|
|
981
|
+
byte_size INTEGER,
|
|
982
|
+
modified_time_ms REAL,
|
|
983
|
+
changed_time_ms REAL,
|
|
984
|
+
filesystem_mode INTEGER,
|
|
985
|
+
content_verified_at_ms REAL
|
|
986
|
+
);
|
|
940
987
|
CREATE TABLE IF NOT EXISTS cloud_outbox (
|
|
941
988
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
942
989
|
mutation_id TEXT NOT NULL UNIQUE,
|
|
@@ -945,6 +992,7 @@ function initializeDatabase(file) {
|
|
|
945
992
|
base_version INTEGER NOT NULL,
|
|
946
993
|
snapshot_checksum TEXT NOT NULL,
|
|
947
994
|
snapshot_json TEXT NOT NULL,
|
|
995
|
+
detected_records_json TEXT NOT NULL DEFAULT '[]',
|
|
948
996
|
created_at TEXT NOT NULL
|
|
949
997
|
);
|
|
950
998
|
CREATE TABLE IF NOT EXISTS workspace_add_intents (
|
|
@@ -956,6 +1004,10 @@ function initializeDatabase(file) {
|
|
|
956
1004
|
ON entities(absolute_path);
|
|
957
1005
|
CREATE INDEX IF NOT EXISTS entities_by_physical_identity
|
|
958
1006
|
ON entities(device_number, inode);
|
|
1007
|
+
CREATE INDEX IF NOT EXISTS detection_notebook_by_root_path
|
|
1008
|
+
ON detection_notebook(resource_id, root_uuid, relative_path);
|
|
1009
|
+
CREATE INDEX IF NOT EXISTS detection_notebook_by_physical_identity
|
|
1010
|
+
ON detection_notebook(device_number, inode);
|
|
959
1011
|
`);
|
|
960
1012
|
const currentEntityColumns = new Set(database.prepare("PRAGMA table_info(entities)").all()
|
|
961
1013
|
.map(({ name }) => name));
|
|
@@ -970,9 +1022,20 @@ function initializeDatabase(file) {
|
|
|
970
1022
|
if (!currentEntityColumns.has(name))
|
|
971
1023
|
database.exec(`ALTER TABLE entities ADD COLUMN ${name} ${type}`);
|
|
972
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
|
|
1035
|
+
`);
|
|
973
1036
|
return database;
|
|
974
1037
|
}
|
|
975
|
-
function
|
|
1038
|
+
function readGroundRows(file, table) {
|
|
976
1039
|
if (!existsSync(file))
|
|
977
1040
|
return [];
|
|
978
1041
|
const database = initializeDatabase(file);
|
|
@@ -986,13 +1049,19 @@ function readRows(file) {
|
|
|
986
1049
|
byte_size AS byteSize, modified_time_ms AS modifiedTimeMs,
|
|
987
1050
|
changed_time_ms AS changedTimeMs, filesystem_mode AS filesystemMode,
|
|
988
1051
|
content_verified_at_ms AS contentVerifiedAtMs
|
|
989
|
-
FROM
|
|
1052
|
+
FROM ${table} ORDER BY uuid
|
|
990
1053
|
`).all();
|
|
991
1054
|
}
|
|
992
1055
|
finally {
|
|
993
1056
|
database.close();
|
|
994
1057
|
}
|
|
995
1058
|
}
|
|
1059
|
+
function readRows(file) {
|
|
1060
|
+
return readGroundRows(file, "entities");
|
|
1061
|
+
}
|
|
1062
|
+
function readDetectionNotebook(file) {
|
|
1063
|
+
return readGroundRows(file, "detection_notebook");
|
|
1064
|
+
}
|
|
996
1065
|
function workspaceAddStagingPath(destinationPath, workspaceId) {
|
|
997
1066
|
return join(dirname(destinationPath), `.amalgm-${workspaceId}.adding`);
|
|
998
1067
|
}
|
|
@@ -1107,12 +1176,14 @@ function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
|
|
|
1107
1176
|
const database = initializeDatabase(file);
|
|
1108
1177
|
try {
|
|
1109
1178
|
const exact = database.prepare("SELECT uuid FROM entities WHERE absolute_path = ? LIMIT 2").all(absolutePath);
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
if (ids.length > 1) {
|
|
1113
|
-
throw new Error(`entity graph has ambiguous identity for ${absolutePath}`);
|
|
1179
|
+
if (exact.length > 0) {
|
|
1180
|
+
return selectKnownRegistrationId(absolutePath, exact.map(({ uuid }) => ({ uuid, absolutePath })));
|
|
1114
1181
|
}
|
|
1115
|
-
|
|
1182
|
+
const physical = database.prepare(`
|
|
1183
|
+
SELECT uuid, absolute_path AS absolutePath
|
|
1184
|
+
FROM entities WHERE device_number = ? AND inode = ?
|
|
1185
|
+
`).all(deviceNumber, inode);
|
|
1186
|
+
return selectKnownRegistrationId(absolutePath, physical);
|
|
1116
1187
|
}
|
|
1117
1188
|
finally {
|
|
1118
1189
|
database.close();
|
|
@@ -1126,7 +1197,8 @@ function readOutbox(file) {
|
|
|
1126
1197
|
return database.prepare(`
|
|
1127
1198
|
SELECT mutation_id AS mutationId, resource_id AS resourceId,
|
|
1128
1199
|
authority_epoch AS authorityEpoch, base_version AS baseVersion,
|
|
1129
|
-
snapshot_checksum AS snapshotChecksum, snapshot_json AS snapshotJson
|
|
1200
|
+
snapshot_checksum AS snapshotChecksum, snapshot_json AS snapshotJson,
|
|
1201
|
+
detected_records_json AS detectedRecordsJson
|
|
1130
1202
|
FROM cloud_outbox ORDER BY sequence
|
|
1131
1203
|
`).all();
|
|
1132
1204
|
}
|
|
@@ -1134,6 +1206,33 @@ function readOutbox(file) {
|
|
|
1134
1206
|
database.close();
|
|
1135
1207
|
}
|
|
1136
1208
|
}
|
|
1209
|
+
/** Record is the SQLite transaction that accepts Detect's exact proposals and
|
|
1210
|
+
* advances the last-verified notebook. Watch may settle only after this
|
|
1211
|
+
* function returns. The existing outbox remains the one journal and queue. */
|
|
1212
|
+
function commitDetectedState(file, input) {
|
|
1213
|
+
const database = initializeDatabase(file);
|
|
1214
|
+
try {
|
|
1215
|
+
database.transaction(() => {
|
|
1216
|
+
if (input.outbox) {
|
|
1217
|
+
database.prepare(`
|
|
1218
|
+
INSERT INTO cloud_outbox(
|
|
1219
|
+
mutation_id, resource_id, authority_epoch, base_version,
|
|
1220
|
+
snapshot_checksum, snapshot_json, detected_records_json, created_at
|
|
1221
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1222
|
+
`).run(input.outbox.mutationId, input.outbox.resourceId, input.outbox.authorityEpoch, input.outbox.baseVersion, input.outbox.snapshotChecksum, input.outbox.snapshotJson, input.outbox.detectedRecordsJson, new Date().toISOString());
|
|
1223
|
+
}
|
|
1224
|
+
const remove = database.prepare("DELETE FROM detection_notebook WHERE uuid = ?");
|
|
1225
|
+
for (const uuid of input.notebookRemovals)
|
|
1226
|
+
remove.run(uuid);
|
|
1227
|
+
const upsertNotebook = prepareGroundUpsert(database, "detection_notebook");
|
|
1228
|
+
for (const row of input.acceptedNotebookRows)
|
|
1229
|
+
upsertNotebook.run(...groundRowValues(row));
|
|
1230
|
+
})();
|
|
1231
|
+
}
|
|
1232
|
+
finally {
|
|
1233
|
+
database.close();
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1137
1236
|
function deleteOutbox(file, mutationId) {
|
|
1138
1237
|
const database = initializeDatabase(file);
|
|
1139
1238
|
try {
|
|
@@ -1149,7 +1248,85 @@ function portableRecord(row) {
|
|
|
1149
1248
|
function portableRecords(file) {
|
|
1150
1249
|
return readRows(file).map(portableRecord);
|
|
1151
1250
|
}
|
|
1152
|
-
function
|
|
1251
|
+
function capturedGroundRow(resourceId, rootUUID, row) {
|
|
1252
|
+
return {
|
|
1253
|
+
resourceId,
|
|
1254
|
+
rootUUID,
|
|
1255
|
+
uuid: row.record.uuid,
|
|
1256
|
+
type: row.record.type,
|
|
1257
|
+
parentUUID: row.record.parentUUID,
|
|
1258
|
+
name: row.record.name,
|
|
1259
|
+
status: "active",
|
|
1260
|
+
version: row.record.version,
|
|
1261
|
+
payloadVersion: row.record.payloadVersion,
|
|
1262
|
+
transportVersion: row.record.transportVersion,
|
|
1263
|
+
relativePath: row.relativePath,
|
|
1264
|
+
absolutePath: row.absolutePath,
|
|
1265
|
+
deviceNumber: row.deviceNumber,
|
|
1266
|
+
inode: row.inode,
|
|
1267
|
+
byteSize: row.byteSize,
|
|
1268
|
+
modifiedTimeMs: row.modifiedTimeMs,
|
|
1269
|
+
changedTimeMs: row.changedTimeMs,
|
|
1270
|
+
filesystemMode: row.filesystemMode,
|
|
1271
|
+
contentVerifiedAtMs: row.contentVerifiedAtMs,
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
function sameGroundRow(left, right) {
|
|
1275
|
+
return right !== undefined
|
|
1276
|
+
&& left.resourceId === right.resourceId
|
|
1277
|
+
&& left.rootUUID === right.rootUUID
|
|
1278
|
+
&& left.uuid === right.uuid
|
|
1279
|
+
&& left.type === right.type
|
|
1280
|
+
&& left.parentUUID === right.parentUUID
|
|
1281
|
+
&& left.name === right.name
|
|
1282
|
+
&& left.status === right.status
|
|
1283
|
+
&& left.version === right.version
|
|
1284
|
+
&& left.payloadVersion === right.payloadVersion
|
|
1285
|
+
&& left.transportVersion === right.transportVersion
|
|
1286
|
+
&& left.relativePath === right.relativePath
|
|
1287
|
+
&& left.absolutePath === right.absolutePath
|
|
1288
|
+
&& left.deviceNumber === right.deviceNumber
|
|
1289
|
+
&& left.inode === right.inode
|
|
1290
|
+
&& left.byteSize === right.byteSize
|
|
1291
|
+
&& left.modifiedTimeMs === right.modifiedTimeMs
|
|
1292
|
+
&& left.changedTimeMs === right.changedTimeMs
|
|
1293
|
+
&& left.filesystemMode === right.filesystemMode
|
|
1294
|
+
&& left.contentVerifiedAtMs === right.contentVerifiedAtMs;
|
|
1295
|
+
}
|
|
1296
|
+
function groundRowValues(row) {
|
|
1297
|
+
return [
|
|
1298
|
+
row.uuid, row.resourceId, row.rootUUID, row.type, row.parentUUID, row.name, row.status,
|
|
1299
|
+
row.version, row.payloadVersion, row.transportVersion, row.relativePath, row.absolutePath,
|
|
1300
|
+
row.deviceNumber, row.inode, row.byteSize, row.modifiedTimeMs, row.changedTimeMs,
|
|
1301
|
+
row.filesystemMode, row.contentVerifiedAtMs,
|
|
1302
|
+
];
|
|
1303
|
+
}
|
|
1304
|
+
function prepareGroundUpsert(database, table) {
|
|
1305
|
+
return database.prepare(`
|
|
1306
|
+
INSERT INTO ${table}(${GROUND_ROW_COLUMNS})
|
|
1307
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1308
|
+
ON CONFLICT(uuid) DO UPDATE SET
|
|
1309
|
+
resource_id = excluded.resource_id,
|
|
1310
|
+
root_uuid = excluded.root_uuid,
|
|
1311
|
+
type = excluded.type,
|
|
1312
|
+
parent_uuid = excluded.parent_uuid,
|
|
1313
|
+
name = excluded.name,
|
|
1314
|
+
status = excluded.status,
|
|
1315
|
+
version = excluded.version,
|
|
1316
|
+
payload_version = excluded.payload_version,
|
|
1317
|
+
transport_version = excluded.transport_version,
|
|
1318
|
+
relative_path = excluded.relative_path,
|
|
1319
|
+
absolute_path = excluded.absolute_path,
|
|
1320
|
+
device_number = excluded.device_number,
|
|
1321
|
+
inode = excluded.inode,
|
|
1322
|
+
byte_size = excluded.byte_size,
|
|
1323
|
+
modified_time_ms = excluded.modified_time_ms,
|
|
1324
|
+
changed_time_ms = excluded.changed_time_ms,
|
|
1325
|
+
filesystem_mode = excluded.filesystem_mode,
|
|
1326
|
+
content_verified_at_ms = excluded.content_verified_at_ms
|
|
1327
|
+
`);
|
|
1328
|
+
}
|
|
1329
|
+
function persistRows(file, identity, resourceId, rootUUID, rows, previousRows = [], options = {}) {
|
|
1153
1330
|
const database = initializeDatabase(file);
|
|
1154
1331
|
try {
|
|
1155
1332
|
const replaceIdentity = database.prepare(`
|
|
@@ -1160,37 +1337,15 @@ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows =
|
|
|
1160
1337
|
user_email = excluded.user_email,
|
|
1161
1338
|
device_id = excluded.device_id
|
|
1162
1339
|
`);
|
|
1163
|
-
const upsert = database
|
|
1164
|
-
|
|
1165
|
-
uuid, resource_id, root_uuid, type, parent_uuid, name, status, version, payload_version,
|
|
1166
|
-
transport_version, relative_path, absolute_path, device_number, inode
|
|
1167
|
-
, byte_size, modified_time_ms, changed_time_ms, filesystem_mode, content_verified_at_ms
|
|
1168
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1169
|
-
ON CONFLICT(uuid) DO UPDATE SET
|
|
1170
|
-
resource_id = excluded.resource_id,
|
|
1171
|
-
root_uuid = excluded.root_uuid,
|
|
1172
|
-
type = excluded.type,
|
|
1173
|
-
parent_uuid = excluded.parent_uuid,
|
|
1174
|
-
name = excluded.name,
|
|
1175
|
-
status = excluded.status,
|
|
1176
|
-
version = excluded.version,
|
|
1177
|
-
payload_version = excluded.payload_version,
|
|
1178
|
-
transport_version = excluded.transport_version,
|
|
1179
|
-
relative_path = excluded.relative_path,
|
|
1180
|
-
absolute_path = excluded.absolute_path,
|
|
1181
|
-
device_number = excluded.device_number,
|
|
1182
|
-
inode = excluded.inode,
|
|
1183
|
-
byte_size = excluded.byte_size,
|
|
1184
|
-
modified_time_ms = excluded.modified_time_ms,
|
|
1185
|
-
changed_time_ms = excluded.changed_time_ms,
|
|
1186
|
-
filesystem_mode = excluded.filesystem_mode,
|
|
1187
|
-
content_verified_at_ms = excluded.content_verified_at_ms
|
|
1188
|
-
`);
|
|
1340
|
+
const upsert = prepareGroundUpsert(database, "entities");
|
|
1341
|
+
const upsertNotebook = prepareGroundUpsert(database, "detection_notebook");
|
|
1189
1342
|
const remove = database.prepare("DELETE FROM entities WHERE uuid = ? AND resource_id = ? AND root_uuid = ?");
|
|
1343
|
+
const removeNotebook = database.prepare("DELETE FROM detection_notebook WHERE uuid = ? AND resource_id = ? AND root_uuid = ?");
|
|
1190
1344
|
const previousByUuid = new Map(previousRows.map((row) => [row.uuid, row]));
|
|
1345
|
+
const materializedIds = new Set(database.prepare("SELECT uuid FROM entities").all()
|
|
1346
|
+
.map(({ uuid }) => uuid));
|
|
1191
1347
|
const currentUuids = new Set(rows.map((row) => row.record.uuid));
|
|
1192
|
-
const
|
|
1193
|
-
const old = previousByUuid.get(row.record.uuid);
|
|
1348
|
+
const rowChanged = (row, old) => {
|
|
1194
1349
|
return !old
|
|
1195
1350
|
|| old.resourceId !== resourceId
|
|
1196
1351
|
|| old.rootUUID !== rootUUID
|
|
@@ -1210,15 +1365,27 @@ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows =
|
|
|
1210
1365
|
|| old.changedTimeMs !== row.changedTimeMs
|
|
1211
1366
|
|| old.filesystemMode !== row.filesystemMode
|
|
1212
1367
|
|| old.contentVerifiedAtMs !== row.contentVerifiedAtMs;
|
|
1213
|
-
}
|
|
1368
|
+
};
|
|
1369
|
+
const changed = rows.filter((row) => !materializedIds.has(row.record.uuid)
|
|
1370
|
+
|| rowChanged(row, previousByUuid.get(row.record.uuid)));
|
|
1371
|
+
const notebookChanged = rows.filter((row) => rowChanged(row, previousByUuid.get(row.record.uuid)));
|
|
1214
1372
|
const replace = database.transaction(() => {
|
|
1215
1373
|
replaceIdentity.run(identity.userId, identity.userEmail, identity.deviceId);
|
|
1216
1374
|
for (const old of previousRows) {
|
|
1217
|
-
if (!currentUuids.has(old.uuid))
|
|
1375
|
+
if (!currentUuids.has(old.uuid)) {
|
|
1218
1376
|
remove.run(old.uuid, resourceId, old.rootUUID);
|
|
1377
|
+
if (options.updateNotebook !== false) {
|
|
1378
|
+
removeNotebook.run(old.uuid, resourceId, old.rootUUID);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1219
1381
|
}
|
|
1220
1382
|
for (const row of changed)
|
|
1221
|
-
upsert.run(
|
|
1383
|
+
upsert.run(...groundRowValues(capturedGroundRow(resourceId, rootUUID, row)));
|
|
1384
|
+
if (options.updateNotebook !== false) {
|
|
1385
|
+
for (const row of notebookChanged) {
|
|
1386
|
+
upsertNotebook.run(...groundRowValues(capturedGroundRow(resourceId, rootUUID, row)));
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1222
1389
|
});
|
|
1223
1390
|
replace();
|
|
1224
1391
|
}
|
|
@@ -1233,15 +1400,17 @@ function refreshRevealedRootEvidence(file, workspaceId, absolutePath) {
|
|
|
1233
1400
|
const stats = statFingerprint(lstatSync(absolutePath));
|
|
1234
1401
|
const database = initializeDatabase(file);
|
|
1235
1402
|
try {
|
|
1236
|
-
const
|
|
1237
|
-
UPDATE
|
|
1403
|
+
const refresh = (table) => database.prepare(`
|
|
1404
|
+
UPDATE ${table} SET
|
|
1238
1405
|
device_number = ?, inode = ?, byte_size = ?, modified_time_ms = ?,
|
|
1239
1406
|
changed_time_ms = ?, filesystem_mode = ?
|
|
1240
1407
|
WHERE uuid = ? AND absolute_path = ?
|
|
1241
1408
|
`).run(stats.deviceNumber, stats.inode, stats.byteSize, stats.modifiedTimeMs, stats.changedTimeMs, stats.filesystemMode, workspaceId, absolutePath);
|
|
1409
|
+
const result = refresh("entities");
|
|
1242
1410
|
if (result.changes !== 1) {
|
|
1243
1411
|
throw new Error(`revealed workspace ${workspaceId} has no committed root row`);
|
|
1244
1412
|
}
|
|
1413
|
+
refresh("detection_notebook");
|
|
1245
1414
|
}
|
|
1246
1415
|
finally {
|
|
1247
1416
|
database.close();
|
|
@@ -1364,7 +1533,10 @@ function materializedWorkspaceBindings(bindingDir) {
|
|
|
1364
1533
|
async function scanAndRegister(input) {
|
|
1365
1534
|
const { identity, userRoot, database, cacheDir, suspicions, onScan } = input;
|
|
1366
1535
|
const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
|
|
1367
|
-
const
|
|
1536
|
+
const materialized = readRows(database);
|
|
1537
|
+
const notebook = readDetectionNotebook(database);
|
|
1538
|
+
const existing = suspicions ? notebook : materialized;
|
|
1539
|
+
const notebookByUuid = new Map(notebook.map((row) => [row.uuid, row]));
|
|
1368
1540
|
const ignoreFile = join(userRoot, ".amalgmignore");
|
|
1369
1541
|
const policy = createUserGroundEnrollmentPolicy(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "");
|
|
1370
1542
|
const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
|
|
@@ -1378,8 +1550,9 @@ async function scanAndRegister(input) {
|
|
|
1378
1550
|
const suspicionFor = (root) => {
|
|
1379
1551
|
if (!suspicions || !suspicions.has(root))
|
|
1380
1552
|
return suspicions ? [] : null;
|
|
1381
|
-
return suspicions.get(root);
|
|
1553
|
+
return suspicions.get(root).paths;
|
|
1382
1554
|
};
|
|
1555
|
+
const observationFor = (root) => suspicions?.get(root);
|
|
1383
1556
|
const existingCore = existing.find((row) => row.parentUUID === null && row.absolutePath === userRoot && row.type === "workspace");
|
|
1384
1557
|
const coreUUID = existingCore?.uuid || randomUUID();
|
|
1385
1558
|
const existingReferenceIds = new Set(existing
|
|
@@ -1388,7 +1561,10 @@ async function scanAndRegister(input) {
|
|
|
1388
1561
|
const coreSuspicion = suspicionFor(userRoot);
|
|
1389
1562
|
const missingPortableReference = [...boundRoots.keys()]
|
|
1390
1563
|
.some((workspaceId) => !existingReferenceIds.has(workspaceId));
|
|
1391
|
-
const
|
|
1564
|
+
const acceptedRecords = [];
|
|
1565
|
+
const acceptedNotebookRows = [];
|
|
1566
|
+
const notebookRemovals = new Set();
|
|
1567
|
+
const detection = [];
|
|
1392
1568
|
const scan = async (parameters) => {
|
|
1393
1569
|
const counters = {
|
|
1394
1570
|
entries: 0,
|
|
@@ -1399,6 +1575,22 @@ async function scanAndRegister(input) {
|
|
|
1399
1575
|
};
|
|
1400
1576
|
const started = performance.now();
|
|
1401
1577
|
const result = await scanRoot({ ...parameters, evidence: counters });
|
|
1578
|
+
if (result.detection)
|
|
1579
|
+
detection.push(result.detection);
|
|
1580
|
+
for (const uuid of result.notebookRemovals) {
|
|
1581
|
+
notebookRemovals.add(uuid);
|
|
1582
|
+
notebookByUuid.delete(uuid);
|
|
1583
|
+
}
|
|
1584
|
+
if (result.transferable) {
|
|
1585
|
+
acceptedRecords.push(...result.records);
|
|
1586
|
+
for (const row of result.rows) {
|
|
1587
|
+
const captured = capturedGroundRow(parameters.resourceId, parameters.rootUUID, row);
|
|
1588
|
+
if (sameGroundRow(captured, notebookByUuid.get(captured.uuid)))
|
|
1589
|
+
continue;
|
|
1590
|
+
acceptedNotebookRows.push(captured);
|
|
1591
|
+
notebookByUuid.set(captured.uuid, captured);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1402
1594
|
onScan?.({
|
|
1403
1595
|
rootId: parameters.rootUUID,
|
|
1404
1596
|
directory: parameters.rootPath,
|
|
@@ -1413,7 +1605,7 @@ async function scanAndRegister(input) {
|
|
|
1413
1605
|
await new Promise((resolve) => setImmediate(resolve));
|
|
1414
1606
|
return result;
|
|
1415
1607
|
};
|
|
1416
|
-
|
|
1608
|
+
await scan({
|
|
1417
1609
|
identity,
|
|
1418
1610
|
resourceId,
|
|
1419
1611
|
rootUUID: coreUUID,
|
|
@@ -1428,13 +1620,12 @@ async function scanAndRegister(input) {
|
|
|
1428
1620
|
? [...new Set([...coreSuspicion, "workspaces"])]
|
|
1429
1621
|
: coreSuspicion,
|
|
1430
1622
|
existingRows: existing.filter((row) => row.rootUUID === coreUUID),
|
|
1623
|
+
suspicion: observationFor(userRoot),
|
|
1431
1624
|
});
|
|
1432
|
-
if (!core.transferable)
|
|
1433
|
-
deferredRootIds.add(coreUUID);
|
|
1434
1625
|
for (const { workspaceId, directory: rootPath } of outerBoundRoots) {
|
|
1435
1626
|
const existingRoot = existing.find((row) => row.uuid === workspaceId);
|
|
1436
1627
|
const existingWorkspaceRows = existing.filter((row) => row.resourceId === resourceId && pathWithin(rootPath, row.absolutePath));
|
|
1437
|
-
|
|
1628
|
+
await scan({
|
|
1438
1629
|
identity,
|
|
1439
1630
|
resourceId,
|
|
1440
1631
|
rootUUID: workspaceId,
|
|
@@ -1451,13 +1642,15 @@ async function scanAndRegister(input) {
|
|
|
1451
1642
|
suspects: existingWorkspaceRows.length === 0 ? null : suspicionFor(rootPath),
|
|
1452
1643
|
existingRows: existingWorkspaceRows,
|
|
1453
1644
|
registeredBoundaries,
|
|
1645
|
+
suspicion: observationFor(rootPath),
|
|
1454
1646
|
});
|
|
1455
|
-
if (!scanned.transferable)
|
|
1456
|
-
deferredRootIds.add(workspaceId);
|
|
1457
1647
|
}
|
|
1458
|
-
return
|
|
1459
|
-
|
|
1460
|
-
|
|
1648
|
+
return {
|
|
1649
|
+
records: acceptedRecords,
|
|
1650
|
+
detection,
|
|
1651
|
+
acceptedNotebookRows,
|
|
1652
|
+
notebookRemovals: [...notebookRemovals],
|
|
1653
|
+
};
|
|
1461
1654
|
}
|
|
1462
1655
|
async function scanRoot(input) {
|
|
1463
1656
|
const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, evidence, } = input;
|
|
@@ -1476,32 +1669,43 @@ async function scanRoot(input) {
|
|
|
1476
1669
|
&& suspects.length === 0
|
|
1477
1670
|
&& existingRows.length > 0
|
|
1478
1671
|
&& completeRepositoryEvidence) {
|
|
1672
|
+
const rows = existingRows.map((row) => ({
|
|
1673
|
+
record: portableRecord(row),
|
|
1674
|
+
relativePath: row.relativePath,
|
|
1675
|
+
absolutePath: row.absolutePath,
|
|
1676
|
+
deviceNumber: row.deviceNumber,
|
|
1677
|
+
inode: row.inode,
|
|
1678
|
+
byteSize: row.byteSize,
|
|
1679
|
+
modifiedTimeMs: row.modifiedTimeMs,
|
|
1680
|
+
changedTimeMs: row.changedTimeMs,
|
|
1681
|
+
filesystemMode: row.filesystemMode,
|
|
1682
|
+
contentVerifiedAtMs: row.contentVerifiedAtMs,
|
|
1683
|
+
}));
|
|
1479
1684
|
return {
|
|
1480
1685
|
records: existingRows.map(portableRecord),
|
|
1481
|
-
rows
|
|
1482
|
-
record: portableRecord(row),
|
|
1483
|
-
relativePath: row.relativePath,
|
|
1484
|
-
absolutePath: row.absolutePath,
|
|
1485
|
-
deviceNumber: row.deviceNumber,
|
|
1486
|
-
inode: row.inode,
|
|
1487
|
-
byteSize: row.byteSize,
|
|
1488
|
-
modifiedTimeMs: row.modifiedTimeMs,
|
|
1489
|
-
changedTimeMs: row.changedTimeMs,
|
|
1490
|
-
filesystemMode: row.filesystemMode,
|
|
1491
|
-
contentVerifiedAtMs: row.contentVerifiedAtMs,
|
|
1492
|
-
})),
|
|
1686
|
+
rows,
|
|
1493
1687
|
referenceWorkspaceIds: existingRows
|
|
1494
1688
|
.filter((row) => row.type === "reference" && row.payloadVersion && UUID.test(row.payloadVersion))
|
|
1495
1689
|
.map((row) => row.payloadVersion),
|
|
1496
1690
|
transferable: true,
|
|
1691
|
+
notebookRemovals: [],
|
|
1692
|
+
detection: input.suspicion ? planGroundDetection({
|
|
1693
|
+
rootId: rootUUID,
|
|
1694
|
+
suspicion: input.suspicion,
|
|
1695
|
+
before: existingRows.map(portableRecord),
|
|
1696
|
+
after: rows.map((row) => ({ record: row.record, relativePath: row.relativePath })),
|
|
1697
|
+
}) : null,
|
|
1497
1698
|
};
|
|
1498
1699
|
}
|
|
1499
1700
|
const entries = [];
|
|
1500
1701
|
const referenceWorkspaceIds = new Set();
|
|
1501
1702
|
const rootStats = lstatSync(rootPath);
|
|
1703
|
+
const priorRootType = existingByPath.get("")?.type;
|
|
1704
|
+
const rootRailChanged = priorRootType !== undefined && priorRootType !== rootType;
|
|
1502
1705
|
evidence && (evidence.entries += 1);
|
|
1503
1706
|
entries.push({
|
|
1504
1707
|
uuid: rootUUID,
|
|
1708
|
+
fixedUUID: rootUUID,
|
|
1505
1709
|
type: rootType,
|
|
1506
1710
|
parentUUID: input.rootParentUUID ?? null,
|
|
1507
1711
|
name: rootName,
|
|
@@ -1538,7 +1742,7 @@ async function scanRoot(input) {
|
|
|
1538
1742
|
};
|
|
1539
1743
|
const visit = async (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
|
|
1540
1744
|
? { root: directory, evidencePaths: repositoryEvidencePaths(directory), evidence: null }
|
|
1541
|
-
: null) => {
|
|
1745
|
+
: null, forceObserve = rootRailChanged) => {
|
|
1542
1746
|
const children = readdirSync(directory, { withFileTypes: true })
|
|
1543
1747
|
.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
|
1544
1748
|
for (const child of children) {
|
|
@@ -1561,7 +1765,7 @@ async function scanRoot(input) {
|
|
|
1561
1765
|
const stableDuringCompleteCatchUp = suspects === null
|
|
1562
1766
|
&& existing !== undefined
|
|
1563
1767
|
&& reusableDuringCompleteCatchUp(existing, stats);
|
|
1564
|
-
const observe = existing === undefined
|
|
1768
|
+
const observe = forceObserve || existing === undefined
|
|
1565
1769
|
|| (suspects === null ? !stableDuringCompleteCatchUp : touchesSuspicion(relativePath));
|
|
1566
1770
|
if (!observe && suspects === null)
|
|
1567
1771
|
evidence && (evidence.metadataReused += 1);
|
|
@@ -1585,6 +1789,7 @@ async function scanRoot(input) {
|
|
|
1585
1789
|
: undefined;
|
|
1586
1790
|
let type;
|
|
1587
1791
|
let payloadVersion = null;
|
|
1792
|
+
let linkTarget;
|
|
1588
1793
|
let contentVerifiedAtMs = existing?.contentVerifiedAtMs ?? null;
|
|
1589
1794
|
if (!observe && existing && !stats.isDirectory()) {
|
|
1590
1795
|
type = existing.type;
|
|
@@ -1607,7 +1812,8 @@ async function scanRoot(input) {
|
|
|
1607
1812
|
}
|
|
1608
1813
|
else {
|
|
1609
1814
|
evidence && (evidence.contentReads += 1);
|
|
1610
|
-
|
|
1815
|
+
linkTarget = readlinkSync(absolutePath);
|
|
1816
|
+
const bytes = Buffer.from(linkTarget, "utf8");
|
|
1611
1817
|
payloadVersion = sha256Hex(bytes);
|
|
1612
1818
|
if (!activeRepository)
|
|
1613
1819
|
immutableWriteArtifact(cacheDir, {
|
|
@@ -1641,6 +1847,7 @@ async function scanRoot(input) {
|
|
|
1641
1847
|
contentVerifiedAtMs = Date.now();
|
|
1642
1848
|
entries.push({
|
|
1643
1849
|
uuid,
|
|
1850
|
+
...(registeredBoundaryId ? { fixedUUID: registeredBoundaryId } : {}),
|
|
1644
1851
|
type,
|
|
1645
1852
|
parentUUID,
|
|
1646
1853
|
name: child.name,
|
|
@@ -1649,20 +1856,47 @@ async function scanRoot(input) {
|
|
|
1649
1856
|
payloadVersion,
|
|
1650
1857
|
transportVersion: null,
|
|
1651
1858
|
contentVerifiedAtMs,
|
|
1859
|
+
...(linkTarget !== undefined ? { linkTarget } : {}),
|
|
1652
1860
|
...statFingerprint(stats),
|
|
1653
1861
|
});
|
|
1654
1862
|
if (stats.isDirectory() && !stats.isSymbolicLink()) {
|
|
1863
|
+
const childRailChanged = existing !== undefined && existing.type !== type;
|
|
1655
1864
|
await visit(absolutePath, uuid, type, relativePath, type === "repo.git"
|
|
1656
1865
|
? {
|
|
1657
1866
|
root: absolutePath,
|
|
1658
1867
|
evidencePaths: repositoryEvidencePaths(absolutePath),
|
|
1659
1868
|
evidence: null,
|
|
1660
1869
|
}
|
|
1661
|
-
: activeRepository);
|
|
1870
|
+
: activeRepository, forceObserve || childRailChanged);
|
|
1662
1871
|
}
|
|
1663
1872
|
}
|
|
1664
1873
|
};
|
|
1665
1874
|
await visit(rootPath, rootUUID, rootType);
|
|
1875
|
+
const resolvedIdentity = reconcileGroundUUIDs(existingRows.map((row) => ({
|
|
1876
|
+
uuid: row.uuid,
|
|
1877
|
+
type: row.type,
|
|
1878
|
+
relativePath: row.relativePath,
|
|
1879
|
+
deviceNumber: row.deviceNumber,
|
|
1880
|
+
inode: row.inode,
|
|
1881
|
+
})), entries.map((entry) => ({
|
|
1882
|
+
key: entry.relativePath,
|
|
1883
|
+
type: entry.type,
|
|
1884
|
+
relativePath: entry.relativePath,
|
|
1885
|
+
deviceNumber: entry.deviceNumber,
|
|
1886
|
+
inode: entry.inode,
|
|
1887
|
+
...(entry.fixedUUID ? { fixedUUID: entry.fixedUUID } : {}),
|
|
1888
|
+
})));
|
|
1889
|
+
const resolvedByProvisional = new Map();
|
|
1890
|
+
for (const entry of entries) {
|
|
1891
|
+
const resolved = resolvedIdentity.get(entry.relativePath) ?? entry.uuid;
|
|
1892
|
+
resolvedByProvisional.set(entry.uuid, resolved);
|
|
1893
|
+
entry.uuid = resolved;
|
|
1894
|
+
}
|
|
1895
|
+
for (const entry of entries) {
|
|
1896
|
+
if (entry.parentUUID !== null) {
|
|
1897
|
+
entry.parentUUID = resolvedByProvisional.get(entry.parentUUID) ?? entry.parentUUID;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1666
1900
|
const children = new Map();
|
|
1667
1901
|
for (const entry of entries) {
|
|
1668
1902
|
if (entry.parentUUID === null)
|
|
@@ -1706,10 +1940,13 @@ async function scanRoot(input) {
|
|
|
1706
1940
|
});
|
|
1707
1941
|
const persistCurrentRows = () => {
|
|
1708
1942
|
const rows = rowsFromEntries();
|
|
1709
|
-
persistRows(database, identity, resourceId, rootUUID, rows, existingRows
|
|
1943
|
+
persistRows(database, identity, resourceId, rootUUID, rows, existingRows, {
|
|
1944
|
+
updateNotebook: input.suspicion === undefined,
|
|
1945
|
+
});
|
|
1710
1946
|
return rows;
|
|
1711
1947
|
};
|
|
1712
1948
|
const repositories = entries.filter((entry) => entry.type === "repo.git");
|
|
1949
|
+
const replayByUuid = new Map();
|
|
1713
1950
|
const properDescendantOf = (candidate, ancestor) => ancestor === "" ? candidate !== "" : candidate.startsWith(`${ancestor}/`);
|
|
1714
1951
|
const repositoryOwner = (entry) => repositories
|
|
1715
1952
|
.filter((repository) => repository.uuid !== entry.uuid
|
|
@@ -1734,12 +1971,14 @@ async function scanRoot(input) {
|
|
|
1734
1971
|
repository.transportVersion = priorRow.transportVersion;
|
|
1735
1972
|
}
|
|
1736
1973
|
let prior = null;
|
|
1974
|
+
let priorLayout = null;
|
|
1737
1975
|
if (priorRow?.type === "repo.git" && priorRow.payloadVersion && priorRow.transportVersion) {
|
|
1738
1976
|
const priorPath = join(cacheDir, `${priorRow.transportVersion}.bin`);
|
|
1739
1977
|
if (existsSync(priorPath)) {
|
|
1740
1978
|
try {
|
|
1741
1979
|
const layout = inspectRepositoryTransportFile(priorPath);
|
|
1742
1980
|
if (layout.stateId === priorRow.payloadVersion) {
|
|
1981
|
+
priorLayout = layout;
|
|
1743
1982
|
prior = {
|
|
1744
1983
|
stateId: priorRow.payloadVersion,
|
|
1745
1984
|
transportVersion: priorRow.transportVersion,
|
|
@@ -1789,6 +2028,17 @@ async function scanRoot(input) {
|
|
|
1789
2028
|
contentHash: captured.transportVersion,
|
|
1790
2029
|
}, captured.bytes);
|
|
1791
2030
|
}
|
|
2031
|
+
const currentLayout = inspectRepositoryTransportFile(join(cacheDir, `${captured.transportVersion}.bin`));
|
|
2032
|
+
replayByUuid.set(repository.uuid, {
|
|
2033
|
+
kind: "repo.git",
|
|
2034
|
+
stateId: currentLayout.stateId,
|
|
2035
|
+
cardId: currentLayout.cardId,
|
|
2036
|
+
checkpointId: currentLayout.checkpointId,
|
|
2037
|
+
cardChanged: priorLayout?.cardId !== currentLayout.cardId,
|
|
2038
|
+
checkpointChanged: priorLayout?.checkpointId !== currentLayout.checkpointId,
|
|
2039
|
+
parentTransportVersion: currentLayout.parentTransportVersion,
|
|
2040
|
+
transportVersion: captured.transportVersion,
|
|
2041
|
+
});
|
|
1792
2042
|
}
|
|
1793
2043
|
}
|
|
1794
2044
|
catch {
|
|
@@ -1804,14 +2054,90 @@ async function scanRoot(input) {
|
|
|
1804
2054
|
rows,
|
|
1805
2055
|
referenceWorkspaceIds: [...referenceWorkspaceIds],
|
|
1806
2056
|
transferable: false,
|
|
2057
|
+
notebookRemovals: [],
|
|
2058
|
+
detection: null,
|
|
1807
2059
|
};
|
|
1808
2060
|
}
|
|
1809
2061
|
const rows = persistCurrentRows();
|
|
2062
|
+
const currentUUIDs = new Set(rows.map((row) => row.record.uuid));
|
|
2063
|
+
const notebookRemovals = input.suspicion === undefined ? [] : existingRows
|
|
2064
|
+
.filter((row) => !currentUUIDs.has(row.uuid) && existsSync(row.absolutePath))
|
|
2065
|
+
.map((row) => row.uuid);
|
|
2066
|
+
const before = existingRows.map(portableRecord);
|
|
2067
|
+
const beforeByUuid = new Map(before.map((record) => [record.uuid, record]));
|
|
2068
|
+
const detected = rows.map((row) => {
|
|
2069
|
+
const base = beforeByUuid.get(row.record.uuid) ?? null;
|
|
2070
|
+
const contentChanged = base === null
|
|
2071
|
+
|| base.type !== row.record.type
|
|
2072
|
+
|| base.payloadVersion !== row.record.payloadVersion
|
|
2073
|
+
|| base.transportVersion !== row.record.transportVersion;
|
|
2074
|
+
let replay = { kind: "structure" };
|
|
2075
|
+
if (contentChanged) {
|
|
2076
|
+
switch (row.record.type) {
|
|
2077
|
+
case "file.text":
|
|
2078
|
+
if (!row.record.payloadVersion)
|
|
2079
|
+
throw new Error("detected text file has no content head");
|
|
2080
|
+
replay = {
|
|
2081
|
+
kind: "file.text",
|
|
2082
|
+
mode: "snapshot",
|
|
2083
|
+
basePayloadVersion: base?.payloadVersion && /^[0-9a-f]{64}$/.test(base.payloadVersion)
|
|
2084
|
+
? base.payloadVersion
|
|
2085
|
+
: null,
|
|
2086
|
+
resultPayloadVersion: row.record.payloadVersion,
|
|
2087
|
+
delta: null,
|
|
2088
|
+
};
|
|
2089
|
+
break;
|
|
2090
|
+
case "file.binary": {
|
|
2091
|
+
if (!row.record.payloadVersion)
|
|
2092
|
+
throw new Error("detected binary file has no content head");
|
|
2093
|
+
const manifest = readCachedManifest(cacheDir, row.record.payloadVersion);
|
|
2094
|
+
if (manifest) {
|
|
2095
|
+
replay = {
|
|
2096
|
+
kind: "file.binary",
|
|
2097
|
+
resultPayloadVersion: row.record.payloadVersion,
|
|
2098
|
+
manifest,
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
break;
|
|
2102
|
+
}
|
|
2103
|
+
case "link": {
|
|
2104
|
+
if (!row.record.payloadVersion)
|
|
2105
|
+
throw new Error("detected link has no content head");
|
|
2106
|
+
const target = entries.find((entry) => entry.uuid === row.record.uuid)?.linkTarget;
|
|
2107
|
+
if (target !== undefined) {
|
|
2108
|
+
replay = {
|
|
2109
|
+
kind: "link",
|
|
2110
|
+
resultPayloadVersion: row.record.payloadVersion,
|
|
2111
|
+
target,
|
|
2112
|
+
};
|
|
2113
|
+
}
|
|
2114
|
+
break;
|
|
2115
|
+
}
|
|
2116
|
+
case "repo.git": {
|
|
2117
|
+
const repositoryReplay = replayByUuid.get(row.record.uuid);
|
|
2118
|
+
if (!repositoryReplay)
|
|
2119
|
+
throw new Error("detected repository has no Card + Checkpoint replay");
|
|
2120
|
+
replay = repositoryReplay;
|
|
2121
|
+
break;
|
|
2122
|
+
}
|
|
2123
|
+
default:
|
|
2124
|
+
break;
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
return { record: row.record, relativePath: row.relativePath, replay };
|
|
2128
|
+
});
|
|
1810
2129
|
return {
|
|
1811
2130
|
records: rows.map((row) => row.record),
|
|
1812
2131
|
rows,
|
|
1813
2132
|
referenceWorkspaceIds: [...referenceWorkspaceIds],
|
|
1814
2133
|
transferable: true,
|
|
2134
|
+
notebookRemovals,
|
|
2135
|
+
detection: input.suspicion ? planGroundDetection({
|
|
2136
|
+
rootId: rootUUID,
|
|
2137
|
+
suspicion: input.suspicion,
|
|
2138
|
+
before,
|
|
2139
|
+
after: detected,
|
|
2140
|
+
}) : null,
|
|
1815
2141
|
};
|
|
1816
2142
|
}
|
|
1817
2143
|
function portablePaths(records, boundaryUUIDs = new Set()) {
|
|
@@ -1878,6 +2204,40 @@ function mergeMaterializedRoots(cloudRecords, localRecords) {
|
|
|
1878
2204
|
});
|
|
1879
2205
|
return [...preserved, ...localRecords];
|
|
1880
2206
|
}
|
|
2207
|
+
/** Filesystem disappearance is evidence, not cloud lifecycle authority. Keep
|
|
2208
|
+
* the last logical records in the outgoing graph while their materialized
|
|
2209
|
+
* rows remain absent, and derive container membership from that honest union. */
|
|
2210
|
+
function recordsRetainingMissingEvidence(present, missing) {
|
|
2211
|
+
const byUuid = new Map(present.map((record) => [record.uuid, record]));
|
|
2212
|
+
for (const record of missing) {
|
|
2213
|
+
if (!byUuid.has(record.uuid))
|
|
2214
|
+
byUuid.set(record.uuid, record);
|
|
2215
|
+
}
|
|
2216
|
+
const records = [...byUuid.values()];
|
|
2217
|
+
const activeChildren = new Map();
|
|
2218
|
+
for (const record of records) {
|
|
2219
|
+
if (record.status !== "active" || record.parentUUID === null)
|
|
2220
|
+
continue;
|
|
2221
|
+
const children = activeChildren.get(record.parentUUID) ?? [];
|
|
2222
|
+
children.push(record);
|
|
2223
|
+
activeChildren.set(record.parentUUID, children);
|
|
2224
|
+
}
|
|
2225
|
+
return records.map((record) => {
|
|
2226
|
+
if (record.type !== "workspace" && record.type !== "folder")
|
|
2227
|
+
return record;
|
|
2228
|
+
const payloadVersion = membershipHash(activeChildren.get(record.uuid) ?? [], sha256Hex);
|
|
2229
|
+
return {
|
|
2230
|
+
...record,
|
|
2231
|
+
version: canonicalVersion({
|
|
2232
|
+
type: record.type,
|
|
2233
|
+
parentUUID: record.parentUUID,
|
|
2234
|
+
name: record.name,
|
|
2235
|
+
status: record.status,
|
|
2236
|
+
payloadVersion,
|
|
2237
|
+
}, sha256Hex),
|
|
2238
|
+
};
|
|
2239
|
+
});
|
|
2240
|
+
}
|
|
1881
2241
|
function clearCoreMaterialization(userRoot) {
|
|
1882
2242
|
ensurePrivateDir(userRoot);
|
|
1883
2243
|
for (const entry of readdirSync(userRoot, { withFileTypes: true })) {
|
|
@@ -1889,8 +2249,12 @@ function clearCoreMaterialization(userRoot) {
|
|
|
1889
2249
|
function deleteRootRows(databasePath, resourceId, rootUUID) {
|
|
1890
2250
|
const database = initializeDatabase(databasePath);
|
|
1891
2251
|
try {
|
|
1892
|
-
database.
|
|
1893
|
-
.
|
|
2252
|
+
database.transaction(() => {
|
|
2253
|
+
database.prepare("DELETE FROM entities WHERE resource_id = ? AND root_uuid = ?")
|
|
2254
|
+
.run(resourceId, rootUUID);
|
|
2255
|
+
database.prepare("DELETE FROM detection_notebook WHERE resource_id = ? AND root_uuid = ?")
|
|
2256
|
+
.run(resourceId, rootUUID);
|
|
2257
|
+
})();
|
|
1894
2258
|
}
|
|
1895
2259
|
finally {
|
|
1896
2260
|
database.close();
|