@amalgm/shell 0.1.37 → 0.1.39
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 +49 -31
- package/dist/cli.js +4 -7
- package/dist/cli.js.map +1 -1
- package/dist/content-cache-host.d.ts +27 -1
- package/dist/content-cache-host.js +112 -5
- package/dist/content-cache-host.js.map +1 -1
- package/dist/files-register-host.d.ts +10 -2
- package/dist/files-register-host.js +11 -5
- package/dist/files-register-host.js.map +1 -1
- package/dist/machine-host.d.ts +5 -6
- package/dist/machine-host.js +15 -26
- package/dist/machine-host.js.map +1 -1
- package/dist/runtime-command-host.d.ts +1 -1
- package/dist/runtime-command-host.js.map +1 -1
- package/dist/user-ground-host.d.ts +9 -0
- package/dist/user-ground-host.js +237 -87
- package/dist/user-ground-host.js.map +1 -1
- package/dist/watching/host.d.ts +3 -0
- package/dist/watching/host.js +158 -3
- package/dist/watching/host.js.map +1 -1
- package/package.json +2 -2
package/dist/user-ground-host.js
CHANGED
|
@@ -6,7 +6,7 @@ import { buildUserHomeManifest, buildUserManifest, liveMachineStateDir, scopedAm
|
|
|
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
|
-
import { ContentCacheDownload, hashContentFile } from "./content-cache-host.js";
|
|
9
|
+
import { ContentCacheDownload, captureContentFile, hashContentFile, } from "./content-cache-host.js";
|
|
10
10
|
import { decodeContentWireBytes, encodeContentWireBytes } from "./content-wire-codec.js";
|
|
11
11
|
import { WORKSPACE_UUID as UUID, createFilesRegisterPorts, ensureWorkspaceReference, pathExists, pathWithin, referenceWorkspaceId, workspaceBindingDir, } from "./files-register-host.js";
|
|
12
12
|
import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, hasGitMarker, } from "./git-repository-host.js";
|
|
@@ -117,6 +117,7 @@ export class UserGroundHost {
|
|
|
117
117
|
userRoot,
|
|
118
118
|
deviceId: identity.deviceId,
|
|
119
119
|
onReferenceDeclared: () => this.scheduleRescan(),
|
|
120
|
+
findKnownWorkspaceId: ({ absolutePath, deviceNumber, inode }) => findKnownEntityId(this.databasePath(identity), absolutePath, deviceNumber, inode),
|
|
120
121
|
});
|
|
121
122
|
const add = {
|
|
122
123
|
lookupRegistry: async () => {
|
|
@@ -151,6 +152,76 @@ export class UserGroundHost {
|
|
|
151
152
|
};
|
|
152
153
|
return { register, add };
|
|
153
154
|
}
|
|
155
|
+
/** Finish the user-visible register journey inside the one persistent Files
|
|
156
|
+
* owner. The declaration remains durable if any later stage fails, and a
|
|
157
|
+
* retry reuses its UUID through filesCommandPorts().register. */
|
|
158
|
+
async completeWorkspaceRegistration(workspaceId) {
|
|
159
|
+
const identity = this.activeIdentity;
|
|
160
|
+
if (!identity || !this.converged || !this.cloudState) {
|
|
161
|
+
throw new Error("Files register requires a converged authenticated machine");
|
|
162
|
+
}
|
|
163
|
+
let watch;
|
|
164
|
+
try {
|
|
165
|
+
this.ensureWatchers(identity);
|
|
166
|
+
watch = this.watchHost.evidence();
|
|
167
|
+
assertHealthyWatch(watch, workspaceId);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
throw stageError("Watch coverage", error);
|
|
171
|
+
}
|
|
172
|
+
let rows = readRows(this.databasePath(identity));
|
|
173
|
+
if (rows.some((row) => row.uuid === workspaceId)
|
|
174
|
+
&& this.cloudState.records.some((record) => record.uuid === workspaceId)) {
|
|
175
|
+
return {
|
|
176
|
+
registered: true,
|
|
177
|
+
entityCount: descendantRows(rows, workspaceId).length,
|
|
178
|
+
cloudHead: this.cloudState.checksum,
|
|
179
|
+
watch,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
this.watchHost.suspectAll();
|
|
183
|
+
this.watchDirty = false;
|
|
184
|
+
try {
|
|
185
|
+
await this.syncNow();
|
|
186
|
+
if (!this.cloudState.records.some((record) => record.uuid === workspaceId)) {
|
|
187
|
+
// A pre-existing scan may have taken its observation before this
|
|
188
|
+
// declaration. Preserve the declared root as suspicion and give it
|
|
189
|
+
// one scan of its own. Completion is evidence about this workspace,
|
|
190
|
+
// never a demand that unrelated live Watch traffic become silent.
|
|
191
|
+
this.watchHost.suspectAll();
|
|
192
|
+
this.watchDirty = false;
|
|
193
|
+
await this.syncNow();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
const failedStage = filesPipelineStage(error);
|
|
198
|
+
if (failedStage) {
|
|
199
|
+
throw stageError(failedStage, error.cause ?? error);
|
|
200
|
+
}
|
|
201
|
+
throw stageError("entity registration or cloud publication", error);
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
this.ensureWatchers(identity);
|
|
205
|
+
watch = this.watchHost.evidence();
|
|
206
|
+
assertHealthyWatch(watch, workspaceId);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
throw stageError("Watch coverage", error);
|
|
210
|
+
}
|
|
211
|
+
rows = readRows(this.databasePath(identity));
|
|
212
|
+
if (!rows.some((row) => row.uuid === workspaceId)) {
|
|
213
|
+
throw stageError("entity registration", new Error(`workspace ${workspaceId} was not recorded`));
|
|
214
|
+
}
|
|
215
|
+
if (!this.cloudState.records.some((record) => record.uuid === workspaceId)) {
|
|
216
|
+
throw stageError("cloud graph commit", new Error(`workspace ${workspaceId} is absent from the committed head`));
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
registered: true,
|
|
220
|
+
entityCount: descendantRows(rows, workspaceId).length,
|
|
221
|
+
cloudHead: this.cloudState.checksum,
|
|
222
|
+
watch,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
154
225
|
/** Make every observed filesystem change durable in SQLite, immutable
|
|
155
226
|
* content storage, and the official cloud head before returning. */
|
|
156
227
|
async flush() {
|
|
@@ -585,14 +656,20 @@ export class UserGroundHost {
|
|
|
585
656
|
if (!state)
|
|
586
657
|
throw new Error("cloud state is unavailable for user-ground Watch");
|
|
587
658
|
const observations = this.watchHost.observations();
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
659
|
+
let localRecords;
|
|
660
|
+
try {
|
|
661
|
+
localRecords = await scanAndRegister({
|
|
662
|
+
identity,
|
|
663
|
+
userRoot: this.userRoot(identity),
|
|
664
|
+
database: this.databasePath(identity),
|
|
665
|
+
cacheDir: this.cacheDir(identity),
|
|
666
|
+
onScan: this.options.onDetectScan,
|
|
667
|
+
suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion.paths])),
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
throw pipelineStageError("entity registration", error);
|
|
672
|
+
}
|
|
596
673
|
const snapshot = snapshotFromRecords(travelingRecords(mergeMaterializedRoots(state.records, localRecords)));
|
|
597
674
|
const checksum = sha256Hex(stableJson(snapshot));
|
|
598
675
|
if (checksum === state.checksum) {
|
|
@@ -634,24 +711,34 @@ export class UserGroundHost {
|
|
|
634
711
|
async drainOutbox(identity) {
|
|
635
712
|
for (const pending of readOutbox(this.databasePath(identity))) {
|
|
636
713
|
const snapshot = snapshotFromRecords(JSON.parse(pending.snapshotJson).records || []);
|
|
637
|
-
const
|
|
638
|
-
.
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
714
|
+
const locallyMaterializedIds = new Set(readRows(this.databasePath(identity))
|
|
715
|
+
.map((row) => row.uuid));
|
|
716
|
+
try {
|
|
717
|
+
await this.uploadSnapshotContent(identity, pending.resourceId, snapshot.records.filter((record) => locallyMaterializedIds.has(record.uuid)));
|
|
718
|
+
}
|
|
719
|
+
catch (error) {
|
|
720
|
+
throw pipelineStageError("cloud upload", error);
|
|
721
|
+
}
|
|
722
|
+
let frame;
|
|
723
|
+
try {
|
|
724
|
+
frame = await this.wire.request({
|
|
725
|
+
type: "shared.mutation.submit",
|
|
726
|
+
envelope: {
|
|
727
|
+
resourceId: pending.resourceId,
|
|
728
|
+
authorityEpoch: pending.authorityEpoch,
|
|
729
|
+
mutationId: pending.mutationId,
|
|
730
|
+
deviceId: identity.deviceId,
|
|
731
|
+
baseVersion: pending.baseVersion,
|
|
732
|
+
contract: ENTITY_CLOUD_CONTRACT,
|
|
733
|
+
schemaVersion: ENTITY_CLOUD_SCHEMA_VERSION,
|
|
734
|
+
operationKind: "entity.snapshot.replace",
|
|
735
|
+
operation: { kind: "entity.snapshot.replace", snapshot },
|
|
736
|
+
},
|
|
737
|
+
}, ["shared.mutation.ack"]);
|
|
738
|
+
}
|
|
739
|
+
catch (error) {
|
|
740
|
+
throw pipelineStageError("cloud graph commit", error);
|
|
741
|
+
}
|
|
655
742
|
this.cloudState = {
|
|
656
743
|
resourceId: pending.resourceId,
|
|
657
744
|
authorityEpoch: Number(frame.authority_epoch),
|
|
@@ -686,12 +773,48 @@ function normalizedIdentity(identity) {
|
|
|
686
773
|
deviceId: String(identity.deviceId).trim(),
|
|
687
774
|
};
|
|
688
775
|
}
|
|
776
|
+
function stageError(stage, error) {
|
|
777
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
778
|
+
return Object.assign(new Error(`Files register failed during ${stage}: ${message}`), {
|
|
779
|
+
code: `files_register_${stage.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")}`,
|
|
780
|
+
cause: error,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
function pipelineStageError(stage, error) {
|
|
784
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
785
|
+
return Object.assign(new Error(`Files pipeline failed during ${stage}: ${message}`), {
|
|
786
|
+
code: `files_pipeline_${stage.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")}`,
|
|
787
|
+
filesStage: stage,
|
|
788
|
+
cause: error,
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
function filesPipelineStage(error) {
|
|
792
|
+
const stage = error instanceof Error
|
|
793
|
+
? error.filesStage
|
|
794
|
+
: null;
|
|
795
|
+
return typeof stage === "string" && stage ? stage : null;
|
|
796
|
+
}
|
|
797
|
+
function descendantRows(rows, rootUUID) {
|
|
798
|
+
const included = new Set([rootUUID]);
|
|
799
|
+
let changed = true;
|
|
800
|
+
while (changed) {
|
|
801
|
+
changed = false;
|
|
802
|
+
for (const row of rows) {
|
|
803
|
+
if (row.parentUUID !== null && included.has(row.parentUUID) && !included.has(row.uuid)) {
|
|
804
|
+
included.add(row.uuid);
|
|
805
|
+
changed = true;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return rows.filter((row) => included.has(row.uuid));
|
|
810
|
+
}
|
|
689
811
|
function assertHealthyWatch(evidence, rootId) {
|
|
690
812
|
const root = evidence.roots.find((candidate) => candidate.rootId === rootId);
|
|
691
813
|
const content = root?.contentOwner
|
|
692
814
|
? evidence.content.find((candidate) => candidate.directory === root.contentOwner)
|
|
693
815
|
: undefined;
|
|
694
|
-
const
|
|
816
|
+
const ownsContent = root?.contentOwner === root?.directory;
|
|
817
|
+
const address = ownsContent
|
|
695
818
|
? evidence.addresses.find((candidate) => candidate.rootIds.includes(rootId))
|
|
696
819
|
: undefined;
|
|
697
820
|
if (evidence.health.state !== "healthy"
|
|
@@ -699,7 +822,7 @@ function assertHealthyWatch(evidence, rootId) {
|
|
|
699
822
|
|| evidence.health.addressHandles !== evidence.addresses.length
|
|
700
823
|
|| !root?.materialized
|
|
701
824
|
|| !content?.rootIds.includes(rootId)
|
|
702
|
-
|| !address) {
|
|
825
|
+
|| (ownsContent && !address)) {
|
|
703
826
|
throw new Error(`Watch coverage is not healthy for workspace ${rootId}: ${evidence.health.reason ?? "incomplete handle evidence"}`);
|
|
704
827
|
}
|
|
705
828
|
}
|
|
@@ -772,6 +895,10 @@ function initializeDatabase(file) {
|
|
|
772
895
|
snapshot_json TEXT NOT NULL,
|
|
773
896
|
created_at TEXT NOT NULL
|
|
774
897
|
);
|
|
898
|
+
CREATE INDEX IF NOT EXISTS entities_by_absolute_path
|
|
899
|
+
ON entities(absolute_path);
|
|
900
|
+
CREATE INDEX IF NOT EXISTS entities_by_physical_identity
|
|
901
|
+
ON entities(device_number, inode);
|
|
775
902
|
`);
|
|
776
903
|
const currentEntityColumns = new Set(database.prepare("PRAGMA table_info(entities)").all()
|
|
777
904
|
.map(({ name }) => name));
|
|
@@ -809,6 +936,25 @@ function readRows(file) {
|
|
|
809
936
|
database.close();
|
|
810
937
|
}
|
|
811
938
|
}
|
|
939
|
+
/** Register identity lookup is indexed and bounded: the command never walks
|
|
940
|
+
* the complete entity table merely to discover that a directory is known. */
|
|
941
|
+
function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
|
|
942
|
+
if (!existsSync(file))
|
|
943
|
+
return null;
|
|
944
|
+
const database = initializeDatabase(file);
|
|
945
|
+
try {
|
|
946
|
+
const exact = database.prepare("SELECT uuid FROM entities WHERE absolute_path = ? LIMIT 2").all(absolutePath);
|
|
947
|
+
const physical = exact.length > 0 ? exact : database.prepare("SELECT uuid FROM entities WHERE device_number = ? AND inode = ? LIMIT 2").all(deviceNumber, inode);
|
|
948
|
+
const ids = [...new Set(physical.map(({ uuid }) => uuid))];
|
|
949
|
+
if (ids.length > 1) {
|
|
950
|
+
throw new Error(`entity graph has ambiguous identity for ${absolutePath}`);
|
|
951
|
+
}
|
|
952
|
+
return ids[0] ?? null;
|
|
953
|
+
}
|
|
954
|
+
finally {
|
|
955
|
+
database.close();
|
|
956
|
+
}
|
|
957
|
+
}
|
|
812
958
|
function readOutbox(file) {
|
|
813
959
|
if (!existsSync(file))
|
|
814
960
|
return [];
|
|
@@ -906,7 +1052,7 @@ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows =
|
|
|
906
1052
|
replaceIdentity.run(identity.userId, identity.userEmail, identity.deviceId);
|
|
907
1053
|
for (const old of previousRows) {
|
|
908
1054
|
if (!currentUuids.has(old.uuid))
|
|
909
|
-
remove.run(old.uuid, resourceId, rootUUID);
|
|
1055
|
+
remove.run(old.uuid, resourceId, old.rootUUID);
|
|
910
1056
|
}
|
|
911
1057
|
for (const row of changed)
|
|
912
1058
|
upsert.run(row.record.uuid, resourceId, rootUUID, row.record.type, row.record.parentUUID, row.record.name, row.record.status, row.record.version, row.record.payloadVersion, row.record.transportVersion, row.relativePath, row.absolutePath, row.deviceNumber, row.inode, row.byteSize, row.modifiedTimeMs, row.changedTimeMs, row.filesystemMode, row.contentVerifiedAtMs);
|
|
@@ -972,9 +1118,6 @@ function createDeclaredHome(input) {
|
|
|
972
1118
|
}
|
|
973
1119
|
}
|
|
974
1120
|
}
|
|
975
|
-
function fileType(bytes) {
|
|
976
|
-
return classifyFile({ binary: bytes.includes(0) });
|
|
977
|
-
}
|
|
978
1121
|
function slashPath(path) {
|
|
979
1122
|
return path.split(sep).join("/");
|
|
980
1123
|
}
|
|
@@ -1041,8 +1184,13 @@ async function scanAndRegister(input) {
|
|
|
1041
1184
|
const ignoreFile = join(userRoot, ".amalgmignore");
|
|
1042
1185
|
const policy = createUserGroundEnrollmentPolicy(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "");
|
|
1043
1186
|
const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
|
|
1044
|
-
const
|
|
1187
|
+
const boundRootEntries = materializedWorkspaceBindings(bindingDir);
|
|
1188
|
+
const boundRoots = new Map(boundRootEntries
|
|
1045
1189
|
.map(({ workspaceId, directory }) => [workspaceId, directory]));
|
|
1190
|
+
const outerBoundRoots = boundRootEntries.filter((candidate) => !boundRootEntries.some((other) => other.workspaceId !== candidate.workspaceId
|
|
1191
|
+
&& pathWithin(other.directory, candidate.directory)));
|
|
1192
|
+
const registeredBoundaries = new Map(boundRootEntries
|
|
1193
|
+
.map(({ workspaceId, directory }) => [resolve(directory), workspaceId]));
|
|
1046
1194
|
const suspicionFor = (root) => {
|
|
1047
1195
|
if (!suspicions || !suspicions.has(root))
|
|
1048
1196
|
return suspicions ? [] : null;
|
|
@@ -1066,7 +1214,7 @@ async function scanAndRegister(input) {
|
|
|
1066
1214
|
repositoryCaptures: 0,
|
|
1067
1215
|
};
|
|
1068
1216
|
const started = performance.now();
|
|
1069
|
-
const result = scanRoot({ ...parameters, evidence: counters });
|
|
1217
|
+
const result = await scanRoot({ ...parameters, evidence: counters });
|
|
1070
1218
|
onScan?.({
|
|
1071
1219
|
rootId: parameters.rootUUID,
|
|
1072
1220
|
directory: parameters.rootPath,
|
|
@@ -1099,35 +1247,26 @@ async function scanAndRegister(input) {
|
|
|
1099
1247
|
});
|
|
1100
1248
|
if (!core.transferable)
|
|
1101
1249
|
deferredRootIds.add(coreUUID);
|
|
1102
|
-
const
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
targetRoots.add(row.uuid);
|
|
1106
|
-
}
|
|
1107
|
-
for (const workspaceId of boundRoots.keys())
|
|
1108
|
-
targetRoots.add(workspaceId);
|
|
1109
|
-
for (const workspaceId of targetRoots) {
|
|
1110
|
-
const rootPath = boundRoots.get(workspaceId);
|
|
1111
|
-
if (!rootPath) {
|
|
1112
|
-
// Missing external ground is undecided. Preserve its last witnessed rows
|
|
1113
|
-
// so a move can be rescued; absence is never permission to delete it.
|
|
1114
|
-
continue;
|
|
1115
|
-
}
|
|
1116
|
-
const existingRoot = existing.find((row) => row.uuid === workspaceId && row.parentUUID === null);
|
|
1117
|
-
const existingWorkspaceRows = existing.filter((row) => row.rootUUID === workspaceId);
|
|
1250
|
+
for (const { workspaceId, directory: rootPath } of outerBoundRoots) {
|
|
1251
|
+
const existingRoot = existing.find((row) => row.uuid === workspaceId);
|
|
1252
|
+
const existingWorkspaceRows = existing.filter((row) => row.resourceId === resourceId && pathWithin(rootPath, row.absolutePath));
|
|
1118
1253
|
const scanned = await scan({
|
|
1119
1254
|
identity,
|
|
1120
1255
|
resourceId,
|
|
1121
1256
|
rootUUID: workspaceId,
|
|
1122
1257
|
rootName: existingRoot?.name || basename(rootPath),
|
|
1123
1258
|
rootPath,
|
|
1124
|
-
rootType:
|
|
1259
|
+
rootType: existingRoot?.parentUUID
|
|
1260
|
+
? classifyDirectory({ repository: hasGitMarker(rootPath) })
|
|
1261
|
+
: classifyRegisteredRoot({ repository: hasGitMarker(rootPath) }),
|
|
1262
|
+
rootParentUUID: existingRoot?.parentUUID ?? null,
|
|
1125
1263
|
database,
|
|
1126
1264
|
cacheDir,
|
|
1127
1265
|
policy: () => true,
|
|
1128
1266
|
bindingDir,
|
|
1129
1267
|
suspects: existingWorkspaceRows.length === 0 ? null : suspicionFor(rootPath),
|
|
1130
1268
|
existingRows: existingWorkspaceRows,
|
|
1269
|
+
registeredBoundaries,
|
|
1131
1270
|
});
|
|
1132
1271
|
if (!scanned.transferable)
|
|
1133
1272
|
deferredRootIds.add(workspaceId);
|
|
@@ -1136,11 +1275,17 @@ async function scanAndRegister(input) {
|
|
|
1136
1275
|
.filter((row) => !deferredRootIds.has(row.rootUUID))
|
|
1137
1276
|
.map(portableRecord);
|
|
1138
1277
|
}
|
|
1139
|
-
function scanRoot(input) {
|
|
1278
|
+
async function scanRoot(input) {
|
|
1140
1279
|
const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, evidence, } = input;
|
|
1141
|
-
const existingRows =
|
|
1142
|
-
.filter((row) => row.resourceId === resourceId && row.rootUUID === rootUUID);
|
|
1143
|
-
const existingByPath = new Map(existingRows.
|
|
1280
|
+
const existingRows = input.existingRows
|
|
1281
|
+
?? readRows(database).filter((row) => row.resourceId === resourceId && row.rootUUID === rootUUID);
|
|
1282
|
+
const existingByPath = new Map(existingRows.flatMap((row) => {
|
|
1283
|
+
if (!pathWithin(rootPath, row.absolutePath))
|
|
1284
|
+
return [];
|
|
1285
|
+
const local = slashPath(relative(rootPath, row.absolutePath));
|
|
1286
|
+
return [[local, row]];
|
|
1287
|
+
}));
|
|
1288
|
+
const registeredBoundaries = input.registeredBoundaries ?? new Map();
|
|
1144
1289
|
const completeRepositoryEvidence = existingRows.every((row) => row.type !== "repo.git"
|
|
1145
1290
|
|| (Boolean(row.payloadVersion) && Boolean(row.transportVersion)));
|
|
1146
1291
|
if (suspects !== null
|
|
@@ -1174,7 +1319,7 @@ function scanRoot(input) {
|
|
|
1174
1319
|
entries.push({
|
|
1175
1320
|
uuid: rootUUID,
|
|
1176
1321
|
type: rootType,
|
|
1177
|
-
parentUUID: null,
|
|
1322
|
+
parentUUID: input.rootParentUUID ?? null,
|
|
1178
1323
|
name: rootName,
|
|
1179
1324
|
relativePath: "",
|
|
1180
1325
|
absolutePath: rootPath,
|
|
@@ -1207,7 +1352,7 @@ function scanRoot(input) {
|
|
|
1207
1352
|
}
|
|
1208
1353
|
return [...new Set(paths)].sort();
|
|
1209
1354
|
};
|
|
1210
|
-
const visit = (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
|
|
1355
|
+
const visit = async (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
|
|
1211
1356
|
? { root: directory, evidencePaths: repositoryEvidencePaths(directory), evidence: null }
|
|
1212
1357
|
: null) => {
|
|
1213
1358
|
const children = readdirSync(directory, { withFileTypes: true })
|
|
@@ -1222,7 +1367,13 @@ function scanRoot(input) {
|
|
|
1222
1367
|
const stats = lstatSync(absolutePath);
|
|
1223
1368
|
evidence && (evidence.entries += 1);
|
|
1224
1369
|
const existing = existingByPath.get(relativePath);
|
|
1225
|
-
const
|
|
1370
|
+
const registeredBoundaryId = stats.isDirectory()
|
|
1371
|
+
? registeredBoundaries.get(resolve(absolutePath))
|
|
1372
|
+
: undefined;
|
|
1373
|
+
if (registeredBoundaryId && existing && existing.uuid !== registeredBoundaryId) {
|
|
1374
|
+
throw new Error(`registered boundary ${absolutePath} conflicts with entity ${existing.uuid}`);
|
|
1375
|
+
}
|
|
1376
|
+
const uuid = registeredBoundaryId || existing?.uuid || randomUUID();
|
|
1226
1377
|
const stableDuringCompleteCatchUp = suspects === null
|
|
1227
1378
|
&& existing !== undefined
|
|
1228
1379
|
&& reusableDuringCompleteCatchUp(existing, stats);
|
|
@@ -1294,16 +1445,9 @@ function scanRoot(input) {
|
|
|
1294
1445
|
}
|
|
1295
1446
|
else {
|
|
1296
1447
|
evidence && (evidence.contentReads += 1);
|
|
1297
|
-
const
|
|
1298
|
-
type =
|
|
1299
|
-
payloadVersion =
|
|
1300
|
-
if (!activeRepository)
|
|
1301
|
-
immutableWriteArtifact(cacheDir, {
|
|
1302
|
-
entityId: uuid,
|
|
1303
|
-
entityType: type,
|
|
1304
|
-
kind: "file",
|
|
1305
|
-
contentHash: payloadVersion,
|
|
1306
|
-
}, bytes);
|
|
1448
|
+
const captured = await captureContentFile(absolutePath, stats, activeRepository ? null : cacheDir);
|
|
1449
|
+
type = classifyFile({ binary: captured.binary });
|
|
1450
|
+
payloadVersion = captured.contentHash;
|
|
1307
1451
|
}
|
|
1308
1452
|
}
|
|
1309
1453
|
else {
|
|
@@ -1324,7 +1468,7 @@ function scanRoot(input) {
|
|
|
1324
1468
|
...statFingerprint(stats),
|
|
1325
1469
|
});
|
|
1326
1470
|
if (stats.isDirectory() && !stats.isSymbolicLink()) {
|
|
1327
|
-
visit(absolutePath, uuid, type, relativePath, type === "repo.git"
|
|
1471
|
+
await visit(absolutePath, uuid, type, relativePath, type === "repo.git"
|
|
1328
1472
|
? {
|
|
1329
1473
|
root: absolutePath,
|
|
1330
1474
|
evidencePaths: repositoryEvidencePaths(absolutePath),
|
|
@@ -1334,7 +1478,7 @@ function scanRoot(input) {
|
|
|
1334
1478
|
}
|
|
1335
1479
|
}
|
|
1336
1480
|
};
|
|
1337
|
-
visit(rootPath, rootUUID, rootType);
|
|
1481
|
+
await visit(rootPath, rootUUID, rootType);
|
|
1338
1482
|
const children = new Map();
|
|
1339
1483
|
for (const entry of entries) {
|
|
1340
1484
|
if (entry.parentUUID === null)
|
|
@@ -1486,7 +1630,7 @@ function scanRoot(input) {
|
|
|
1486
1630
|
transferable: true,
|
|
1487
1631
|
};
|
|
1488
1632
|
}
|
|
1489
|
-
function portablePaths(records) {
|
|
1633
|
+
function portablePaths(records, boundaryUUIDs = new Set()) {
|
|
1490
1634
|
const byUuid = new Map(records.map((record) => [record.uuid, record]));
|
|
1491
1635
|
const memo = new Map();
|
|
1492
1636
|
const resolving = new Set();
|
|
@@ -1498,7 +1642,7 @@ function portablePaths(records) {
|
|
|
1498
1642
|
throw new Error("cloud user-ground graph has a parent cycle");
|
|
1499
1643
|
resolving.add(record.uuid);
|
|
1500
1644
|
let path = "";
|
|
1501
|
-
if (record.parentUUID !== null) {
|
|
1645
|
+
if (!boundaryUUIDs.has(record.uuid) && record.parentUUID !== null) {
|
|
1502
1646
|
const parent = byUuid.get(record.parentUUID);
|
|
1503
1647
|
if (!parent)
|
|
1504
1648
|
throw new Error(`cloud entity ${record.uuid} has no parent`);
|
|
@@ -1536,22 +1680,15 @@ function recordRootResolver(records) {
|
|
|
1536
1680
|
};
|
|
1537
1681
|
return rootOf;
|
|
1538
1682
|
}
|
|
1539
|
-
/** Select the records whose root is authored by this machine. A full cloud
|
|
1540
|
-
* snapshot also carries untouched cloud-only sibling roots; preserving their
|
|
1541
|
-
* rows never means this machine owns or must re-upload their bytes. */
|
|
1542
|
-
function recordsWithinRoots(records, rootIds) {
|
|
1543
|
-
const rootOf = recordRootResolver(records);
|
|
1544
|
-
return records.filter((record) => {
|
|
1545
|
-
const root = rootOf(record);
|
|
1546
|
-
return root !== null && rootIds.has(root);
|
|
1547
|
-
});
|
|
1548
|
-
}
|
|
1549
1683
|
function mergeMaterializedRoots(cloudRecords, localRecords) {
|
|
1550
1684
|
const localRoots = new Set(localRecords
|
|
1551
1685
|
.filter((record) => record.parentUUID === null)
|
|
1552
1686
|
.map((record) => record.uuid));
|
|
1687
|
+
const localIds = new Set(localRecords.map((record) => record.uuid));
|
|
1553
1688
|
const rootOf = recordRootResolver(cloudRecords);
|
|
1554
1689
|
const preserved = cloudRecords.filter((record) => {
|
|
1690
|
+
if (localIds.has(record.uuid))
|
|
1691
|
+
return false;
|
|
1555
1692
|
const root = rootOf(record);
|
|
1556
1693
|
return root === null || !localRoots.has(root);
|
|
1557
1694
|
});
|
|
@@ -1660,7 +1797,7 @@ async function materializeRecords(input) {
|
|
|
1660
1797
|
const applied = await applyRepositoryFiles(destination, chain);
|
|
1661
1798
|
repositories.push({ record, state: applied });
|
|
1662
1799
|
};
|
|
1663
|
-
const paths = portablePaths(records);
|
|
1800
|
+
const paths = portablePaths(records, input.boundaryUUID ? new Set([input.boundaryUUID]) : new Set());
|
|
1664
1801
|
const ordered = [...records].sort((left, right) => {
|
|
1665
1802
|
const leftPath = paths.get(left.uuid);
|
|
1666
1803
|
const rightPath = paths.get(right.uuid);
|
|
@@ -1673,7 +1810,7 @@ async function materializeRecords(input) {
|
|
|
1673
1810
|
if (!pathWithin(rootPath, destination)) {
|
|
1674
1811
|
throw new Error(`cloud entity path escapes its workspace: ${relativePath}`);
|
|
1675
1812
|
}
|
|
1676
|
-
if (
|
|
1813
|
+
if (relativePath === "") {
|
|
1677
1814
|
if (record.type === "repo.git")
|
|
1678
1815
|
await materializeRepository(record, destination);
|
|
1679
1816
|
else
|
|
@@ -1837,7 +1974,11 @@ function installCloudWorkspaceReferences(input) {
|
|
|
1837
1974
|
}
|
|
1838
1975
|
async function installCloudWorkspace(input) {
|
|
1839
1976
|
const { identity, userRoot, database, cacheDir, bindingDir, resourceId, workspace, reference, references, records, destinationParent, readContent, } = input;
|
|
1840
|
-
const
|
|
1977
|
+
const allLocalRows = readRows(database);
|
|
1978
|
+
const existingBoundary = allLocalRows.find((row) => row.uuid === workspace.uuid);
|
|
1979
|
+
const existing = existingBoundary
|
|
1980
|
+
? descendantRows(allLocalRows, workspace.uuid)
|
|
1981
|
+
: [];
|
|
1841
1982
|
if (existing.length > 0) {
|
|
1842
1983
|
const root = existing.find((row) => row.uuid === workspace.uuid);
|
|
1843
1984
|
if (!root || !pathExists(root.absolutePath)) {
|
|
@@ -1869,6 +2010,10 @@ async function installCloudWorkspace(input) {
|
|
|
1869
2010
|
if (!statSync(parent).isDirectory())
|
|
1870
2011
|
throw new Error("destination must be a directory");
|
|
1871
2012
|
const destination = join(parent, workspace.name);
|
|
2013
|
+
const canonicalUserRoot = realpathSync(userRoot);
|
|
2014
|
+
if (pathWithin(canonicalUserRoot, destination) || pathWithin(destination, canonicalUserRoot)) {
|
|
2015
|
+
throw new Error("files add destination must be outside the Amalgm user ground");
|
|
2016
|
+
}
|
|
1872
2017
|
if (pathExists(destination))
|
|
1873
2018
|
throw new Error(`workspace destination already exists: ${destination}`);
|
|
1874
2019
|
ensurePrivateDir(bindingDir);
|
|
@@ -1877,7 +2022,12 @@ async function installCloudWorkspace(input) {
|
|
|
1877
2022
|
throw new Error(`workspace ${workspace.uuid} already has a local binding`);
|
|
1878
2023
|
try {
|
|
1879
2024
|
const materialized = await materializeRecords({
|
|
1880
|
-
records,
|
|
2025
|
+
records,
|
|
2026
|
+
rootPath: destination,
|
|
2027
|
+
boundaryUUID: workspace.uuid,
|
|
2028
|
+
cacheDir,
|
|
2029
|
+
bindingDir,
|
|
2030
|
+
readContent,
|
|
1881
2031
|
});
|
|
1882
2032
|
const rows = projectMaterializedGraph(materialized.rows, materialized.repositories.map((repository) => ({
|
|
1883
2033
|
record: repository.record,
|