@amalgm/shell 0.1.18 → 0.1.20
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 +45 -7
- package/dist/filesystem.d.ts +6 -0
- package/dist/filesystem.js +46 -2
- package/dist/filesystem.js.map +1 -1
- package/dist/git-command.d.ts +8 -0
- package/dist/git-command.js +68 -0
- package/dist/git-command.js.map +1 -0
- package/dist/git-registration-host.d.ts +18 -0
- package/dist/git-registration-host.js +107 -0
- package/dist/git-registration-host.js.map +1 -0
- package/dist/git-repository-host.d.ts +28 -9
- package/dist/git-repository-host.js +254 -102
- package/dist/git-repository-host.js.map +1 -1
- package/dist/materialized-graph.d.ts +18 -0
- package/dist/materialized-graph.js +113 -0
- package/dist/materialized-graph.js.map +1 -0
- package/dist/user-ground-host.d.ts +1 -0
- package/dist/user-ground-host.js +339 -200
- package/dist/user-ground-host.js.map +1 -1
- package/dist/wire-binary.d.ts +8 -0
- package/dist/wire-binary.js +31 -0
- package/dist/wire-binary.js.map +1 -0
- package/dist/wire-client.d.ts +3 -0
- package/dist/wire-client.js +22 -3
- package/dist/wire-client.js.map +1 -1
- package/package.json +2 -2
package/dist/user-ground-host.js
CHANGED
|
@@ -3,10 +3,12 @@ import { createReadStream, existsSync, lstatSync, realpathSync, readFileSync, re
|
|
|
3
3
|
import { open } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { buildUserHomeManifest, buildUserManifest, liveMachineStateDir, scopedAmalgmDir, shippedUserHomeDeclaration, } from "@amalgm/core/identity";
|
|
6
|
-
import { CHUNK_BYTES, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, download as downloadArtifact, membershipHash, parseSnapshot, privateEntityResourceId, sameRecords, snapshotFromRecords, stableJson, travelingRecords,
|
|
6
|
+
import { CHUNK_BYTES, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, convergeUserGround, createSuspicionScope, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, download as downloadArtifact, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
|
|
7
7
|
import Database from "better-sqlite3";
|
|
8
|
-
import { atomicWrite, ensurePrivateDir } from "./filesystem.js";
|
|
9
|
-
import {
|
|
8
|
+
import { atomicAssemble, atomicCopy, atomicWrite, ensurePrivateDir } from "./filesystem.js";
|
|
9
|
+
import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, isRepository, } from "./git-repository-host.js";
|
|
10
|
+
import { inspectGitRegistration, } from "./git-registration-host.js";
|
|
11
|
+
import { projectMaterializedGraph } from "./materialized-graph.js";
|
|
10
12
|
import { WireClient, WireRequestError } from "./wire-client.js";
|
|
11
13
|
const PORTABLE_FIELDS = [
|
|
12
14
|
"uuid", "type", "parentUUID", "name", "status", "version",
|
|
@@ -25,6 +27,7 @@ export class UserGroundHost {
|
|
|
25
27
|
cloudState = null;
|
|
26
28
|
converged = false;
|
|
27
29
|
syncing = null;
|
|
30
|
+
closing = false;
|
|
28
31
|
port;
|
|
29
32
|
constructor(options) {
|
|
30
33
|
this.options = options;
|
|
@@ -179,8 +182,16 @@ export class UserGroundHost {
|
|
|
179
182
|
async flush() {
|
|
180
183
|
if (!this.converged || !this.activeIdentity || !this.cloudState)
|
|
181
184
|
return;
|
|
182
|
-
|
|
185
|
+
const identity = this.activeIdentity;
|
|
186
|
+
for (const watcher of this.watchers.values())
|
|
187
|
+
watcher.suspicion.suspectAll();
|
|
188
|
+
this.watchDirty = true;
|
|
183
189
|
await this.syncNow();
|
|
190
|
+
if (!this.closing)
|
|
191
|
+
this.ensureWatchers(identity);
|
|
192
|
+
await this.flushObservedChanges();
|
|
193
|
+
if (!this.closing)
|
|
194
|
+
this.ensureWatchers(identity);
|
|
184
195
|
await this.flushObservedChanges();
|
|
185
196
|
}
|
|
186
197
|
async syncNow() {
|
|
@@ -194,14 +205,20 @@ export class UserGroundHost {
|
|
|
194
205
|
await this.syncing;
|
|
195
206
|
}
|
|
196
207
|
async flushObservedChanges() {
|
|
197
|
-
|
|
198
|
-
const dirty = this.watchDirty
|
|
208
|
+
while (true) {
|
|
209
|
+
const dirty = this.watchDirty
|
|
210
|
+
|| [...this.watchers.values()].some((watcher) => watcher.suspicion.size > 0);
|
|
199
211
|
this.watchDirty = false;
|
|
200
|
-
if (dirty)
|
|
212
|
+
if (dirty) {
|
|
201
213
|
await this.syncNow();
|
|
202
|
-
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (this.syncing) {
|
|
203
217
|
await this.syncing;
|
|
204
|
-
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
205
222
|
}
|
|
206
223
|
async activateRuntimeTunnel(gatewayPort, runtimeToken) {
|
|
207
224
|
await this.flush();
|
|
@@ -213,12 +230,18 @@ export class UserGroundHost {
|
|
|
213
230
|
await this.wire.close();
|
|
214
231
|
}
|
|
215
232
|
async close() {
|
|
233
|
+
// Command shutdown is the last catch-up boundary. A filesystem callback
|
|
234
|
+
// may still be queued, so watcher silence cannot be used as evidence yet.
|
|
235
|
+
for (const watcher of this.watchers.values())
|
|
236
|
+
watcher.suspicion.suspectAll();
|
|
237
|
+
this.watchDirty = this.watchers.size > 0 || this.watchDirty;
|
|
238
|
+
this.closing = true;
|
|
216
239
|
if (this.rescanTimer)
|
|
217
240
|
clearTimeout(this.rescanTimer);
|
|
218
241
|
this.rescanTimer = null;
|
|
219
242
|
await this.flushObservedChanges();
|
|
220
243
|
for (const watcher of this.watchers.values())
|
|
221
|
-
watcher.close();
|
|
244
|
+
watcher.handle.close();
|
|
222
245
|
this.watchers.clear();
|
|
223
246
|
if (this.rescanTimer)
|
|
224
247
|
clearTimeout(this.rescanTimer);
|
|
@@ -338,32 +361,23 @@ export class UserGroundHost {
|
|
|
338
361
|
}, {
|
|
339
362
|
sha256Hex,
|
|
340
363
|
missingChunks: async (_candidate, manifest) => {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if (!found.ok)
|
|
351
|
-
throw new Error(found.error);
|
|
352
|
-
manifestPresent = true;
|
|
353
|
-
return [];
|
|
354
|
-
}
|
|
355
|
-
catch (error) {
|
|
356
|
-
if (error instanceof WireRequestError && error.code === "entity_content_not_found") {
|
|
357
|
-
return manifest.chunks.map((_chunk, index) => index);
|
|
358
|
-
}
|
|
359
|
-
throw error;
|
|
364
|
+
const frame = await retryContent(() => this.wire.request({
|
|
365
|
+
type: "private.entity-content.inventory",
|
|
366
|
+
resource_id: resourceId,
|
|
367
|
+
content_hash: artifact.contentHash,
|
|
368
|
+
chunks: manifest.chunks,
|
|
369
|
+
}, ["private.entity-content.inventory-result"]));
|
|
370
|
+
manifestPresent = frame.complete === true;
|
|
371
|
+
if (!Array.isArray(frame.missing)) {
|
|
372
|
+
throw new Error("content authority returned an invalid inventory");
|
|
360
373
|
}
|
|
374
|
+
return frame.missing;
|
|
361
375
|
},
|
|
362
376
|
putChunk: async (_candidate, manifest, index, bytes) => {
|
|
363
377
|
const chunk = manifest.chunks[index];
|
|
364
378
|
if (!chunk)
|
|
365
379
|
throw new Error(`content manifest has no chunk ${index}`);
|
|
366
|
-
await retryContent(() => this.wire.
|
|
380
|
+
await retryContent(() => this.wire.requestBinary({
|
|
367
381
|
type: "private.entity-content.put",
|
|
368
382
|
resource_id: resourceId,
|
|
369
383
|
content_hash: artifact.contentHash,
|
|
@@ -372,14 +386,13 @@ export class UserGroundHost {
|
|
|
372
386
|
part_count: manifest.chunks.length,
|
|
373
387
|
sha256: chunk.sha256,
|
|
374
388
|
bytes: chunk.bytes,
|
|
375
|
-
|
|
376
|
-
}, ["private.entity-content.stored"]));
|
|
389
|
+
}, bytes, ["private.entity-content.stored"]));
|
|
377
390
|
},
|
|
378
391
|
putManifest: async (_candidate, manifest) => {
|
|
379
392
|
if (manifestPresent)
|
|
380
393
|
return;
|
|
381
394
|
const bytes = Buffer.from(stableJson(manifest), "utf8");
|
|
382
|
-
await retryContent(() => this.wire.
|
|
395
|
+
await retryContent(() => this.wire.requestBinary({
|
|
383
396
|
type: "private.entity-content.put",
|
|
384
397
|
resource_id: resourceId,
|
|
385
398
|
content_hash: artifact.contentHash,
|
|
@@ -388,8 +401,7 @@ export class UserGroundHost {
|
|
|
388
401
|
part_count: manifest.chunks.length,
|
|
389
402
|
sha256: sha256Hex(bytes),
|
|
390
403
|
bytes: bytes.length,
|
|
391
|
-
|
|
392
|
-
}, ["private.entity-content.stored"]));
|
|
404
|
+
}, bytes, ["private.entity-content.stored"]));
|
|
393
405
|
},
|
|
394
406
|
});
|
|
395
407
|
atomicWrite(manifestCacheFile(cache, artifact.contentHash), stableJson(receipt.manifest));
|
|
@@ -444,40 +456,34 @@ export class UserGroundHost {
|
|
|
444
456
|
atomicWrite(chunkCacheFile(cacheDir, chunk.sha256), bytes);
|
|
445
457
|
},
|
|
446
458
|
sealArtifact: async (_candidate, manifest) => {
|
|
447
|
-
const
|
|
448
|
-
const
|
|
449
|
-
if (bytes
|
|
459
|
+
const file = contentCacheFile(cacheDir, artifact.contentHash);
|
|
460
|
+
const sealed = await atomicAssemble(file, manifest.chunks.map((chunk) => chunkCacheFile(cacheDir, chunk.sha256)));
|
|
461
|
+
if (sealed.bytes !== manifest.bytes || sealed.sha256 !== manifest.contentHash) {
|
|
450
462
|
throw new Error(`cloud content ${artifact.contentHash} is corrupt`);
|
|
451
463
|
}
|
|
452
|
-
atomicWrite(contentCacheFile(cacheDir, artifact.contentHash), bytes);
|
|
453
464
|
atomicWrite(manifestCacheFile(cacheDir, artifact.contentHash), stableJson(manifest));
|
|
454
|
-
return {
|
|
465
|
+
return {
|
|
466
|
+
local: { file, bytes: sealed.bytes, contentHash: sealed.sha256 },
|
|
467
|
+
bytes: sealed.bytes,
|
|
468
|
+
contentHash: sealed.sha256,
|
|
469
|
+
};
|
|
455
470
|
},
|
|
456
471
|
});
|
|
457
472
|
return receipt.local;
|
|
458
473
|
}
|
|
459
474
|
ensureWatchers(identity) {
|
|
475
|
+
if (this.closing)
|
|
476
|
+
return;
|
|
460
477
|
const root = this.userRoot(identity);
|
|
461
478
|
const ignoreFile = join(root, ".amalgmignore");
|
|
462
479
|
const policy = createUserGroundEnrollmentPolicy(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "");
|
|
463
|
-
const wanted = new
|
|
464
|
-
const visit = (directory, enrollment, base = "") => {
|
|
465
|
-
wanted.add(directory);
|
|
466
|
-
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
467
|
-
const rel = base ? `${base}/${entry.name}` : entry.name;
|
|
468
|
-
if (!entry.isDirectory() || entry.isSymbolicLink() || !enrollment(rel))
|
|
469
|
-
continue;
|
|
470
|
-
const child = join(directory, entry.name);
|
|
471
|
-
visit(child, enrollment, rel);
|
|
472
|
-
}
|
|
473
|
-
};
|
|
474
|
-
visit(root, policy);
|
|
480
|
+
const wanted = new Map([[root, policy]]);
|
|
475
481
|
for (const row of readRows(this.databasePath(identity))) {
|
|
476
482
|
if (row.parentUUID !== null || row.absolutePath === root)
|
|
477
483
|
continue;
|
|
478
484
|
try {
|
|
479
485
|
if (statSync(row.absolutePath).isDirectory())
|
|
480
|
-
|
|
486
|
+
wanted.set(row.absolutePath, () => true);
|
|
481
487
|
}
|
|
482
488
|
catch {
|
|
483
489
|
// Unresolved ground keeps its rows but cannot hold a watcher handle.
|
|
@@ -485,35 +491,62 @@ export class UserGroundHost {
|
|
|
485
491
|
}
|
|
486
492
|
for (const [directory, watcher] of this.watchers) {
|
|
487
493
|
if (!wanted.has(directory)) {
|
|
488
|
-
watcher.close();
|
|
494
|
+
watcher.handle.close();
|
|
489
495
|
this.watchers.delete(directory);
|
|
490
496
|
}
|
|
491
497
|
}
|
|
492
|
-
for (const directory of wanted) {
|
|
498
|
+
for (const [directory, enrollment] of wanted) {
|
|
493
499
|
if (this.watchers.has(directory))
|
|
494
500
|
continue;
|
|
495
|
-
const
|
|
496
|
-
|
|
501
|
+
const suspicion = createSuspicionScope();
|
|
502
|
+
const handle = watch(directory, { recursive: true }, (_event, file) => {
|
|
503
|
+
const relativePath = String(file || "").split(sep).join("/");
|
|
504
|
+
if (relativePath.split("/").includes(".amalgm"))
|
|
505
|
+
return;
|
|
506
|
+
if (relativePath && !enrollment(relativePath))
|
|
497
507
|
return;
|
|
508
|
+
const segments = relativePath.split("/");
|
|
509
|
+
const git = segments.indexOf(".git");
|
|
510
|
+
if (!relativePath || relativePath === ".amalgmignore"
|
|
511
|
+
|| (git === 0 && segments[1] === "index")) {
|
|
512
|
+
suspicion.suspectAll();
|
|
513
|
+
}
|
|
514
|
+
else if (git > 0 && segments[git + 1] === "index") {
|
|
515
|
+
suspicion.ring(segments.slice(0, git).join("/"));
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
suspicion.ring(relativePath);
|
|
519
|
+
}
|
|
498
520
|
this.scheduleRescan();
|
|
499
521
|
});
|
|
500
|
-
|
|
501
|
-
|
|
522
|
+
handle.on("error", () => {
|
|
523
|
+
suspicion.suspectAll();
|
|
524
|
+
handle.close();
|
|
502
525
|
this.watchers.delete(directory);
|
|
526
|
+
this.scheduleRescan();
|
|
503
527
|
});
|
|
504
|
-
this.watchers.set(directory,
|
|
528
|
+
this.watchers.set(directory, { handle, suspicion });
|
|
529
|
+
// Coverage began after the preceding observation. One catch-up proves
|
|
530
|
+
// the unobserved interval before watcher silence can be trusted.
|
|
531
|
+
suspicion.suspectAll();
|
|
532
|
+
this.scheduleRescan();
|
|
505
533
|
}
|
|
506
534
|
}
|
|
507
535
|
scheduleRescan() {
|
|
536
|
+
if (this.closing)
|
|
537
|
+
return;
|
|
508
538
|
this.watchDirty = true;
|
|
509
539
|
if (this.rescanTimer)
|
|
510
540
|
clearTimeout(this.rescanTimer);
|
|
511
541
|
this.rescanTimer = setTimeout(() => {
|
|
512
542
|
this.rescanTimer = null;
|
|
513
543
|
const identity = this.activeIdentity;
|
|
514
|
-
if (!identity)
|
|
544
|
+
if (!identity || this.closing)
|
|
515
545
|
return;
|
|
516
|
-
void this.flushObservedChanges().then(() =>
|
|
546
|
+
void this.flushObservedChanges().then(() => {
|
|
547
|
+
if (!this.closing)
|
|
548
|
+
this.ensureWatchers(identity);
|
|
549
|
+
}).catch(() => {
|
|
517
550
|
// The durable outbox remains claimable by the next event or resume.
|
|
518
551
|
});
|
|
519
552
|
}, 150);
|
|
@@ -524,16 +557,25 @@ export class UserGroundHost {
|
|
|
524
557
|
const state = this.cloudState;
|
|
525
558
|
if (!state)
|
|
526
559
|
throw new Error("cloud state is unavailable for user-ground Watch");
|
|
560
|
+
const observations = new Map();
|
|
561
|
+
for (const [directory, watcher] of this.watchers) {
|
|
562
|
+
observations.set(directory, watcher.suspicion.snapshot());
|
|
563
|
+
}
|
|
527
564
|
const localRecords = scanAndRegister({
|
|
528
565
|
identity,
|
|
529
566
|
userRoot: this.userRoot(identity),
|
|
530
567
|
database: this.databasePath(identity),
|
|
531
568
|
cacheDir: this.cacheDir(identity),
|
|
569
|
+
suspicions: new Map([...observations].map(([directory, snapshot]) => [directory, snapshot.paths])),
|
|
532
570
|
});
|
|
533
571
|
const snapshot = snapshotFromRecords(travelingRecords(mergeMaterializedRoots(state.records, localRecords)));
|
|
534
572
|
const checksum = sha256Hex(stableJson(snapshot));
|
|
535
|
-
if (checksum === state.checksum)
|
|
573
|
+
if (checksum === state.checksum) {
|
|
574
|
+
for (const [directory, snapshot] of observations) {
|
|
575
|
+
this.watchers.get(directory)?.suspicion.settle(snapshot);
|
|
576
|
+
}
|
|
536
577
|
return;
|
|
578
|
+
}
|
|
537
579
|
const database = initializeDatabase(this.databasePath(identity));
|
|
538
580
|
try {
|
|
539
581
|
database.prepare(`
|
|
@@ -547,6 +589,9 @@ export class UserGroundHost {
|
|
|
547
589
|
database.close();
|
|
548
590
|
}
|
|
549
591
|
await this.drainOutbox(identity);
|
|
592
|
+
for (const [directory, snapshot] of observations) {
|
|
593
|
+
this.watchers.get(directory)?.suspicion.settle(snapshot);
|
|
594
|
+
}
|
|
550
595
|
}
|
|
551
596
|
async drainOutboxBeforeLookup(identity, resourceId) {
|
|
552
597
|
const pending = readOutbox(this.databasePath(identity));
|
|
@@ -780,10 +825,13 @@ function deleteOutbox(file, mutationId) {
|
|
|
780
825
|
database.close();
|
|
781
826
|
}
|
|
782
827
|
}
|
|
828
|
+
function portableRecord(row) {
|
|
829
|
+
return Object.fromEntries(PORTABLE_FIELDS.map((field) => [field, row[field]]));
|
|
830
|
+
}
|
|
783
831
|
function portableRecords(file) {
|
|
784
|
-
return readRows(file).map(
|
|
832
|
+
return readRows(file).map(portableRecord);
|
|
785
833
|
}
|
|
786
|
-
function persistRows(file, identity, resourceId, rootUUID, rows) {
|
|
834
|
+
function persistRows(file, identity, resourceId, rootUUID, rows, previousRows = []) {
|
|
787
835
|
const database = initializeDatabase(file);
|
|
788
836
|
try {
|
|
789
837
|
const replaceIdentity = database.prepare(`
|
|
@@ -794,18 +842,54 @@ function persistRows(file, identity, resourceId, rootUUID, rows) {
|
|
|
794
842
|
user_email = excluded.user_email,
|
|
795
843
|
device_id = excluded.device_id
|
|
796
844
|
`);
|
|
797
|
-
const
|
|
845
|
+
const upsert = database.prepare(`
|
|
798
846
|
INSERT INTO entities(
|
|
799
847
|
uuid, resource_id, root_uuid, type, parent_uuid, name, status, version, payload_version,
|
|
800
848
|
transport_version, relative_path, absolute_path, device_number, inode
|
|
801
849
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
850
|
+
ON CONFLICT(uuid) DO UPDATE SET
|
|
851
|
+
resource_id = excluded.resource_id,
|
|
852
|
+
root_uuid = excluded.root_uuid,
|
|
853
|
+
type = excluded.type,
|
|
854
|
+
parent_uuid = excluded.parent_uuid,
|
|
855
|
+
name = excluded.name,
|
|
856
|
+
status = excluded.status,
|
|
857
|
+
version = excluded.version,
|
|
858
|
+
payload_version = excluded.payload_version,
|
|
859
|
+
transport_version = excluded.transport_version,
|
|
860
|
+
relative_path = excluded.relative_path,
|
|
861
|
+
absolute_path = excluded.absolute_path,
|
|
862
|
+
device_number = excluded.device_number,
|
|
863
|
+
inode = excluded.inode
|
|
802
864
|
`);
|
|
865
|
+
const remove = database.prepare("DELETE FROM entities WHERE uuid = ? AND resource_id = ? AND root_uuid = ?");
|
|
866
|
+
const previousByUuid = new Map(previousRows.map((row) => [row.uuid, row]));
|
|
867
|
+
const currentUuids = new Set(rows.map((row) => row.record.uuid));
|
|
868
|
+
const changed = rows.filter((row) => {
|
|
869
|
+
const old = previousByUuid.get(row.record.uuid);
|
|
870
|
+
return !old
|
|
871
|
+
|| old.resourceId !== resourceId
|
|
872
|
+
|| old.rootUUID !== rootUUID
|
|
873
|
+
|| old.type !== row.record.type
|
|
874
|
+
|| old.parentUUID !== row.record.parentUUID
|
|
875
|
+
|| old.name !== row.record.name
|
|
876
|
+
|| old.status !== row.record.status
|
|
877
|
+
|| old.version !== row.record.version
|
|
878
|
+
|| old.payloadVersion !== row.record.payloadVersion
|
|
879
|
+
|| old.transportVersion !== row.record.transportVersion
|
|
880
|
+
|| old.relativePath !== row.relativePath
|
|
881
|
+
|| old.absolutePath !== row.absolutePath
|
|
882
|
+
|| old.deviceNumber !== row.deviceNumber
|
|
883
|
+
|| old.inode !== row.inode;
|
|
884
|
+
});
|
|
803
885
|
const replace = database.transaction(() => {
|
|
804
886
|
replaceIdentity.run(identity.userId, identity.userEmail, identity.deviceId);
|
|
805
|
-
|
|
806
|
-
.
|
|
807
|
-
|
|
808
|
-
|
|
887
|
+
for (const old of previousRows) {
|
|
888
|
+
if (!currentUuids.has(old.uuid))
|
|
889
|
+
remove.run(old.uuid, resourceId, rootUUID);
|
|
890
|
+
}
|
|
891
|
+
for (const row of changed)
|
|
892
|
+
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);
|
|
809
893
|
});
|
|
810
894
|
replace();
|
|
811
895
|
}
|
|
@@ -875,12 +959,17 @@ function slashPath(path) {
|
|
|
875
959
|
return path.split(sep).join("/");
|
|
876
960
|
}
|
|
877
961
|
function scanAndRegister(input) {
|
|
878
|
-
const { identity, userRoot, database, cacheDir } = input;
|
|
962
|
+
const { identity, userRoot, database, cacheDir, suspicions } = input;
|
|
879
963
|
const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
|
|
880
964
|
const existing = readRows(database);
|
|
881
965
|
const ignoreFile = join(userRoot, ".amalgmignore");
|
|
882
966
|
const policy = createUserGroundEnrollmentPolicy(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "");
|
|
883
967
|
const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
|
|
968
|
+
const suspicionFor = (root) => {
|
|
969
|
+
if (!suspicions || !suspicions.has(root))
|
|
970
|
+
return suspicions ? [] : null;
|
|
971
|
+
return suspicions.get(root);
|
|
972
|
+
};
|
|
884
973
|
const existingCore = existing.find((row) => row.parentUUID === null && row.absolutePath === userRoot && row.type === "workspace");
|
|
885
974
|
const coreUUID = existingCore?.uuid || randomUUID();
|
|
886
975
|
const core = scanRoot({
|
|
@@ -894,9 +983,11 @@ function scanAndRegister(input) {
|
|
|
894
983
|
cacheDir,
|
|
895
984
|
policy,
|
|
896
985
|
bindingDir,
|
|
986
|
+
suspects: suspicionFor(userRoot),
|
|
987
|
+
existingRows: existing.filter((row) => row.rootUUID === coreUUID),
|
|
897
988
|
});
|
|
898
989
|
const targetRoots = new Set(core.referenceWorkspaceIds);
|
|
899
|
-
for (const row of
|
|
990
|
+
for (const row of existing) {
|
|
900
991
|
if (row.parentUUID === null && row.uuid !== coreUUID)
|
|
901
992
|
targetRoots.add(row.uuid);
|
|
902
993
|
}
|
|
@@ -914,7 +1005,8 @@ function scanAndRegister(input) {
|
|
|
914
1005
|
// so a move can be rescued; absence is never permission to delete it.
|
|
915
1006
|
continue;
|
|
916
1007
|
}
|
|
917
|
-
const existingRoot =
|
|
1008
|
+
const existingRoot = existing.find((row) => row.uuid === workspaceId && row.parentUUID === null);
|
|
1009
|
+
const existingWorkspaceRows = existing.filter((row) => row.rootUUID === workspaceId);
|
|
918
1010
|
scanRoot({
|
|
919
1011
|
identity,
|
|
920
1012
|
resourceId,
|
|
@@ -926,15 +1018,32 @@ function scanAndRegister(input) {
|
|
|
926
1018
|
cacheDir,
|
|
927
1019
|
policy: () => true,
|
|
928
1020
|
bindingDir,
|
|
1021
|
+
suspects: existingWorkspaceRows.length === 0 ? null : suspicionFor(rootPath),
|
|
1022
|
+
existingRows: existingWorkspaceRows,
|
|
929
1023
|
});
|
|
930
1024
|
}
|
|
931
1025
|
return portableRecords(database);
|
|
932
1026
|
}
|
|
933
1027
|
function scanRoot(input) {
|
|
934
|
-
const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir,
|
|
935
|
-
const
|
|
936
|
-
.filter((row) => row.resourceId === resourceId && row.rootUUID === rootUUID)
|
|
937
|
-
|
|
1028
|
+
const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, } = input;
|
|
1029
|
+
const existingRows = (input.existingRows ?? readRows(database))
|
|
1030
|
+
.filter((row) => row.resourceId === resourceId && row.rootUUID === rootUUID);
|
|
1031
|
+
const existingByPath = new Map(existingRows.map((row) => [row.relativePath, row]));
|
|
1032
|
+
if (suspects !== null && suspects.length === 0 && existingRows.length > 0) {
|
|
1033
|
+
return {
|
|
1034
|
+
records: existingRows.map(portableRecord),
|
|
1035
|
+
rows: existingRows.map((row) => ({
|
|
1036
|
+
record: portableRecord(row),
|
|
1037
|
+
relativePath: row.relativePath,
|
|
1038
|
+
absolutePath: row.absolutePath,
|
|
1039
|
+
deviceNumber: row.deviceNumber,
|
|
1040
|
+
inode: row.inode,
|
|
1041
|
+
})),
|
|
1042
|
+
referenceWorkspaceIds: existingRows
|
|
1043
|
+
.filter((row) => row.type === "reference" && row.payloadVersion && UUID.test(row.payloadVersion))
|
|
1044
|
+
.map((row) => row.payloadVersion),
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
938
1047
|
const entries = [];
|
|
939
1048
|
const referenceWorkspaceIds = new Set();
|
|
940
1049
|
const rootStats = lstatSync(rootPath);
|
|
@@ -950,7 +1059,33 @@ function scanRoot(input) {
|
|
|
950
1059
|
deviceNumber: rootStats.dev,
|
|
951
1060
|
inode: rootStats.ino,
|
|
952
1061
|
});
|
|
953
|
-
const
|
|
1062
|
+
const touchesSuspicion = (path) => suspects === null
|
|
1063
|
+
|| pathIsSuspect(path, suspects)
|
|
1064
|
+
|| suspects.some((suspect) => suspect.startsWith(`${path}/`));
|
|
1065
|
+
const repositoryEvidencePaths = (repository) => {
|
|
1066
|
+
if (suspects === null)
|
|
1067
|
+
return null;
|
|
1068
|
+
const prefix = slashPath(relative(rootPath, repository));
|
|
1069
|
+
const paths = [];
|
|
1070
|
+
for (const suspect of suspects) {
|
|
1071
|
+
if (suspect === prefix || (prefix && prefix.startsWith(`${suspect}/`)))
|
|
1072
|
+
return null;
|
|
1073
|
+
const local = prefix
|
|
1074
|
+
? suspect.startsWith(`${prefix}/`) ? suspect.slice(prefix.length + 1) : null
|
|
1075
|
+
: suspect;
|
|
1076
|
+
if (local === null)
|
|
1077
|
+
continue;
|
|
1078
|
+
if (local === ".git/index" || local.startsWith(".git/index/"))
|
|
1079
|
+
return null;
|
|
1080
|
+
if (local === ".git" || local.startsWith(".git/"))
|
|
1081
|
+
continue;
|
|
1082
|
+
paths.push(local);
|
|
1083
|
+
}
|
|
1084
|
+
return [...new Set(paths)].sort();
|
|
1085
|
+
};
|
|
1086
|
+
const visit = (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
|
|
1087
|
+
? { root: directory, evidencePaths: repositoryEvidencePaths(directory), evidence: null }
|
|
1088
|
+
: null) => {
|
|
954
1089
|
const children = readdirSync(directory, { withFileTypes: true })
|
|
955
1090
|
.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
|
956
1091
|
for (const child of children) {
|
|
@@ -962,11 +1097,27 @@ function scanRoot(input) {
|
|
|
962
1097
|
const absolutePath = join(directory, child.name);
|
|
963
1098
|
const stats = lstatSync(absolutePath);
|
|
964
1099
|
const existing = existingByPath.get(relativePath);
|
|
965
|
-
const
|
|
966
|
-
const
|
|
1100
|
+
const uuid = existing?.uuid || randomUUID();
|
|
1101
|
+
const observe = existing === undefined || touchesSuspicion(relativePath);
|
|
1102
|
+
const repositoryPath = activeRepository
|
|
1103
|
+
? slashPath(relative(activeRepository.root, absolutePath))
|
|
1104
|
+
: null;
|
|
1105
|
+
if (observe && repositoryPath && activeRepository && !activeRepository.evidence) {
|
|
1106
|
+
activeRepository.evidence = inspectGitRegistration(activeRepository.root, activeRepository.evidencePaths);
|
|
1107
|
+
}
|
|
1108
|
+
const indexed = observe && repositoryPath
|
|
1109
|
+
? activeRepository?.evidence?.cleanLeaves.get(repositoryPath)
|
|
1110
|
+
: undefined;
|
|
967
1111
|
let type;
|
|
968
1112
|
let payloadVersion = null;
|
|
969
|
-
if (
|
|
1113
|
+
if (!observe && existing) {
|
|
1114
|
+
type = existing.type;
|
|
1115
|
+
payloadVersion = existing.payloadVersion;
|
|
1116
|
+
if (type === "reference" && payloadVersion && UUID.test(payloadVersion)) {
|
|
1117
|
+
referenceWorkspaceIds.add(payloadVersion);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
else if (stats.isSymbolicLink()) {
|
|
970
1121
|
const referenceId = referenceWorkspaceId(absolutePath, bindingDir);
|
|
971
1122
|
if (referenceId) {
|
|
972
1123
|
type = "reference";
|
|
@@ -975,28 +1126,36 @@ function scanRoot(input) {
|
|
|
975
1126
|
}
|
|
976
1127
|
else {
|
|
977
1128
|
type = "link";
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1129
|
+
if (indexed?.type === "link") {
|
|
1130
|
+
payloadVersion = indexed.payloadVersion;
|
|
1131
|
+
}
|
|
1132
|
+
else {
|
|
1133
|
+
const bytes = Buffer.from(readlinkSync(absolutePath), "utf8");
|
|
1134
|
+
payloadVersion = sha256Hex(bytes);
|
|
1135
|
+
if (!activeRepository)
|
|
1136
|
+
immutableWrite(join(cacheDir, `${payloadVersion}.bin`), bytes);
|
|
1137
|
+
}
|
|
981
1138
|
}
|
|
982
1139
|
}
|
|
983
1140
|
else if (stats.isDirectory()) {
|
|
984
1141
|
type = classifyDirectory({ repository: isRepository(absolutePath) });
|
|
985
1142
|
}
|
|
986
1143
|
else if (stats.isFile()) {
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1144
|
+
if (indexed && indexed.type !== "link") {
|
|
1145
|
+
type = indexed.type;
|
|
1146
|
+
payloadVersion = indexed.payloadVersion;
|
|
1147
|
+
}
|
|
1148
|
+
else {
|
|
1149
|
+
const bytes = readFileSync(absolutePath);
|
|
1150
|
+
type = fileType(bytes);
|
|
1151
|
+
payloadVersion = sha256Hex(bytes);
|
|
1152
|
+
if (!activeRepository)
|
|
1153
|
+
immutableWrite(join(cacheDir, `${payloadVersion}.bin`), bytes);
|
|
1154
|
+
}
|
|
991
1155
|
}
|
|
992
1156
|
else {
|
|
993
1157
|
continue;
|
|
994
1158
|
}
|
|
995
|
-
if (seeded && seeded.type !== type
|
|
996
|
-
&& !(["file.text", "file.binary"].includes(seeded.type)
|
|
997
|
-
&& ["file.text", "file.binary"].includes(type))) {
|
|
998
|
-
throw new Error(`materialized entity ${relativePath} is ${type}, expected ${seeded.type}`);
|
|
999
|
-
}
|
|
1000
1159
|
entries.push({
|
|
1001
1160
|
uuid,
|
|
1002
1161
|
type,
|
|
@@ -1010,7 +1169,13 @@ function scanRoot(input) {
|
|
|
1010
1169
|
inode: stats.ino,
|
|
1011
1170
|
});
|
|
1012
1171
|
if (stats.isDirectory() && !stats.isSymbolicLink()) {
|
|
1013
|
-
visit(absolutePath, uuid, type, relativePath
|
|
1172
|
+
visit(absolutePath, uuid, type, relativePath, type === "repo.git"
|
|
1173
|
+
? {
|
|
1174
|
+
root: absolutePath,
|
|
1175
|
+
evidencePaths: repositoryEvidencePaths(absolutePath),
|
|
1176
|
+
evidence: null,
|
|
1177
|
+
}
|
|
1178
|
+
: activeRepository);
|
|
1014
1179
|
}
|
|
1015
1180
|
}
|
|
1016
1181
|
};
|
|
@@ -1030,42 +1195,60 @@ function scanRoot(input) {
|
|
|
1030
1195
|
: entry.relativePath,
|
|
1031
1196
|
uuid: entry.uuid,
|
|
1032
1197
|
type: entry.type,
|
|
1198
|
+
payloadVersion: entry.payloadVersion,
|
|
1033
1199
|
}))
|
|
1034
1200
|
.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
1035
|
-
const seededHead = repositoryHeads.get(repository.uuid);
|
|
1036
|
-
if (seededHead) {
|
|
1037
|
-
if (sha256Hex(seededHead.bytes) !== seededHead.transportVersion) {
|
|
1038
|
-
throw new Error(`repository transport ${repository.uuid} is corrupt`);
|
|
1039
|
-
}
|
|
1040
|
-
const state = unpack(Buffer.from(seededHead.bytes).toString("utf8"));
|
|
1041
|
-
if (state.stateId !== seededHead.stateId
|
|
1042
|
-
|| JSON.stringify(state.identity ?? []) !== JSON.stringify(repoIdentity)) {
|
|
1043
|
-
throw new Error(`repository transport ${repository.uuid} does not match its materialized identity`);
|
|
1044
|
-
}
|
|
1045
|
-
repository.payloadVersion = seededHead.stateId;
|
|
1046
|
-
repository.transportVersion = seededHead.transportVersion;
|
|
1047
|
-
immutableWrite(join(cacheDir, `${seededHead.transportVersion}.bin`), seededHead.bytes);
|
|
1048
|
-
continue;
|
|
1049
|
-
}
|
|
1050
1201
|
const priorRow = existingByPath.get(repository.relativePath);
|
|
1051
1202
|
let prior = null;
|
|
1052
1203
|
if (priorRow?.type === "repo.git" && priorRow.payloadVersion && priorRow.transportVersion) {
|
|
1053
1204
|
const priorPath = join(cacheDir, `${priorRow.transportVersion}.bin`);
|
|
1054
1205
|
if (existsSync(priorPath)) {
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1206
|
+
try {
|
|
1207
|
+
const layout = inspectRepositoryTransportFile(priorPath);
|
|
1208
|
+
if (layout.stateId === priorRow.payloadVersion) {
|
|
1209
|
+
prior = {
|
|
1210
|
+
stateId: priorRow.payloadVersion,
|
|
1211
|
+
transportVersion: priorRow.transportVersion,
|
|
1212
|
+
card: layout.card,
|
|
1213
|
+
identityHash: layout.identityHash,
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
else {
|
|
1217
|
+
rmSync(priorPath, { force: true });
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
catch {
|
|
1221
|
+
// Content-addressed cache bytes are evidence, not ground. A file
|
|
1222
|
+
// that cannot prove the current envelope is a cache miss.
|
|
1223
|
+
rmSync(priorPath, { force: true });
|
|
1062
1224
|
}
|
|
1063
1225
|
}
|
|
1064
1226
|
}
|
|
1065
|
-
const
|
|
1227
|
+
const previousRepositories = [...existingByPath.values()]
|
|
1228
|
+
.filter((row) => row.type === "repo.git");
|
|
1229
|
+
const previousOwner = (row) => previousRepositories
|
|
1230
|
+
.filter((candidate) => candidate.uuid !== row.uuid
|
|
1231
|
+
&& properDescendantOf(row.relativePath, candidate.relativePath))
|
|
1232
|
+
.sort((left, right) => right.relativePath.length - left.relativePath.length)[0] ?? null;
|
|
1233
|
+
const previousIdentity = prior && priorRow
|
|
1234
|
+
? [...existingByPath.values()]
|
|
1235
|
+
.filter((row) => previousOwner(row)?.uuid === priorRow.uuid)
|
|
1236
|
+
.map((row) => ({
|
|
1237
|
+
path: priorRow.relativePath
|
|
1238
|
+
? row.relativePath.slice(priorRow.relativePath.length + 1)
|
|
1239
|
+
: row.relativePath,
|
|
1240
|
+
uuid: row.uuid,
|
|
1241
|
+
type: row.type,
|
|
1242
|
+
payloadVersion: row.payloadVersion,
|
|
1243
|
+
}))
|
|
1244
|
+
.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0)
|
|
1245
|
+
: null;
|
|
1246
|
+
const captured = captureRepository(repository.absolutePath, repoIdentity, prior, previousIdentity);
|
|
1066
1247
|
repository.payloadVersion = captured.stateId;
|
|
1067
1248
|
repository.transportVersion = captured.transportVersion;
|
|
1068
|
-
|
|
1249
|
+
if (captured.bytes) {
|
|
1250
|
+
immutableWrite(join(cacheDir, `${captured.transportVersion}.bin`), captured.bytes);
|
|
1251
|
+
}
|
|
1069
1252
|
}
|
|
1070
1253
|
const children = new Map();
|
|
1071
1254
|
for (const entry of entries) {
|
|
@@ -1103,8 +1286,7 @@ function scanRoot(input) {
|
|
|
1103
1286
|
inode: entry.inode,
|
|
1104
1287
|
};
|
|
1105
1288
|
});
|
|
1106
|
-
|
|
1107
|
-
persistRows(database, identity, resourceId, rootUUID, rows);
|
|
1289
|
+
persistRows(database, identity, resourceId, rootUUID, rows, existingRows);
|
|
1108
1290
|
return { records: rows.map((row) => row.record), rows, referenceWorkspaceIds: [...referenceWorkspaceIds] };
|
|
1109
1291
|
}
|
|
1110
1292
|
function portablePaths(records) {
|
|
@@ -1204,19 +1386,10 @@ function installCloudGround(input) {
|
|
|
1204
1386
|
bindingDir: workspaceBindingDir(userRoot, identity.deviceId),
|
|
1205
1387
|
readContent,
|
|
1206
1388
|
});
|
|
1207
|
-
const rows = materialized.repositories.
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
root: roots[0],
|
|
1212
|
-
records,
|
|
1213
|
-
repositories: materialized.repositories,
|
|
1214
|
-
rootPath: userRoot,
|
|
1215
|
-
database,
|
|
1216
|
-
cacheDir,
|
|
1217
|
-
bindingDir: workspaceBindingDir(userRoot, identity.deviceId),
|
|
1218
|
-
})
|
|
1219
|
-
: materialized.rows;
|
|
1389
|
+
const rows = projectMaterializedGraph(materialized.rows, materialized.repositories.map((repository) => ({
|
|
1390
|
+
record: repository.record,
|
|
1391
|
+
identity: repository.state.identity,
|
|
1392
|
+
})), sha256Hex);
|
|
1220
1393
|
persistRows(database, identity, cloud.resourceId, roots[0].uuid, rows);
|
|
1221
1394
|
}
|
|
1222
1395
|
catch (error) {
|
|
@@ -1237,16 +1410,32 @@ async function materializeRecords(input) {
|
|
|
1237
1410
|
const artifact = artifactForRecord(record);
|
|
1238
1411
|
if (!artifact)
|
|
1239
1412
|
throw new Error(`cloud repository ${record.uuid} has no transfer artifact`);
|
|
1240
|
-
const
|
|
1241
|
-
if (
|
|
1413
|
+
const local = await readContent(artifact);
|
|
1414
|
+
if (local.contentHash !== record.transportVersion) {
|
|
1242
1415
|
throw new Error(`cloud repository ${record.uuid} transport is corrupt`);
|
|
1243
1416
|
}
|
|
1244
|
-
const
|
|
1245
|
-
|
|
1417
|
+
const chain = [{ file: local.file, transportVersion: local.contentHash }];
|
|
1418
|
+
const versions = new Set([record.transportVersion]);
|
|
1419
|
+
let state = inspectRepositoryTransportFile(local.file);
|
|
1420
|
+
while (state.parentTransportVersion) {
|
|
1421
|
+
if (versions.has(state.parentTransportVersion)) {
|
|
1422
|
+
throw new Error(`cloud repository ${record.uuid} transport chain has a cycle`);
|
|
1423
|
+
}
|
|
1424
|
+
const parentVersion = state.parentTransportVersion;
|
|
1425
|
+
versions.add(parentVersion);
|
|
1426
|
+
const parent = await readContent({ ...artifact, contentHash: parentVersion });
|
|
1427
|
+
if (parent.contentHash !== parentVersion) {
|
|
1428
|
+
throw new Error(`cloud repository ${record.uuid} parent transport is corrupt`);
|
|
1429
|
+
}
|
|
1430
|
+
chain.unshift({ file: parent.file, transportVersion: parent.contentHash });
|
|
1431
|
+
state = inspectRepositoryTransportFile(parent.file);
|
|
1432
|
+
}
|
|
1433
|
+
const latest = inspectRepositoryTransportFile(local.file);
|
|
1434
|
+
if (latest.stateId !== record.payloadVersion) {
|
|
1246
1435
|
throw new Error(`cloud repository ${record.uuid} transport does not name its declared state`);
|
|
1247
1436
|
}
|
|
1248
|
-
const applied =
|
|
1249
|
-
repositories.push({ record, state: applied
|
|
1437
|
+
const applied = await applyRepositoryFiles(destination, chain);
|
|
1438
|
+
repositories.push({ record, state: applied });
|
|
1250
1439
|
};
|
|
1251
1440
|
const paths = portablePaths(records);
|
|
1252
1441
|
const ordered = [...records].sort((left, right) => {
|
|
@@ -1295,12 +1484,12 @@ async function materializeRecords(input) {
|
|
|
1295
1484
|
const artifact = artifactForRecord(record);
|
|
1296
1485
|
if (!artifact)
|
|
1297
1486
|
throw new Error(`cloud leaf ${record.uuid} has no transfer artifact`);
|
|
1298
|
-
const
|
|
1487
|
+
const local = await readContent(artifact);
|
|
1299
1488
|
ensurePrivateDir(dirname(destination));
|
|
1300
1489
|
if (record.type === "link")
|
|
1301
|
-
symlinkSync(
|
|
1490
|
+
symlinkSync(readFileSync(local.file, "utf8"), destination);
|
|
1302
1491
|
else
|
|
1303
|
-
|
|
1492
|
+
atomicCopy(local.file, destination);
|
|
1304
1493
|
}
|
|
1305
1494
|
else {
|
|
1306
1495
|
throw new Error(`materialization for ${record.type} requires its dedicated adapter`);
|
|
@@ -1320,50 +1509,6 @@ async function materializeRecords(input) {
|
|
|
1320
1509
|
});
|
|
1321
1510
|
return { rows, repositories };
|
|
1322
1511
|
}
|
|
1323
|
-
function captureMaterializedGraph(input) {
|
|
1324
|
-
const { identity, resourceId, root, records, repositories, rootPath, database, cacheDir, bindingDir, } = input;
|
|
1325
|
-
if (root.type !== "workspace" && root.type !== "repo.git") {
|
|
1326
|
-
throw new Error(`cloud root ${root.uuid} cannot be scanned as a workspace`);
|
|
1327
|
-
}
|
|
1328
|
-
const paths = portablePaths(records);
|
|
1329
|
-
const seedByPath = new Map();
|
|
1330
|
-
for (const record of records) {
|
|
1331
|
-
const path = paths.get(record.uuid);
|
|
1332
|
-
if (path)
|
|
1333
|
-
seedByPath.set(path, { uuid: record.uuid, type: record.type });
|
|
1334
|
-
}
|
|
1335
|
-
const repositoryHeads = new Map();
|
|
1336
|
-
for (const repository of repositories) {
|
|
1337
|
-
if (!repository.record.transportVersion || !repository.record.payloadVersion) {
|
|
1338
|
-
throw new Error(`cloud repository ${repository.record.uuid} has no transport identity`);
|
|
1339
|
-
}
|
|
1340
|
-
const base = paths.get(repository.record.uuid);
|
|
1341
|
-
for (const entry of repository.state.identity ?? []) {
|
|
1342
|
-
const path = [base, entry.path].filter(Boolean).join("/");
|
|
1343
|
-
seedByPath.set(path, { uuid: entry.uuid, type: entry.type });
|
|
1344
|
-
}
|
|
1345
|
-
repositoryHeads.set(repository.record.uuid, {
|
|
1346
|
-
stateId: repository.record.payloadVersion,
|
|
1347
|
-
transportVersion: repository.record.transportVersion,
|
|
1348
|
-
bytes: repository.bytes,
|
|
1349
|
-
});
|
|
1350
|
-
}
|
|
1351
|
-
return scanRoot({
|
|
1352
|
-
identity,
|
|
1353
|
-
resourceId,
|
|
1354
|
-
rootUUID: root.uuid,
|
|
1355
|
-
rootName: root.name,
|
|
1356
|
-
rootPath,
|
|
1357
|
-
rootType: root.type,
|
|
1358
|
-
database,
|
|
1359
|
-
cacheDir,
|
|
1360
|
-
policy: () => true,
|
|
1361
|
-
bindingDir,
|
|
1362
|
-
seedByPath,
|
|
1363
|
-
repositoryHeads,
|
|
1364
|
-
persist: false,
|
|
1365
|
-
}).rows;
|
|
1366
|
-
}
|
|
1367
1512
|
async function installCloudWorkspace(input) {
|
|
1368
1513
|
const { identity, database, cacheDir, bindingDir, resourceId, workspace, records, destinationParent, readContent, } = input;
|
|
1369
1514
|
const existing = readRows(database).filter((row) => row.rootUUID === workspace.uuid);
|
|
@@ -1397,19 +1542,10 @@ async function installCloudWorkspace(input) {
|
|
|
1397
1542
|
const materialized = await materializeRecords({
|
|
1398
1543
|
records, rootPath: destination, cacheDir, bindingDir, readContent,
|
|
1399
1544
|
});
|
|
1400
|
-
const rows = materialized.repositories.
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
root: workspace,
|
|
1405
|
-
records,
|
|
1406
|
-
repositories: materialized.repositories,
|
|
1407
|
-
rootPath: destination,
|
|
1408
|
-
database,
|
|
1409
|
-
cacheDir,
|
|
1410
|
-
bindingDir,
|
|
1411
|
-
})
|
|
1412
|
-
: materialized.rows;
|
|
1545
|
+
const rows = projectMaterializedGraph(materialized.rows, materialized.repositories.map((repository) => ({
|
|
1546
|
+
record: repository.record,
|
|
1547
|
+
identity: repository.state.identity,
|
|
1548
|
+
})), sha256Hex);
|
|
1413
1549
|
symlinkSync(destination, binding, "dir");
|
|
1414
1550
|
persistRows(database, identity, resourceId, workspace.uuid, rows);
|
|
1415
1551
|
return {
|
|
@@ -1447,7 +1583,10 @@ function readyFromFrame(frame) {
|
|
|
1447
1583
|
};
|
|
1448
1584
|
}
|
|
1449
1585
|
function frameBytes(frame) {
|
|
1450
|
-
|
|
1586
|
+
if (!(frame.data_bin instanceof Uint8Array)) {
|
|
1587
|
+
throw new Error("wire content response is not a binary frame");
|
|
1588
|
+
}
|
|
1589
|
+
const bytes = Buffer.from(frame.data_bin);
|
|
1451
1590
|
if (bytes.length !== Number(frame.bytes) || sha256Hex(bytes) !== String(frame.sha256 || "")) {
|
|
1452
1591
|
throw new Error("wire content bytes do not match their checksum");
|
|
1453
1592
|
}
|