@amalgm/shell 0.1.40 → 0.1.42
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 +41 -2
- package/dist/detection-host.d.ts +48 -0
- package/dist/detection-host.js +122 -0
- package/dist/detection-host.js.map +1 -0
- package/dist/files-register-host.d.ts +18 -0
- package/dist/files-register-host.js +41 -15
- package/dist/files-register-host.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/machine-host.d.ts +6 -1
- package/dist/machine-host.js +1 -0
- package/dist/machine-host.js.map +1 -1
- package/dist/user-ground-host.d.ts +12 -2
- package/dist/user-ground-host.js +769 -176
- package/dist/user-ground-host.js.map +1 -1
- package/package.json +2 -2
package/dist/user-ground-host.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { open } from "node:fs/promises";
|
|
4
|
-
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { buildUserHomeManifest, buildUserManifest, liveMachineStateDir, scopedAmalgmDir, shippedUserHomeDeclaration, } from "@amalgm/core/identity";
|
|
6
6
|
import { CHUNK_BYTES, CONTENT_CONTRACT, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, download as downloadArtifact, deriveUploadManifest, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
|
|
7
7
|
import Database from "better-sqlite3";
|
|
8
8
|
import { atomicCopy, atomicWrite, ensurePrivateDir } from "./filesystem.js";
|
|
9
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 {
|
|
@@ -57,6 +64,11 @@ export class UserGroundHost {
|
|
|
57
64
|
cloud: this.cloudPort(this.activeIdentity),
|
|
58
65
|
local,
|
|
59
66
|
});
|
|
67
|
+
await this.resumeWorkspaceAdds(this.activeIdentity);
|
|
68
|
+
const watchHealth = this.watchHost.health();
|
|
69
|
+
if (watchHealth.state !== "healthy") {
|
|
70
|
+
throw new Error(`Watch coverage is degraded: ${watchHealth.reason ?? "unknown failure"}`);
|
|
71
|
+
}
|
|
60
72
|
const evidence = {
|
|
61
73
|
userId: result.userId,
|
|
62
74
|
userEmail: result.userEmail,
|
|
@@ -64,9 +76,9 @@ export class UserGroundHost {
|
|
|
64
76
|
localRoot: result.localRoot,
|
|
65
77
|
cloudHead: result.cloudHead,
|
|
66
78
|
mode: result.mode,
|
|
67
|
-
entityCount:
|
|
68
|
-
watcherRoots:
|
|
69
|
-
watching:
|
|
79
|
+
entityCount: readRows(this.databasePath(this.activeIdentity)).length,
|
|
80
|
+
watcherRoots: watchHealth.contentHandles,
|
|
81
|
+
watching: true,
|
|
70
82
|
};
|
|
71
83
|
this.converged = true;
|
|
72
84
|
return evidence;
|
|
@@ -140,18 +152,80 @@ export class UserGroundHost {
|
|
|
140
152
|
references: input.references,
|
|
141
153
|
records: input.records,
|
|
142
154
|
destinationParent: input.destinationParent,
|
|
155
|
+
cloudHead: input.cloudHead,
|
|
143
156
|
readContent: (artifact) => this.downloadContent(privateEntityResourceId(identity.userId, sha256Hex), this.cacheDir(identity), artifact),
|
|
157
|
+
onStage: (stage) => this.options.onWorkspaceAddStage?.({
|
|
158
|
+
workspaceId: input.workspace.uuid,
|
|
159
|
+
stage,
|
|
160
|
+
}),
|
|
144
161
|
});
|
|
145
|
-
this.scheduleRescan();
|
|
146
162
|
return installed;
|
|
147
163
|
},
|
|
148
164
|
coverWorkspace: async ({ workspaceId, baseline }) => {
|
|
149
|
-
const
|
|
165
|
+
const pending = readWorkspaceAddIntent(this.databasePath(identity), workspaceId);
|
|
166
|
+
const health = this.ensureWatchers(identity, pending || baseline === "authoritative-install" ? [workspaceId] : []);
|
|
167
|
+
assertHealthyWatch(this.watchHost.evidence(), workspaceId);
|
|
168
|
+
if (pending) {
|
|
169
|
+
await this.options.onWorkspaceAddStage?.({ workspaceId, stage: "watching" });
|
|
170
|
+
deleteWorkspaceAddIntent(this.databasePath(identity), workspaceId);
|
|
171
|
+
}
|
|
150
172
|
return { active: true, roots: health.contentHandles };
|
|
151
173
|
},
|
|
152
174
|
};
|
|
153
175
|
return { register, add };
|
|
154
176
|
}
|
|
177
|
+
/** Resume the same host effect used by the SDK command before readiness.
|
|
178
|
+
* Staged ground has no binding, so recovery cannot race Watch or Detect. */
|
|
179
|
+
async resumeWorkspaceAdds(identity) {
|
|
180
|
+
const database = this.databasePath(identity);
|
|
181
|
+
const userRoot = this.userRoot(identity);
|
|
182
|
+
const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
|
|
183
|
+
for (const intent of readWorkspaceAddIntents(database)) {
|
|
184
|
+
const selection = workspaceAddSelection(intent);
|
|
185
|
+
const visibleAcrossBlindInterval = pathExists(intent.destinationPath);
|
|
186
|
+
const stagedAcrossBlindInterval = pathExists(intent.stagingPath);
|
|
187
|
+
if (stagedAcrossBlindInterval && readRows(database).some((row) => row.resourceId === intent.resourceId && row.rootUUID === intent.workspaceId)) {
|
|
188
|
+
// Rows prove that staging was verified before the crash, not that its
|
|
189
|
+
// bytes remained unchanged while no watcher existed. Return this
|
|
190
|
+
// private install to the intent-only stage and reproduce it from the
|
|
191
|
+
// verified immutable cache before making it visible.
|
|
192
|
+
deleteRootRows(database, intent.resourceId, intent.workspaceId);
|
|
193
|
+
}
|
|
194
|
+
await installCloudWorkspace({
|
|
195
|
+
identity,
|
|
196
|
+
userRoot,
|
|
197
|
+
database,
|
|
198
|
+
cacheDir: this.cacheDir(identity),
|
|
199
|
+
bindingDir,
|
|
200
|
+
resourceId: intent.resourceId,
|
|
201
|
+
workspace: selection.workspace,
|
|
202
|
+
reference: selection.reference,
|
|
203
|
+
references: intent.references,
|
|
204
|
+
records: intent.records,
|
|
205
|
+
destinationParent: dirname(intent.destinationPath),
|
|
206
|
+
cloudHead: intent.cloudHead,
|
|
207
|
+
readContent: (artifact) => this.downloadContent(intent.resourceId, this.cacheDir(identity), artifact),
|
|
208
|
+
onStage: (stage) => this.options.onWorkspaceAddStage?.({
|
|
209
|
+
workspaceId: intent.workspaceId,
|
|
210
|
+
stage,
|
|
211
|
+
}),
|
|
212
|
+
});
|
|
213
|
+
this.ensureWatchers(identity, visibleAcrossBlindInterval ? [] : [intent.workspaceId]);
|
|
214
|
+
assertHealthyWatch(this.watchHost.evidence(), intent.workspaceId);
|
|
215
|
+
if (visibleAcrossBlindInterval) {
|
|
216
|
+
// Ground that was visible while the runtime was absent may have been
|
|
217
|
+
// edited after its verified reveal. It is an ordinary startup blind
|
|
218
|
+
// interval, so Watch must retain complete suspicion and Detect must
|
|
219
|
+
// settle it before the Add responsibility can be cleared.
|
|
220
|
+
await this.flushObservedChanges();
|
|
221
|
+
}
|
|
222
|
+
await this.options.onWorkspaceAddStage?.({
|
|
223
|
+
workspaceId: intent.workspaceId,
|
|
224
|
+
stage: "watching",
|
|
225
|
+
});
|
|
226
|
+
deleteWorkspaceAddIntent(database, intent.workspaceId);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
155
229
|
/** Finish the user-visible register journey inside the one persistent Files
|
|
156
230
|
* owner. The declaration remains durable if any later stage fails, and a
|
|
157
231
|
* retry reuses its UUID through filesCommandPorts().register. */
|
|
@@ -223,21 +297,21 @@ export class UserGroundHost {
|
|
|
223
297
|
watch,
|
|
224
298
|
};
|
|
225
299
|
}
|
|
226
|
-
/**
|
|
227
|
-
*
|
|
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. */
|
|
228
303
|
async flush() {
|
|
229
304
|
if (!this.converged || !this.activeIdentity || !this.cloudState)
|
|
230
305
|
return;
|
|
231
306
|
const identity = this.activeIdentity;
|
|
232
307
|
this.watchHost.suspectAll();
|
|
233
|
-
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;
|
|
234
312
|
await this.syncNow();
|
|
235
313
|
if (!this.closing)
|
|
236
314
|
this.ensureWatchers(identity);
|
|
237
|
-
await this.flushObservedChanges();
|
|
238
|
-
if (!this.closing)
|
|
239
|
-
this.ensureWatchers(identity);
|
|
240
|
-
await this.flushObservedChanges();
|
|
241
315
|
}
|
|
242
316
|
async syncNow() {
|
|
243
317
|
if (!this.activeIdentity)
|
|
@@ -250,20 +324,14 @@ export class UserGroundHost {
|
|
|
250
324
|
await this.syncing;
|
|
251
325
|
}
|
|
252
326
|
async flushObservedChanges() {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
this.
|
|
257
|
-
if (dirty) {
|
|
258
|
-
await this.syncNow();
|
|
259
|
-
continue;
|
|
260
|
-
}
|
|
261
|
-
if (this.syncing) {
|
|
262
|
-
await this.syncing;
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
327
|
+
const dirty = this.watchDirty || this.watchHost.hasPending;
|
|
328
|
+
this.watchDirty = false;
|
|
329
|
+
if (dirty) {
|
|
330
|
+
await this.syncNow();
|
|
265
331
|
return;
|
|
266
332
|
}
|
|
333
|
+
if (this.syncing)
|
|
334
|
+
await this.syncing;
|
|
267
335
|
}
|
|
268
336
|
async activateRuntimeTunnel(gatewayPort, runtimeToken) {
|
|
269
337
|
// Files convergence and Watch/Detect own ground currency. Advertising an
|
|
@@ -607,22 +675,6 @@ export class UserGroundHost {
|
|
|
607
675
|
materialized: true,
|
|
608
676
|
});
|
|
609
677
|
}
|
|
610
|
-
for (const row of readRows(this.databasePath(identity))) {
|
|
611
|
-
if (row.parentUUID !== null || row.absolutePath === root)
|
|
612
|
-
continue;
|
|
613
|
-
const directory = directoryExists(row.absolutePath)
|
|
614
|
-
? realpathSync(row.absolutePath)
|
|
615
|
-
: resolve(row.absolutePath);
|
|
616
|
-
const prior = wanted.get(directory);
|
|
617
|
-
if (prior && prior.rootId !== row.rootUUID) {
|
|
618
|
-
throw new Error(`two logical roots claim the same Watch ground: ${directory}`);
|
|
619
|
-
}
|
|
620
|
-
wanted.set(directory, {
|
|
621
|
-
rootId: row.rootUUID,
|
|
622
|
-
directory,
|
|
623
|
-
materialized: directoryExists(directory),
|
|
624
|
-
});
|
|
625
|
-
}
|
|
626
678
|
const health = this.watchHost.reconcile([...wanted.values()], {
|
|
627
679
|
authoritativeBaselineRootIds,
|
|
628
680
|
});
|
|
@@ -658,37 +710,62 @@ export class UserGroundHost {
|
|
|
658
710
|
throw new Error("cloud state is unavailable for user-ground Watch");
|
|
659
711
|
const observations = this.watchHost.observations();
|
|
660
712
|
let localRecords;
|
|
713
|
+
let detection;
|
|
714
|
+
let acceptedNotebookRows;
|
|
715
|
+
let notebookRemovals;
|
|
661
716
|
try {
|
|
662
|
-
|
|
717
|
+
const scanned = await scanAndRegister({
|
|
663
718
|
identity,
|
|
664
719
|
userRoot: this.userRoot(identity),
|
|
665
720
|
database: this.databasePath(identity),
|
|
666
721
|
cacheDir: this.cacheDir(identity),
|
|
667
722
|
onScan: this.options.onDetectScan,
|
|
668
|
-
suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion
|
|
723
|
+
suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion])),
|
|
669
724
|
});
|
|
725
|
+
localRecords = [...scanned.records];
|
|
726
|
+
detection = scanned.detection;
|
|
727
|
+
acceptedNotebookRows = scanned.acceptedNotebookRows;
|
|
728
|
+
notebookRemovals = scanned.notebookRemovals;
|
|
670
729
|
}
|
|
671
730
|
catch (error) {
|
|
672
731
|
throw pipelineStageError("entity registration", error);
|
|
673
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));
|
|
674
746
|
const snapshot = snapshotFromRecords(travelingRecords(mergeMaterializedRoots(state.records, localRecords)));
|
|
675
747
|
const checksum = sha256Hex(stableJson(snapshot));
|
|
676
748
|
if (checksum === state.checksum) {
|
|
749
|
+
commitDetectedState(this.databasePath(identity), {
|
|
750
|
+
acceptedNotebookRows,
|
|
751
|
+
notebookRemovals,
|
|
752
|
+
});
|
|
677
753
|
this.watchHost.settle(observations);
|
|
678
754
|
return;
|
|
679
755
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
+
});
|
|
692
769
|
await this.drainOutbox(identity);
|
|
693
770
|
this.watchHost.settle(observations);
|
|
694
771
|
}
|
|
@@ -886,6 +963,27 @@ function initializeDatabase(file) {
|
|
|
886
963
|
content_verified_at_ms REAL,
|
|
887
964
|
UNIQUE(resource_id, root_uuid, relative_path)
|
|
888
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
|
+
);
|
|
889
987
|
CREATE TABLE IF NOT EXISTS cloud_outbox (
|
|
890
988
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
891
989
|
mutation_id TEXT NOT NULL UNIQUE,
|
|
@@ -894,12 +992,22 @@ function initializeDatabase(file) {
|
|
|
894
992
|
base_version INTEGER NOT NULL,
|
|
895
993
|
snapshot_checksum TEXT NOT NULL,
|
|
896
994
|
snapshot_json TEXT NOT NULL,
|
|
995
|
+
detected_records_json TEXT NOT NULL DEFAULT '[]',
|
|
897
996
|
created_at TEXT NOT NULL
|
|
898
997
|
);
|
|
998
|
+
CREATE TABLE IF NOT EXISTS workspace_add_intents (
|
|
999
|
+
workspace_uuid TEXT PRIMARY KEY,
|
|
1000
|
+
destination_path TEXT NOT NULL UNIQUE,
|
|
1001
|
+
intent_json TEXT NOT NULL
|
|
1002
|
+
);
|
|
899
1003
|
CREATE INDEX IF NOT EXISTS entities_by_absolute_path
|
|
900
1004
|
ON entities(absolute_path);
|
|
901
1005
|
CREATE INDEX IF NOT EXISTS entities_by_physical_identity
|
|
902
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);
|
|
903
1011
|
`);
|
|
904
1012
|
const currentEntityColumns = new Set(database.prepare("PRAGMA table_info(entities)").all()
|
|
905
1013
|
.map(({ name }) => name));
|
|
@@ -914,9 +1022,20 @@ function initializeDatabase(file) {
|
|
|
914
1022
|
if (!currentEntityColumns.has(name))
|
|
915
1023
|
database.exec(`ALTER TABLE entities ADD COLUMN ${name} ${type}`);
|
|
916
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
|
+
`);
|
|
917
1036
|
return database;
|
|
918
1037
|
}
|
|
919
|
-
function
|
|
1038
|
+
function readGroundRows(file, table) {
|
|
920
1039
|
if (!existsSync(file))
|
|
921
1040
|
return [];
|
|
922
1041
|
const database = initializeDatabase(file);
|
|
@@ -930,13 +1049,125 @@ function readRows(file) {
|
|
|
930
1049
|
byte_size AS byteSize, modified_time_ms AS modifiedTimeMs,
|
|
931
1050
|
changed_time_ms AS changedTimeMs, filesystem_mode AS filesystemMode,
|
|
932
1051
|
content_verified_at_ms AS contentVerifiedAtMs
|
|
933
|
-
FROM
|
|
1052
|
+
FROM ${table} ORDER BY uuid
|
|
934
1053
|
`).all();
|
|
935
1054
|
}
|
|
936
1055
|
finally {
|
|
937
1056
|
database.close();
|
|
938
1057
|
}
|
|
939
1058
|
}
|
|
1059
|
+
function readRows(file) {
|
|
1060
|
+
return readGroundRows(file, "entities");
|
|
1061
|
+
}
|
|
1062
|
+
function readDetectionNotebook(file) {
|
|
1063
|
+
return readGroundRows(file, "detection_notebook");
|
|
1064
|
+
}
|
|
1065
|
+
function workspaceAddStagingPath(destinationPath, workspaceId) {
|
|
1066
|
+
return join(dirname(destinationPath), `.amalgm-${workspaceId}.adding`);
|
|
1067
|
+
}
|
|
1068
|
+
function workspaceAddSelection(intent) {
|
|
1069
|
+
const workspace = intent.records.find((record) => record.uuid === intent.workspaceId);
|
|
1070
|
+
if (!workspace || !["workspace", "folder", "repo.git"].includes(workspace.type)) {
|
|
1071
|
+
throw new Error(`workspace Add intent ${intent.workspaceId} has no materialization boundary`);
|
|
1072
|
+
}
|
|
1073
|
+
const references = intent.references.filter((record) => record.type === "reference"
|
|
1074
|
+
&& record.status === "active"
|
|
1075
|
+
&& record.payloadVersion === intent.workspaceId);
|
|
1076
|
+
if (references.length !== 1) {
|
|
1077
|
+
throw new Error(`workspace Add intent ${intent.workspaceId} has no unique durable reference`);
|
|
1078
|
+
}
|
|
1079
|
+
return { workspace, reference: references[0] };
|
|
1080
|
+
}
|
|
1081
|
+
function parseWorkspaceAddIntent(json) {
|
|
1082
|
+
const value = JSON.parse(json);
|
|
1083
|
+
const workspaceId = String(value.workspaceId || "").toLowerCase();
|
|
1084
|
+
const resourceId = String(value.resourceId || "");
|
|
1085
|
+
const cloudHead = String(value.cloudHead || "");
|
|
1086
|
+
const destinationPath = String(value.destinationPath || "");
|
|
1087
|
+
const stagingPath = String(value.stagingPath || "");
|
|
1088
|
+
if (!UUID.test(workspaceId) || !resourceId || !/^[0-9a-f]{64}$/i.test(cloudHead)) {
|
|
1089
|
+
throw new Error("workspace Add intent identity is invalid");
|
|
1090
|
+
}
|
|
1091
|
+
if (!isAbsolute(destinationPath)
|
|
1092
|
+
|| stagingPath !== workspaceAddStagingPath(destinationPath, workspaceId)) {
|
|
1093
|
+
throw new Error(`workspace Add intent ${workspaceId} has invalid placement`);
|
|
1094
|
+
}
|
|
1095
|
+
const intent = {
|
|
1096
|
+
workspaceId,
|
|
1097
|
+
resourceId,
|
|
1098
|
+
cloudHead: cloudHead.toLowerCase(),
|
|
1099
|
+
destinationPath,
|
|
1100
|
+
stagingPath,
|
|
1101
|
+
records: snapshotFromRecords(Array.isArray(value.records) ? value.records : []).records,
|
|
1102
|
+
references: snapshotFromRecords(Array.isArray(value.references) ? value.references : []).records,
|
|
1103
|
+
};
|
|
1104
|
+
const { workspace } = workspaceAddSelection(intent);
|
|
1105
|
+
if (basename(destinationPath) !== workspace.name) {
|
|
1106
|
+
throw new Error(`workspace Add intent ${workspaceId} destination differs from its graph`);
|
|
1107
|
+
}
|
|
1108
|
+
return intent;
|
|
1109
|
+
}
|
|
1110
|
+
function readWorkspaceAddIntent(file, workspaceId) {
|
|
1111
|
+
if (!existsSync(file))
|
|
1112
|
+
return null;
|
|
1113
|
+
const database = initializeDatabase(file);
|
|
1114
|
+
try {
|
|
1115
|
+
const row = database.prepare("SELECT intent_json AS intentJson FROM workspace_add_intents WHERE workspace_uuid = ?").get(workspaceId);
|
|
1116
|
+
return row ? parseWorkspaceAddIntent(row.intentJson) : null;
|
|
1117
|
+
}
|
|
1118
|
+
finally {
|
|
1119
|
+
database.close();
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
function readWorkspaceAddIntents(file) {
|
|
1123
|
+
if (!existsSync(file))
|
|
1124
|
+
return [];
|
|
1125
|
+
const database = initializeDatabase(file);
|
|
1126
|
+
try {
|
|
1127
|
+
return database.prepare("SELECT intent_json AS intentJson FROM workspace_add_intents ORDER BY workspace_uuid").all().map(({ intentJson }) => parseWorkspaceAddIntent(intentJson));
|
|
1128
|
+
}
|
|
1129
|
+
finally {
|
|
1130
|
+
database.close();
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
function persistWorkspaceAddIntent(file, proposed) {
|
|
1134
|
+
const database = initializeDatabase(file);
|
|
1135
|
+
try {
|
|
1136
|
+
let inserted;
|
|
1137
|
+
try {
|
|
1138
|
+
inserted = database.prepare(`
|
|
1139
|
+
INSERT INTO workspace_add_intents(workspace_uuid, destination_path, intent_json)
|
|
1140
|
+
VALUES (?, ?, ?)
|
|
1141
|
+
ON CONFLICT(workspace_uuid) DO NOTHING
|
|
1142
|
+
`).run(proposed.workspaceId, proposed.destinationPath, stableJson(proposed));
|
|
1143
|
+
}
|
|
1144
|
+
catch (error) {
|
|
1145
|
+
const destinationOwner = database.prepare("SELECT workspace_uuid AS workspaceId FROM workspace_add_intents WHERE destination_path = ?").get(proposed.destinationPath);
|
|
1146
|
+
if (destinationOwner) {
|
|
1147
|
+
throw new Error(`workspace Add destination belongs to pending workspace ${destinationOwner.workspaceId}`);
|
|
1148
|
+
}
|
|
1149
|
+
throw error;
|
|
1150
|
+
}
|
|
1151
|
+
if (inserted.changes === 1)
|
|
1152
|
+
return { intent: proposed, created: true };
|
|
1153
|
+
const existing = database.prepare("SELECT intent_json AS intentJson FROM workspace_add_intents WHERE workspace_uuid = ?").get(proposed.workspaceId);
|
|
1154
|
+
if (!existing)
|
|
1155
|
+
throw new Error(`workspace Add intent ${proposed.workspaceId} was not persisted`);
|
|
1156
|
+
return { intent: parseWorkspaceAddIntent(existing.intentJson), created: false };
|
|
1157
|
+
}
|
|
1158
|
+
finally {
|
|
1159
|
+
database.close();
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
function deleteWorkspaceAddIntent(file, workspaceId) {
|
|
1163
|
+
const database = initializeDatabase(file);
|
|
1164
|
+
try {
|
|
1165
|
+
database.prepare("DELETE FROM workspace_add_intents WHERE workspace_uuid = ?").run(workspaceId);
|
|
1166
|
+
}
|
|
1167
|
+
finally {
|
|
1168
|
+
database.close();
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
940
1171
|
/** Register identity lookup is indexed and bounded: the command never walks
|
|
941
1172
|
* the complete entity table merely to discover that a directory is known. */
|
|
942
1173
|
function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
|
|
@@ -945,12 +1176,14 @@ function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
|
|
|
945
1176
|
const database = initializeDatabase(file);
|
|
946
1177
|
try {
|
|
947
1178
|
const exact = database.prepare("SELECT uuid FROM entities WHERE absolute_path = ? LIMIT 2").all(absolutePath);
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
if (ids.length > 1) {
|
|
951
|
-
throw new Error(`entity graph has ambiguous identity for ${absolutePath}`);
|
|
1179
|
+
if (exact.length > 0) {
|
|
1180
|
+
return selectKnownRegistrationId(absolutePath, exact.map(({ uuid }) => ({ uuid, absolutePath })));
|
|
952
1181
|
}
|
|
953
|
-
|
|
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);
|
|
954
1187
|
}
|
|
955
1188
|
finally {
|
|
956
1189
|
database.close();
|
|
@@ -964,7 +1197,8 @@ function readOutbox(file) {
|
|
|
964
1197
|
return database.prepare(`
|
|
965
1198
|
SELECT mutation_id AS mutationId, resource_id AS resourceId,
|
|
966
1199
|
authority_epoch AS authorityEpoch, base_version AS baseVersion,
|
|
967
|
-
snapshot_checksum AS snapshotChecksum, snapshot_json AS snapshotJson
|
|
1200
|
+
snapshot_checksum AS snapshotChecksum, snapshot_json AS snapshotJson,
|
|
1201
|
+
detected_records_json AS detectedRecordsJson
|
|
968
1202
|
FROM cloud_outbox ORDER BY sequence
|
|
969
1203
|
`).all();
|
|
970
1204
|
}
|
|
@@ -972,6 +1206,33 @@ function readOutbox(file) {
|
|
|
972
1206
|
database.close();
|
|
973
1207
|
}
|
|
974
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
|
+
}
|
|
975
1236
|
function deleteOutbox(file, mutationId) {
|
|
976
1237
|
const database = initializeDatabase(file);
|
|
977
1238
|
try {
|
|
@@ -987,7 +1248,63 @@ function portableRecord(row) {
|
|
|
987
1248
|
function portableRecords(file) {
|
|
988
1249
|
return readRows(file).map(portableRecord);
|
|
989
1250
|
}
|
|
990
|
-
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 groundRowValues(row) {
|
|
1275
|
+
return [
|
|
1276
|
+
row.uuid, row.resourceId, row.rootUUID, row.type, row.parentUUID, row.name, row.status,
|
|
1277
|
+
row.version, row.payloadVersion, row.transportVersion, row.relativePath, row.absolutePath,
|
|
1278
|
+
row.deviceNumber, row.inode, row.byteSize, row.modifiedTimeMs, row.changedTimeMs,
|
|
1279
|
+
row.filesystemMode, row.contentVerifiedAtMs,
|
|
1280
|
+
];
|
|
1281
|
+
}
|
|
1282
|
+
function prepareGroundUpsert(database, table) {
|
|
1283
|
+
return database.prepare(`
|
|
1284
|
+
INSERT INTO ${table}(${GROUND_ROW_COLUMNS})
|
|
1285
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1286
|
+
ON CONFLICT(uuid) DO UPDATE SET
|
|
1287
|
+
resource_id = excluded.resource_id,
|
|
1288
|
+
root_uuid = excluded.root_uuid,
|
|
1289
|
+
type = excluded.type,
|
|
1290
|
+
parent_uuid = excluded.parent_uuid,
|
|
1291
|
+
name = excluded.name,
|
|
1292
|
+
status = excluded.status,
|
|
1293
|
+
version = excluded.version,
|
|
1294
|
+
payload_version = excluded.payload_version,
|
|
1295
|
+
transport_version = excluded.transport_version,
|
|
1296
|
+
relative_path = excluded.relative_path,
|
|
1297
|
+
absolute_path = excluded.absolute_path,
|
|
1298
|
+
device_number = excluded.device_number,
|
|
1299
|
+
inode = excluded.inode,
|
|
1300
|
+
byte_size = excluded.byte_size,
|
|
1301
|
+
modified_time_ms = excluded.modified_time_ms,
|
|
1302
|
+
changed_time_ms = excluded.changed_time_ms,
|
|
1303
|
+
filesystem_mode = excluded.filesystem_mode,
|
|
1304
|
+
content_verified_at_ms = excluded.content_verified_at_ms
|
|
1305
|
+
`);
|
|
1306
|
+
}
|
|
1307
|
+
function persistRows(file, identity, resourceId, rootUUID, rows, previousRows = [], options = {}) {
|
|
991
1308
|
const database = initializeDatabase(file);
|
|
992
1309
|
try {
|
|
993
1310
|
const replaceIdentity = database.prepare(`
|
|
@@ -998,37 +1315,15 @@ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows =
|
|
|
998
1315
|
user_email = excluded.user_email,
|
|
999
1316
|
device_id = excluded.device_id
|
|
1000
1317
|
`);
|
|
1001
|
-
const upsert = database
|
|
1002
|
-
|
|
1003
|
-
uuid, resource_id, root_uuid, type, parent_uuid, name, status, version, payload_version,
|
|
1004
|
-
transport_version, relative_path, absolute_path, device_number, inode
|
|
1005
|
-
, byte_size, modified_time_ms, changed_time_ms, filesystem_mode, content_verified_at_ms
|
|
1006
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1007
|
-
ON CONFLICT(uuid) DO UPDATE SET
|
|
1008
|
-
resource_id = excluded.resource_id,
|
|
1009
|
-
root_uuid = excluded.root_uuid,
|
|
1010
|
-
type = excluded.type,
|
|
1011
|
-
parent_uuid = excluded.parent_uuid,
|
|
1012
|
-
name = excluded.name,
|
|
1013
|
-
status = excluded.status,
|
|
1014
|
-
version = excluded.version,
|
|
1015
|
-
payload_version = excluded.payload_version,
|
|
1016
|
-
transport_version = excluded.transport_version,
|
|
1017
|
-
relative_path = excluded.relative_path,
|
|
1018
|
-
absolute_path = excluded.absolute_path,
|
|
1019
|
-
device_number = excluded.device_number,
|
|
1020
|
-
inode = excluded.inode,
|
|
1021
|
-
byte_size = excluded.byte_size,
|
|
1022
|
-
modified_time_ms = excluded.modified_time_ms,
|
|
1023
|
-
changed_time_ms = excluded.changed_time_ms,
|
|
1024
|
-
filesystem_mode = excluded.filesystem_mode,
|
|
1025
|
-
content_verified_at_ms = excluded.content_verified_at_ms
|
|
1026
|
-
`);
|
|
1318
|
+
const upsert = prepareGroundUpsert(database, "entities");
|
|
1319
|
+
const upsertNotebook = prepareGroundUpsert(database, "detection_notebook");
|
|
1027
1320
|
const remove = database.prepare("DELETE FROM entities WHERE uuid = ? AND resource_id = ? AND root_uuid = ?");
|
|
1321
|
+
const removeNotebook = database.prepare("DELETE FROM detection_notebook WHERE uuid = ? AND resource_id = ? AND root_uuid = ?");
|
|
1028
1322
|
const previousByUuid = new Map(previousRows.map((row) => [row.uuid, row]));
|
|
1323
|
+
const materializedIds = new Set(database.prepare("SELECT uuid FROM entities").all()
|
|
1324
|
+
.map(({ uuid }) => uuid));
|
|
1029
1325
|
const currentUuids = new Set(rows.map((row) => row.record.uuid));
|
|
1030
|
-
const
|
|
1031
|
-
const old = previousByUuid.get(row.record.uuid);
|
|
1326
|
+
const rowChanged = (row, old) => {
|
|
1032
1327
|
return !old
|
|
1033
1328
|
|| old.resourceId !== resourceId
|
|
1034
1329
|
|| old.rootUUID !== rootUUID
|
|
@@ -1048,15 +1343,27 @@ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows =
|
|
|
1048
1343
|
|| old.changedTimeMs !== row.changedTimeMs
|
|
1049
1344
|
|| old.filesystemMode !== row.filesystemMode
|
|
1050
1345
|
|| old.contentVerifiedAtMs !== row.contentVerifiedAtMs;
|
|
1051
|
-
}
|
|
1346
|
+
};
|
|
1347
|
+
const changed = rows.filter((row) => !materializedIds.has(row.record.uuid)
|
|
1348
|
+
|| rowChanged(row, previousByUuid.get(row.record.uuid)));
|
|
1349
|
+
const notebookChanged = rows.filter((row) => rowChanged(row, previousByUuid.get(row.record.uuid)));
|
|
1052
1350
|
const replace = database.transaction(() => {
|
|
1053
1351
|
replaceIdentity.run(identity.userId, identity.userEmail, identity.deviceId);
|
|
1054
1352
|
for (const old of previousRows) {
|
|
1055
|
-
if (!currentUuids.has(old.uuid))
|
|
1353
|
+
if (!currentUuids.has(old.uuid)) {
|
|
1056
1354
|
remove.run(old.uuid, resourceId, old.rootUUID);
|
|
1355
|
+
if (options.updateNotebook !== false) {
|
|
1356
|
+
removeNotebook.run(old.uuid, resourceId, old.rootUUID);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1057
1359
|
}
|
|
1058
1360
|
for (const row of changed)
|
|
1059
|
-
upsert.run(
|
|
1361
|
+
upsert.run(...groundRowValues(capturedGroundRow(resourceId, rootUUID, row)));
|
|
1362
|
+
if (options.updateNotebook !== false) {
|
|
1363
|
+
for (const row of notebookChanged) {
|
|
1364
|
+
upsertNotebook.run(...groundRowValues(capturedGroundRow(resourceId, rootUUID, row)));
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1060
1367
|
});
|
|
1061
1368
|
replace();
|
|
1062
1369
|
}
|
|
@@ -1064,6 +1371,29 @@ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows =
|
|
|
1064
1371
|
database.close();
|
|
1065
1372
|
}
|
|
1066
1373
|
}
|
|
1374
|
+
/** A same-filesystem rename preserves identity and bytes but may advance the
|
|
1375
|
+
* root directory's ctime. Refresh that one derived fingerprint before the
|
|
1376
|
+
* binding makes the committed graph observable. */
|
|
1377
|
+
function refreshRevealedRootEvidence(file, workspaceId, absolutePath) {
|
|
1378
|
+
const stats = statFingerprint(lstatSync(absolutePath));
|
|
1379
|
+
const database = initializeDatabase(file);
|
|
1380
|
+
try {
|
|
1381
|
+
const refresh = (table) => database.prepare(`
|
|
1382
|
+
UPDATE ${table} SET
|
|
1383
|
+
device_number = ?, inode = ?, byte_size = ?, modified_time_ms = ?,
|
|
1384
|
+
changed_time_ms = ?, filesystem_mode = ?
|
|
1385
|
+
WHERE uuid = ? AND absolute_path = ?
|
|
1386
|
+
`).run(stats.deviceNumber, stats.inode, stats.byteSize, stats.modifiedTimeMs, stats.changedTimeMs, stats.filesystemMode, workspaceId, absolutePath);
|
|
1387
|
+
const result = refresh("entities");
|
|
1388
|
+
if (result.changes !== 1) {
|
|
1389
|
+
throw new Error(`revealed workspace ${workspaceId} has no committed root row`);
|
|
1390
|
+
}
|
|
1391
|
+
refresh("detection_notebook");
|
|
1392
|
+
}
|
|
1393
|
+
finally {
|
|
1394
|
+
database.close();
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1067
1397
|
function assertDatabaseIdentity(file, identity) {
|
|
1068
1398
|
const database = new Database(file, { readonly: true });
|
|
1069
1399
|
try {
|
|
@@ -1181,7 +1511,9 @@ function materializedWorkspaceBindings(bindingDir) {
|
|
|
1181
1511
|
async function scanAndRegister(input) {
|
|
1182
1512
|
const { identity, userRoot, database, cacheDir, suspicions, onScan } = input;
|
|
1183
1513
|
const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
|
|
1184
|
-
const
|
|
1514
|
+
const materialized = readRows(database);
|
|
1515
|
+
const notebook = readDetectionNotebook(database);
|
|
1516
|
+
const existing = suspicions ? notebook : materialized;
|
|
1185
1517
|
const ignoreFile = join(userRoot, ".amalgmignore");
|
|
1186
1518
|
const policy = createUserGroundEnrollmentPolicy(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "");
|
|
1187
1519
|
const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
|
|
@@ -1195,8 +1527,9 @@ async function scanAndRegister(input) {
|
|
|
1195
1527
|
const suspicionFor = (root) => {
|
|
1196
1528
|
if (!suspicions || !suspicions.has(root))
|
|
1197
1529
|
return suspicions ? [] : null;
|
|
1198
|
-
return suspicions.get(root);
|
|
1530
|
+
return suspicions.get(root).paths;
|
|
1199
1531
|
};
|
|
1532
|
+
const observationFor = (root) => suspicions?.get(root);
|
|
1200
1533
|
const existingCore = existing.find((row) => row.parentUUID === null && row.absolutePath === userRoot && row.type === "workspace");
|
|
1201
1534
|
const coreUUID = existingCore?.uuid || randomUUID();
|
|
1202
1535
|
const existingReferenceIds = new Set(existing
|
|
@@ -1205,7 +1538,10 @@ async function scanAndRegister(input) {
|
|
|
1205
1538
|
const coreSuspicion = suspicionFor(userRoot);
|
|
1206
1539
|
const missingPortableReference = [...boundRoots.keys()]
|
|
1207
1540
|
.some((workspaceId) => !existingReferenceIds.has(workspaceId));
|
|
1208
|
-
const
|
|
1541
|
+
const acceptedRecords = [];
|
|
1542
|
+
const acceptedNotebookRows = [];
|
|
1543
|
+
const notebookRemovals = new Set();
|
|
1544
|
+
const detection = [];
|
|
1209
1545
|
const scan = async (parameters) => {
|
|
1210
1546
|
const counters = {
|
|
1211
1547
|
entries: 0,
|
|
@@ -1216,6 +1552,14 @@ async function scanAndRegister(input) {
|
|
|
1216
1552
|
};
|
|
1217
1553
|
const started = performance.now();
|
|
1218
1554
|
const result = await scanRoot({ ...parameters, evidence: counters });
|
|
1555
|
+
if (result.detection)
|
|
1556
|
+
detection.push(result.detection);
|
|
1557
|
+
for (const uuid of result.notebookRemovals)
|
|
1558
|
+
notebookRemovals.add(uuid);
|
|
1559
|
+
if (result.transferable) {
|
|
1560
|
+
acceptedRecords.push(...result.records);
|
|
1561
|
+
acceptedNotebookRows.push(...result.rows.map((row) => capturedGroundRow(parameters.resourceId, parameters.rootUUID, row)));
|
|
1562
|
+
}
|
|
1219
1563
|
onScan?.({
|
|
1220
1564
|
rootId: parameters.rootUUID,
|
|
1221
1565
|
directory: parameters.rootPath,
|
|
@@ -1230,7 +1574,7 @@ async function scanAndRegister(input) {
|
|
|
1230
1574
|
await new Promise((resolve) => setImmediate(resolve));
|
|
1231
1575
|
return result;
|
|
1232
1576
|
};
|
|
1233
|
-
|
|
1577
|
+
await scan({
|
|
1234
1578
|
identity,
|
|
1235
1579
|
resourceId,
|
|
1236
1580
|
rootUUID: coreUUID,
|
|
@@ -1245,13 +1589,12 @@ async function scanAndRegister(input) {
|
|
|
1245
1589
|
? [...new Set([...coreSuspicion, "workspaces"])]
|
|
1246
1590
|
: coreSuspicion,
|
|
1247
1591
|
existingRows: existing.filter((row) => row.rootUUID === coreUUID),
|
|
1592
|
+
suspicion: observationFor(userRoot),
|
|
1248
1593
|
});
|
|
1249
|
-
if (!core.transferable)
|
|
1250
|
-
deferredRootIds.add(coreUUID);
|
|
1251
1594
|
for (const { workspaceId, directory: rootPath } of outerBoundRoots) {
|
|
1252
1595
|
const existingRoot = existing.find((row) => row.uuid === workspaceId);
|
|
1253
1596
|
const existingWorkspaceRows = existing.filter((row) => row.resourceId === resourceId && pathWithin(rootPath, row.absolutePath));
|
|
1254
|
-
|
|
1597
|
+
await scan({
|
|
1255
1598
|
identity,
|
|
1256
1599
|
resourceId,
|
|
1257
1600
|
rootUUID: workspaceId,
|
|
@@ -1268,13 +1611,15 @@ async function scanAndRegister(input) {
|
|
|
1268
1611
|
suspects: existingWorkspaceRows.length === 0 ? null : suspicionFor(rootPath),
|
|
1269
1612
|
existingRows: existingWorkspaceRows,
|
|
1270
1613
|
registeredBoundaries,
|
|
1614
|
+
suspicion: observationFor(rootPath),
|
|
1271
1615
|
});
|
|
1272
|
-
if (!scanned.transferable)
|
|
1273
|
-
deferredRootIds.add(workspaceId);
|
|
1274
1616
|
}
|
|
1275
|
-
return
|
|
1276
|
-
|
|
1277
|
-
|
|
1617
|
+
return {
|
|
1618
|
+
records: acceptedRecords,
|
|
1619
|
+
detection,
|
|
1620
|
+
acceptedNotebookRows,
|
|
1621
|
+
notebookRemovals: [...notebookRemovals],
|
|
1622
|
+
};
|
|
1278
1623
|
}
|
|
1279
1624
|
async function scanRoot(input) {
|
|
1280
1625
|
const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, evidence, } = input;
|
|
@@ -1293,32 +1638,43 @@ async function scanRoot(input) {
|
|
|
1293
1638
|
&& suspects.length === 0
|
|
1294
1639
|
&& existingRows.length > 0
|
|
1295
1640
|
&& completeRepositoryEvidence) {
|
|
1641
|
+
const rows = existingRows.map((row) => ({
|
|
1642
|
+
record: portableRecord(row),
|
|
1643
|
+
relativePath: row.relativePath,
|
|
1644
|
+
absolutePath: row.absolutePath,
|
|
1645
|
+
deviceNumber: row.deviceNumber,
|
|
1646
|
+
inode: row.inode,
|
|
1647
|
+
byteSize: row.byteSize,
|
|
1648
|
+
modifiedTimeMs: row.modifiedTimeMs,
|
|
1649
|
+
changedTimeMs: row.changedTimeMs,
|
|
1650
|
+
filesystemMode: row.filesystemMode,
|
|
1651
|
+
contentVerifiedAtMs: row.contentVerifiedAtMs,
|
|
1652
|
+
}));
|
|
1296
1653
|
return {
|
|
1297
1654
|
records: existingRows.map(portableRecord),
|
|
1298
|
-
rows
|
|
1299
|
-
record: portableRecord(row),
|
|
1300
|
-
relativePath: row.relativePath,
|
|
1301
|
-
absolutePath: row.absolutePath,
|
|
1302
|
-
deviceNumber: row.deviceNumber,
|
|
1303
|
-
inode: row.inode,
|
|
1304
|
-
byteSize: row.byteSize,
|
|
1305
|
-
modifiedTimeMs: row.modifiedTimeMs,
|
|
1306
|
-
changedTimeMs: row.changedTimeMs,
|
|
1307
|
-
filesystemMode: row.filesystemMode,
|
|
1308
|
-
contentVerifiedAtMs: row.contentVerifiedAtMs,
|
|
1309
|
-
})),
|
|
1655
|
+
rows,
|
|
1310
1656
|
referenceWorkspaceIds: existingRows
|
|
1311
1657
|
.filter((row) => row.type === "reference" && row.payloadVersion && UUID.test(row.payloadVersion))
|
|
1312
1658
|
.map((row) => row.payloadVersion),
|
|
1313
1659
|
transferable: true,
|
|
1660
|
+
notebookRemovals: [],
|
|
1661
|
+
detection: input.suspicion ? planGroundDetection({
|
|
1662
|
+
rootId: rootUUID,
|
|
1663
|
+
suspicion: input.suspicion,
|
|
1664
|
+
before: existingRows.map(portableRecord),
|
|
1665
|
+
after: rows.map((row) => ({ record: row.record, relativePath: row.relativePath })),
|
|
1666
|
+
}) : null,
|
|
1314
1667
|
};
|
|
1315
1668
|
}
|
|
1316
1669
|
const entries = [];
|
|
1317
1670
|
const referenceWorkspaceIds = new Set();
|
|
1318
1671
|
const rootStats = lstatSync(rootPath);
|
|
1672
|
+
const priorRootType = existingByPath.get("")?.type;
|
|
1673
|
+
const rootRailChanged = priorRootType !== undefined && priorRootType !== rootType;
|
|
1319
1674
|
evidence && (evidence.entries += 1);
|
|
1320
1675
|
entries.push({
|
|
1321
1676
|
uuid: rootUUID,
|
|
1677
|
+
fixedUUID: rootUUID,
|
|
1322
1678
|
type: rootType,
|
|
1323
1679
|
parentUUID: input.rootParentUUID ?? null,
|
|
1324
1680
|
name: rootName,
|
|
@@ -1355,7 +1711,7 @@ async function scanRoot(input) {
|
|
|
1355
1711
|
};
|
|
1356
1712
|
const visit = async (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
|
|
1357
1713
|
? { root: directory, evidencePaths: repositoryEvidencePaths(directory), evidence: null }
|
|
1358
|
-
: null) => {
|
|
1714
|
+
: null, forceObserve = rootRailChanged) => {
|
|
1359
1715
|
const children = readdirSync(directory, { withFileTypes: true })
|
|
1360
1716
|
.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
|
1361
1717
|
for (const child of children) {
|
|
@@ -1378,7 +1734,7 @@ async function scanRoot(input) {
|
|
|
1378
1734
|
const stableDuringCompleteCatchUp = suspects === null
|
|
1379
1735
|
&& existing !== undefined
|
|
1380
1736
|
&& reusableDuringCompleteCatchUp(existing, stats);
|
|
1381
|
-
const observe = existing === undefined
|
|
1737
|
+
const observe = forceObserve || existing === undefined
|
|
1382
1738
|
|| (suspects === null ? !stableDuringCompleteCatchUp : touchesSuspicion(relativePath));
|
|
1383
1739
|
if (!observe && suspects === null)
|
|
1384
1740
|
evidence && (evidence.metadataReused += 1);
|
|
@@ -1402,6 +1758,7 @@ async function scanRoot(input) {
|
|
|
1402
1758
|
: undefined;
|
|
1403
1759
|
let type;
|
|
1404
1760
|
let payloadVersion = null;
|
|
1761
|
+
let linkTarget;
|
|
1405
1762
|
let contentVerifiedAtMs = existing?.contentVerifiedAtMs ?? null;
|
|
1406
1763
|
if (!observe && existing && !stats.isDirectory()) {
|
|
1407
1764
|
type = existing.type;
|
|
@@ -1424,7 +1781,8 @@ async function scanRoot(input) {
|
|
|
1424
1781
|
}
|
|
1425
1782
|
else {
|
|
1426
1783
|
evidence && (evidence.contentReads += 1);
|
|
1427
|
-
|
|
1784
|
+
linkTarget = readlinkSync(absolutePath);
|
|
1785
|
+
const bytes = Buffer.from(linkTarget, "utf8");
|
|
1428
1786
|
payloadVersion = sha256Hex(bytes);
|
|
1429
1787
|
if (!activeRepository)
|
|
1430
1788
|
immutableWriteArtifact(cacheDir, {
|
|
@@ -1458,6 +1816,7 @@ async function scanRoot(input) {
|
|
|
1458
1816
|
contentVerifiedAtMs = Date.now();
|
|
1459
1817
|
entries.push({
|
|
1460
1818
|
uuid,
|
|
1819
|
+
...(registeredBoundaryId ? { fixedUUID: registeredBoundaryId } : {}),
|
|
1461
1820
|
type,
|
|
1462
1821
|
parentUUID,
|
|
1463
1822
|
name: child.name,
|
|
@@ -1466,20 +1825,47 @@ async function scanRoot(input) {
|
|
|
1466
1825
|
payloadVersion,
|
|
1467
1826
|
transportVersion: null,
|
|
1468
1827
|
contentVerifiedAtMs,
|
|
1828
|
+
...(linkTarget !== undefined ? { linkTarget } : {}),
|
|
1469
1829
|
...statFingerprint(stats),
|
|
1470
1830
|
});
|
|
1471
1831
|
if (stats.isDirectory() && !stats.isSymbolicLink()) {
|
|
1832
|
+
const childRailChanged = existing !== undefined && existing.type !== type;
|
|
1472
1833
|
await visit(absolutePath, uuid, type, relativePath, type === "repo.git"
|
|
1473
1834
|
? {
|
|
1474
1835
|
root: absolutePath,
|
|
1475
1836
|
evidencePaths: repositoryEvidencePaths(absolutePath),
|
|
1476
1837
|
evidence: null,
|
|
1477
1838
|
}
|
|
1478
|
-
: activeRepository);
|
|
1839
|
+
: activeRepository, forceObserve || childRailChanged);
|
|
1479
1840
|
}
|
|
1480
1841
|
}
|
|
1481
1842
|
};
|
|
1482
1843
|
await visit(rootPath, rootUUID, rootType);
|
|
1844
|
+
const resolvedIdentity = reconcileGroundUUIDs(existingRows.map((row) => ({
|
|
1845
|
+
uuid: row.uuid,
|
|
1846
|
+
type: row.type,
|
|
1847
|
+
relativePath: row.relativePath,
|
|
1848
|
+
deviceNumber: row.deviceNumber,
|
|
1849
|
+
inode: row.inode,
|
|
1850
|
+
})), entries.map((entry) => ({
|
|
1851
|
+
key: entry.relativePath,
|
|
1852
|
+
type: entry.type,
|
|
1853
|
+
relativePath: entry.relativePath,
|
|
1854
|
+
deviceNumber: entry.deviceNumber,
|
|
1855
|
+
inode: entry.inode,
|
|
1856
|
+
...(entry.fixedUUID ? { fixedUUID: entry.fixedUUID } : {}),
|
|
1857
|
+
})));
|
|
1858
|
+
const resolvedByProvisional = new Map();
|
|
1859
|
+
for (const entry of entries) {
|
|
1860
|
+
const resolved = resolvedIdentity.get(entry.relativePath) ?? entry.uuid;
|
|
1861
|
+
resolvedByProvisional.set(entry.uuid, resolved);
|
|
1862
|
+
entry.uuid = resolved;
|
|
1863
|
+
}
|
|
1864
|
+
for (const entry of entries) {
|
|
1865
|
+
if (entry.parentUUID !== null) {
|
|
1866
|
+
entry.parentUUID = resolvedByProvisional.get(entry.parentUUID) ?? entry.parentUUID;
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1483
1869
|
const children = new Map();
|
|
1484
1870
|
for (const entry of entries) {
|
|
1485
1871
|
if (entry.parentUUID === null)
|
|
@@ -1523,10 +1909,13 @@ async function scanRoot(input) {
|
|
|
1523
1909
|
});
|
|
1524
1910
|
const persistCurrentRows = () => {
|
|
1525
1911
|
const rows = rowsFromEntries();
|
|
1526
|
-
persistRows(database, identity, resourceId, rootUUID, rows, existingRows
|
|
1912
|
+
persistRows(database, identity, resourceId, rootUUID, rows, existingRows, {
|
|
1913
|
+
updateNotebook: input.suspicion === undefined,
|
|
1914
|
+
});
|
|
1527
1915
|
return rows;
|
|
1528
1916
|
};
|
|
1529
1917
|
const repositories = entries.filter((entry) => entry.type === "repo.git");
|
|
1918
|
+
const replayByUuid = new Map();
|
|
1530
1919
|
const properDescendantOf = (candidate, ancestor) => ancestor === "" ? candidate !== "" : candidate.startsWith(`${ancestor}/`);
|
|
1531
1920
|
const repositoryOwner = (entry) => repositories
|
|
1532
1921
|
.filter((repository) => repository.uuid !== entry.uuid
|
|
@@ -1551,12 +1940,14 @@ async function scanRoot(input) {
|
|
|
1551
1940
|
repository.transportVersion = priorRow.transportVersion;
|
|
1552
1941
|
}
|
|
1553
1942
|
let prior = null;
|
|
1943
|
+
let priorLayout = null;
|
|
1554
1944
|
if (priorRow?.type === "repo.git" && priorRow.payloadVersion && priorRow.transportVersion) {
|
|
1555
1945
|
const priorPath = join(cacheDir, `${priorRow.transportVersion}.bin`);
|
|
1556
1946
|
if (existsSync(priorPath)) {
|
|
1557
1947
|
try {
|
|
1558
1948
|
const layout = inspectRepositoryTransportFile(priorPath);
|
|
1559
1949
|
if (layout.stateId === priorRow.payloadVersion) {
|
|
1950
|
+
priorLayout = layout;
|
|
1560
1951
|
prior = {
|
|
1561
1952
|
stateId: priorRow.payloadVersion,
|
|
1562
1953
|
transportVersion: priorRow.transportVersion,
|
|
@@ -1606,6 +1997,17 @@ async function scanRoot(input) {
|
|
|
1606
1997
|
contentHash: captured.transportVersion,
|
|
1607
1998
|
}, captured.bytes);
|
|
1608
1999
|
}
|
|
2000
|
+
const currentLayout = inspectRepositoryTransportFile(join(cacheDir, `${captured.transportVersion}.bin`));
|
|
2001
|
+
replayByUuid.set(repository.uuid, {
|
|
2002
|
+
kind: "repo.git",
|
|
2003
|
+
stateId: currentLayout.stateId,
|
|
2004
|
+
cardId: currentLayout.cardId,
|
|
2005
|
+
checkpointId: currentLayout.checkpointId,
|
|
2006
|
+
cardChanged: priorLayout?.cardId !== currentLayout.cardId,
|
|
2007
|
+
checkpointChanged: priorLayout?.checkpointId !== currentLayout.checkpointId,
|
|
2008
|
+
parentTransportVersion: currentLayout.parentTransportVersion,
|
|
2009
|
+
transportVersion: captured.transportVersion,
|
|
2010
|
+
});
|
|
1609
2011
|
}
|
|
1610
2012
|
}
|
|
1611
2013
|
catch {
|
|
@@ -1621,14 +2023,90 @@ async function scanRoot(input) {
|
|
|
1621
2023
|
rows,
|
|
1622
2024
|
referenceWorkspaceIds: [...referenceWorkspaceIds],
|
|
1623
2025
|
transferable: false,
|
|
2026
|
+
notebookRemovals: [],
|
|
2027
|
+
detection: null,
|
|
1624
2028
|
};
|
|
1625
2029
|
}
|
|
1626
2030
|
const rows = persistCurrentRows();
|
|
2031
|
+
const currentUUIDs = new Set(rows.map((row) => row.record.uuid));
|
|
2032
|
+
const notebookRemovals = input.suspicion === undefined ? [] : existingRows
|
|
2033
|
+
.filter((row) => !currentUUIDs.has(row.uuid) && existsSync(row.absolutePath))
|
|
2034
|
+
.map((row) => row.uuid);
|
|
2035
|
+
const before = existingRows.map(portableRecord);
|
|
2036
|
+
const beforeByUuid = new Map(before.map((record) => [record.uuid, record]));
|
|
2037
|
+
const detected = rows.map((row) => {
|
|
2038
|
+
const base = beforeByUuid.get(row.record.uuid) ?? null;
|
|
2039
|
+
const contentChanged = base === null
|
|
2040
|
+
|| base.type !== row.record.type
|
|
2041
|
+
|| base.payloadVersion !== row.record.payloadVersion
|
|
2042
|
+
|| base.transportVersion !== row.record.transportVersion;
|
|
2043
|
+
let replay = { kind: "structure" };
|
|
2044
|
+
if (contentChanged) {
|
|
2045
|
+
switch (row.record.type) {
|
|
2046
|
+
case "file.text":
|
|
2047
|
+
if (!row.record.payloadVersion)
|
|
2048
|
+
throw new Error("detected text file has no content head");
|
|
2049
|
+
replay = {
|
|
2050
|
+
kind: "file.text",
|
|
2051
|
+
mode: "snapshot",
|
|
2052
|
+
basePayloadVersion: base?.payloadVersion && /^[0-9a-f]{64}$/.test(base.payloadVersion)
|
|
2053
|
+
? base.payloadVersion
|
|
2054
|
+
: null,
|
|
2055
|
+
resultPayloadVersion: row.record.payloadVersion,
|
|
2056
|
+
delta: null,
|
|
2057
|
+
};
|
|
2058
|
+
break;
|
|
2059
|
+
case "file.binary": {
|
|
2060
|
+
if (!row.record.payloadVersion)
|
|
2061
|
+
throw new Error("detected binary file has no content head");
|
|
2062
|
+
const manifest = readCachedManifest(cacheDir, row.record.payloadVersion);
|
|
2063
|
+
if (manifest) {
|
|
2064
|
+
replay = {
|
|
2065
|
+
kind: "file.binary",
|
|
2066
|
+
resultPayloadVersion: row.record.payloadVersion,
|
|
2067
|
+
manifest,
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
break;
|
|
2071
|
+
}
|
|
2072
|
+
case "link": {
|
|
2073
|
+
if (!row.record.payloadVersion)
|
|
2074
|
+
throw new Error("detected link has no content head");
|
|
2075
|
+
const target = entries.find((entry) => entry.uuid === row.record.uuid)?.linkTarget;
|
|
2076
|
+
if (target !== undefined) {
|
|
2077
|
+
replay = {
|
|
2078
|
+
kind: "link",
|
|
2079
|
+
resultPayloadVersion: row.record.payloadVersion,
|
|
2080
|
+
target,
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
break;
|
|
2084
|
+
}
|
|
2085
|
+
case "repo.git": {
|
|
2086
|
+
const repositoryReplay = replayByUuid.get(row.record.uuid);
|
|
2087
|
+
if (!repositoryReplay)
|
|
2088
|
+
throw new Error("detected repository has no Card + Checkpoint replay");
|
|
2089
|
+
replay = repositoryReplay;
|
|
2090
|
+
break;
|
|
2091
|
+
}
|
|
2092
|
+
default:
|
|
2093
|
+
break;
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
return { record: row.record, relativePath: row.relativePath, replay };
|
|
2097
|
+
});
|
|
1627
2098
|
return {
|
|
1628
2099
|
records: rows.map((row) => row.record),
|
|
1629
2100
|
rows,
|
|
1630
2101
|
referenceWorkspaceIds: [...referenceWorkspaceIds],
|
|
1631
2102
|
transferable: true,
|
|
2103
|
+
notebookRemovals,
|
|
2104
|
+
detection: input.suspicion ? planGroundDetection({
|
|
2105
|
+
rootId: rootUUID,
|
|
2106
|
+
suspicion: input.suspicion,
|
|
2107
|
+
before,
|
|
2108
|
+
after: detected,
|
|
2109
|
+
}) : null,
|
|
1632
2110
|
};
|
|
1633
2111
|
}
|
|
1634
2112
|
function portablePaths(records, boundaryUUIDs = new Set()) {
|
|
@@ -1695,6 +2173,40 @@ function mergeMaterializedRoots(cloudRecords, localRecords) {
|
|
|
1695
2173
|
});
|
|
1696
2174
|
return [...preserved, ...localRecords];
|
|
1697
2175
|
}
|
|
2176
|
+
/** Filesystem disappearance is evidence, not cloud lifecycle authority. Keep
|
|
2177
|
+
* the last logical records in the outgoing graph while their materialized
|
|
2178
|
+
* rows remain absent, and derive container membership from that honest union. */
|
|
2179
|
+
function recordsRetainingMissingEvidence(present, missing) {
|
|
2180
|
+
const byUuid = new Map(present.map((record) => [record.uuid, record]));
|
|
2181
|
+
for (const record of missing) {
|
|
2182
|
+
if (!byUuid.has(record.uuid))
|
|
2183
|
+
byUuid.set(record.uuid, record);
|
|
2184
|
+
}
|
|
2185
|
+
const records = [...byUuid.values()];
|
|
2186
|
+
const activeChildren = new Map();
|
|
2187
|
+
for (const record of records) {
|
|
2188
|
+
if (record.status !== "active" || record.parentUUID === null)
|
|
2189
|
+
continue;
|
|
2190
|
+
const children = activeChildren.get(record.parentUUID) ?? [];
|
|
2191
|
+
children.push(record);
|
|
2192
|
+
activeChildren.set(record.parentUUID, children);
|
|
2193
|
+
}
|
|
2194
|
+
return records.map((record) => {
|
|
2195
|
+
if (record.type !== "workspace" && record.type !== "folder")
|
|
2196
|
+
return record;
|
|
2197
|
+
const payloadVersion = membershipHash(activeChildren.get(record.uuid) ?? [], sha256Hex);
|
|
2198
|
+
return {
|
|
2199
|
+
...record,
|
|
2200
|
+
version: canonicalVersion({
|
|
2201
|
+
type: record.type,
|
|
2202
|
+
parentUUID: record.parentUUID,
|
|
2203
|
+
name: record.name,
|
|
2204
|
+
status: record.status,
|
|
2205
|
+
payloadVersion,
|
|
2206
|
+
}, sha256Hex),
|
|
2207
|
+
};
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
1698
2210
|
function clearCoreMaterialization(userRoot) {
|
|
1699
2211
|
ensurePrivateDir(userRoot);
|
|
1700
2212
|
for (const entry of readdirSync(userRoot, { withFileTypes: true })) {
|
|
@@ -1706,8 +2218,12 @@ function clearCoreMaterialization(userRoot) {
|
|
|
1706
2218
|
function deleteRootRows(databasePath, resourceId, rootUUID) {
|
|
1707
2219
|
const database = initializeDatabase(databasePath);
|
|
1708
2220
|
try {
|
|
1709
|
-
database.
|
|
1710
|
-
.
|
|
2221
|
+
database.transaction(() => {
|
|
2222
|
+
database.prepare("DELETE FROM entities WHERE resource_id = ? AND root_uuid = ?")
|
|
2223
|
+
.run(resourceId, rootUUID);
|
|
2224
|
+
database.prepare("DELETE FROM detection_notebook WHERE resource_id = ? AND root_uuid = ?")
|
|
2225
|
+
.run(resourceId, rootUUID);
|
|
2226
|
+
})();
|
|
1711
2227
|
}
|
|
1712
2228
|
finally {
|
|
1713
2229
|
database.close();
|
|
@@ -2012,94 +2528,171 @@ function installCloudWorkspaceReferences(input) {
|
|
|
2012
2528
|
return references;
|
|
2013
2529
|
}
|
|
2014
2530
|
async function installCloudWorkspace(input) {
|
|
2015
|
-
const { identity, userRoot, database, cacheDir, bindingDir, resourceId, workspace, reference, references, records, destinationParent, readContent, } = input;
|
|
2531
|
+
const { identity, userRoot, database, cacheDir, bindingDir, resourceId, workspace, reference, references, records, destinationParent, cloudHead, readContent, onStage, } = input;
|
|
2016
2532
|
const allLocalRows = readRows(database);
|
|
2017
2533
|
const existingBoundary = allLocalRows.find((row) => row.uuid === workspace.uuid);
|
|
2018
2534
|
const existing = existingBoundary
|
|
2019
2535
|
? descendantRows(allLocalRows, workspace.uuid)
|
|
2020
2536
|
: [];
|
|
2537
|
+
const pending = readWorkspaceAddIntent(database, workspace.uuid);
|
|
2021
2538
|
if (existing.length > 0) {
|
|
2022
2539
|
const root = existing.find((row) => row.uuid === workspace.uuid);
|
|
2023
|
-
if (!root
|
|
2540
|
+
if (!root) {
|
|
2024
2541
|
throw new Error(`local workspace ${workspace.uuid} has unresolved placement`);
|
|
2025
2542
|
}
|
|
2543
|
+
const expectedRecords = pending?.records ?? records;
|
|
2026
2544
|
const localRecords = existing.map((row) => Object.fromEntries(PORTABLE_FIELDS.map((field) => [field, row[field]])));
|
|
2027
|
-
if (!sameRecords(travelingRecords(localRecords),
|
|
2545
|
+
if (!sameRecords(travelingRecords(localRecords), expectedRecords)) {
|
|
2028
2546
|
throw new Error(`local workspace ${workspace.uuid} differs from its cloud graph`);
|
|
2029
2547
|
}
|
|
2548
|
+
if (pending && root.absolutePath !== pending.destinationPath) {
|
|
2549
|
+
throw new Error(`workspace ${workspace.uuid} rows differ from its pending destination`);
|
|
2550
|
+
}
|
|
2551
|
+
let revealed = false;
|
|
2552
|
+
if (!pathExists(root.absolutePath)) {
|
|
2553
|
+
if (!pending || !pathExists(pending.stagingPath)) {
|
|
2554
|
+
throw new Error(`local workspace ${workspace.uuid} has unresolved placement`);
|
|
2555
|
+
}
|
|
2556
|
+
renameSync(pending.stagingPath, pending.destinationPath);
|
|
2557
|
+
revealed = true;
|
|
2558
|
+
}
|
|
2559
|
+
else if (pending && pathExists(pending.stagingPath)) {
|
|
2560
|
+
throw new Error(`workspace ${workspace.uuid} has both staged and revealed ground`);
|
|
2561
|
+
}
|
|
2562
|
+
if (pending && pathExists(root.absolutePath)) {
|
|
2563
|
+
refreshRevealedRootEvidence(database, workspace.uuid, root.absolutePath);
|
|
2564
|
+
}
|
|
2565
|
+
if (revealed)
|
|
2566
|
+
await onStage?.("ground-revealed");
|
|
2567
|
+
ensureWorkspaceBinding({
|
|
2568
|
+
bindingDir,
|
|
2569
|
+
workspaceId: workspace.uuid,
|
|
2570
|
+
absolutePath: root.absolutePath,
|
|
2571
|
+
});
|
|
2572
|
+
if (pending)
|
|
2573
|
+
await onStage?.("binding-created");
|
|
2574
|
+
const activeReferences = pending?.references ?? references;
|
|
2575
|
+
const activeSelection = pending ? workspaceAddSelection(pending) : { workspace, reference };
|
|
2030
2576
|
const installedReferences = installCloudWorkspaceReferences({
|
|
2031
2577
|
identity,
|
|
2032
2578
|
userRoot,
|
|
2033
2579
|
database,
|
|
2034
2580
|
bindingDir,
|
|
2035
2581
|
resourceId,
|
|
2036
|
-
workspace,
|
|
2037
|
-
references,
|
|
2582
|
+
workspace: activeSelection.workspace,
|
|
2583
|
+
references: activeReferences,
|
|
2038
2584
|
});
|
|
2039
2585
|
return {
|
|
2040
2586
|
path: root.absolutePath,
|
|
2041
2587
|
rows: existing.length,
|
|
2042
2588
|
records: localRecords,
|
|
2043
|
-
reference,
|
|
2589
|
+
reference: activeSelection.reference,
|
|
2044
2590
|
references: installedReferences,
|
|
2045
|
-
alreadyMaterialized:
|
|
2591
|
+
alreadyMaterialized: pending === null,
|
|
2046
2592
|
};
|
|
2047
2593
|
}
|
|
2048
2594
|
const parent = realpathSync(resolve(destinationParent));
|
|
2049
2595
|
if (!statSync(parent).isDirectory())
|
|
2050
2596
|
throw new Error("destination must be a directory");
|
|
2051
2597
|
const destination = join(parent, workspace.name);
|
|
2598
|
+
if (dirname(destination) !== parent || basename(destination) !== workspace.name) {
|
|
2599
|
+
throw new Error("workspace name must be one path segment");
|
|
2600
|
+
}
|
|
2052
2601
|
const canonicalUserRoot = realpathSync(userRoot);
|
|
2053
2602
|
if (pathWithin(canonicalUserRoot, destination) || pathWithin(destination, canonicalUserRoot)) {
|
|
2054
2603
|
throw new Error("files add destination must be outside the Amalgm user ground");
|
|
2055
2604
|
}
|
|
2056
|
-
if (pathExists(destination))
|
|
2057
|
-
throw new Error(`workspace destination already exists: ${destination}`);
|
|
2058
2605
|
ensurePrivateDir(bindingDir);
|
|
2059
2606
|
const binding = join(bindingDir, workspace.uuid);
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
persistRows(database, identity, resourceId, workspace.uuid, rows);
|
|
2077
|
-
const installedReferences = installCloudWorkspaceReferences({
|
|
2078
|
-
identity,
|
|
2079
|
-
userRoot,
|
|
2080
|
-
database,
|
|
2081
|
-
bindingDir,
|
|
2607
|
+
let intent = pending;
|
|
2608
|
+
if (!intent) {
|
|
2609
|
+
const stagingPath = workspaceAddStagingPath(destination, workspace.uuid);
|
|
2610
|
+
if (pathExists(destination))
|
|
2611
|
+
throw new Error(`workspace destination already exists: ${destination}`);
|
|
2612
|
+
if (pathExists(stagingPath))
|
|
2613
|
+
throw new Error(`workspace staging path already exists: ${stagingPath}`);
|
|
2614
|
+
if (pathExists(binding))
|
|
2615
|
+
throw new Error(`workspace ${workspace.uuid} already has a local binding`);
|
|
2616
|
+
for (const bound of materializedWorkspaceBindings(bindingDir)) {
|
|
2617
|
+
if (pathWithin(bound.directory, destination) || pathWithin(destination, bound.directory)) {
|
|
2618
|
+
throw new Error(`files add destination overlaps materialized workspace ${bound.workspaceId}`);
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
const written = persistWorkspaceAddIntent(database, {
|
|
2622
|
+
workspaceId: workspace.uuid,
|
|
2082
2623
|
resourceId,
|
|
2083
|
-
|
|
2084
|
-
|
|
2624
|
+
cloudHead,
|
|
2625
|
+
destinationPath: destination,
|
|
2626
|
+
stagingPath,
|
|
2627
|
+
records: snapshotFromRecords(records).records,
|
|
2628
|
+
references: snapshotFromRecords(references).records,
|
|
2085
2629
|
});
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
records: rows.map((row) => row.record),
|
|
2090
|
-
reference,
|
|
2091
|
-
references: installedReferences,
|
|
2092
|
-
alreadyMaterialized: false,
|
|
2093
|
-
};
|
|
2630
|
+
intent = written.intent;
|
|
2631
|
+
if (written.created)
|
|
2632
|
+
await onStage?.("intent-recorded");
|
|
2094
2633
|
}
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2634
|
+
if (intent.destinationPath !== destination) {
|
|
2635
|
+
throw new Error(`workspace ${workspace.uuid} already has a pending Add at ${intent.destinationPath}`);
|
|
2636
|
+
}
|
|
2637
|
+
if (intent.resourceId !== resourceId) {
|
|
2638
|
+
throw new Error(`workspace ${workspace.uuid} Add intent belongs to a different registry`);
|
|
2639
|
+
}
|
|
2640
|
+
if (pathExists(intent.destinationPath)) {
|
|
2641
|
+
throw new Error(`workspace ${workspace.uuid} is visible without committed entity rows`);
|
|
2642
|
+
}
|
|
2643
|
+
if (pathExists(binding)) {
|
|
2644
|
+
throw new Error(`workspace ${workspace.uuid} has a binding without committed entity rows`);
|
|
2645
|
+
}
|
|
2646
|
+
if (pathExists(intent.stagingPath)) {
|
|
2647
|
+
rmSync(intent.stagingPath, { recursive: true, force: true });
|
|
2102
2648
|
}
|
|
2649
|
+
const selection = workspaceAddSelection(intent);
|
|
2650
|
+
const materialized = await materializeRecords({
|
|
2651
|
+
records: intent.records,
|
|
2652
|
+
rootPath: intent.stagingPath,
|
|
2653
|
+
boundaryUUID: intent.workspaceId,
|
|
2654
|
+
cacheDir,
|
|
2655
|
+
bindingDir,
|
|
2656
|
+
readContent,
|
|
2657
|
+
});
|
|
2658
|
+
const rows = projectMaterializedGraph(materialized.rows, materialized.repositories.map((repository) => ({
|
|
2659
|
+
record: repository.record,
|
|
2660
|
+
identity: repository.state.identity,
|
|
2661
|
+
})), sha256Hex).map((row) => ({
|
|
2662
|
+
...row,
|
|
2663
|
+
absolutePath: row.relativePath
|
|
2664
|
+
? join(intent.destinationPath, ...row.relativePath.split("/"))
|
|
2665
|
+
: intent.destinationPath,
|
|
2666
|
+
}));
|
|
2667
|
+
await onStage?.("materialization-verified");
|
|
2668
|
+
persistRows(database, identity, intent.resourceId, intent.workspaceId, rows);
|
|
2669
|
+
await onStage?.("rows-committed");
|
|
2670
|
+
renameSync(intent.stagingPath, intent.destinationPath);
|
|
2671
|
+
refreshRevealedRootEvidence(database, intent.workspaceId, intent.destinationPath);
|
|
2672
|
+
await onStage?.("ground-revealed");
|
|
2673
|
+
ensureWorkspaceBinding({
|
|
2674
|
+
bindingDir,
|
|
2675
|
+
workspaceId: intent.workspaceId,
|
|
2676
|
+
absolutePath: intent.destinationPath,
|
|
2677
|
+
});
|
|
2678
|
+
await onStage?.("binding-created");
|
|
2679
|
+
const installedReferences = installCloudWorkspaceReferences({
|
|
2680
|
+
identity,
|
|
2681
|
+
userRoot,
|
|
2682
|
+
database,
|
|
2683
|
+
bindingDir,
|
|
2684
|
+
resourceId: intent.resourceId,
|
|
2685
|
+
workspace: selection.workspace,
|
|
2686
|
+
references: intent.references,
|
|
2687
|
+
});
|
|
2688
|
+
return {
|
|
2689
|
+
path: intent.destinationPath,
|
|
2690
|
+
rows: rows.length,
|
|
2691
|
+
records: rows.map((row) => row.record),
|
|
2692
|
+
reference: selection.reference,
|
|
2693
|
+
references: installedReferences,
|
|
2694
|
+
alreadyMaterialized: false,
|
|
2695
|
+
};
|
|
2103
2696
|
}
|
|
2104
2697
|
function readyFromFrame(frame) {
|
|
2105
2698
|
const bytes = Buffer.from(String(frame.snapshot_b64 || ""), "base64");
|