@amalgm/shell 0.1.17 → 0.1.19
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 +47 -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 +470 -250
- 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
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlinkSync, rmSync, statSync, symlinkSync, watch, writeFileSync, } from "node:fs";
|
|
2
|
+
import { createReadStream, existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlinkSync, rmSync, statSync, symlinkSync, watch, writeFileSync, } from "node:fs";
|
|
3
|
+
import { open } from "node:fs/promises";
|
|
3
4
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
5
|
import { buildUserHomeManifest, buildUserManifest, liveMachineStateDir, scopedAmalgmDir, shippedUserHomeDeclaration, } from "@amalgm/core/identity";
|
|
5
|
-
import { CHUNK_BYTES, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry,
|
|
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";
|
|
6
7
|
import Database from "better-sqlite3";
|
|
7
|
-
import { atomicWrite, ensurePrivateDir } from "./filesystem.js";
|
|
8
|
-
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";
|
|
9
12
|
import { WireClient, WireRequestError } from "./wire-client.js";
|
|
10
13
|
const PORTABLE_FIELDS = [
|
|
11
14
|
"uuid", "type", "parentUUID", "name", "status", "version",
|
|
@@ -24,6 +27,7 @@ export class UserGroundHost {
|
|
|
24
27
|
cloudState = null;
|
|
25
28
|
converged = false;
|
|
26
29
|
syncing = null;
|
|
30
|
+
closing = false;
|
|
27
31
|
port;
|
|
28
32
|
constructor(options) {
|
|
29
33
|
this.options = options;
|
|
@@ -164,7 +168,7 @@ export class UserGroundHost {
|
|
|
164
168
|
workspace: input.workspace,
|
|
165
169
|
records: input.records,
|
|
166
170
|
destinationParent: input.destinationParent,
|
|
167
|
-
readContent: (
|
|
171
|
+
readContent: (artifact) => this.downloadContent(privateEntityResourceId(identity.userId, sha256Hex), this.cacheDir(identity), artifact),
|
|
168
172
|
}),
|
|
169
173
|
coverWorkspace: async () => {
|
|
170
174
|
this.ensureWatchers(identity);
|
|
@@ -178,8 +182,16 @@ export class UserGroundHost {
|
|
|
178
182
|
async flush() {
|
|
179
183
|
if (!this.converged || !this.activeIdentity || !this.cloudState)
|
|
180
184
|
return;
|
|
181
|
-
|
|
185
|
+
const identity = this.activeIdentity;
|
|
186
|
+
for (const watcher of this.watchers.values())
|
|
187
|
+
watcher.suspicion.suspectAll();
|
|
188
|
+
this.watchDirty = true;
|
|
182
189
|
await this.syncNow();
|
|
190
|
+
if (!this.closing)
|
|
191
|
+
this.ensureWatchers(identity);
|
|
192
|
+
await this.flushObservedChanges();
|
|
193
|
+
if (!this.closing)
|
|
194
|
+
this.ensureWatchers(identity);
|
|
183
195
|
await this.flushObservedChanges();
|
|
184
196
|
}
|
|
185
197
|
async syncNow() {
|
|
@@ -193,14 +205,20 @@ export class UserGroundHost {
|
|
|
193
205
|
await this.syncing;
|
|
194
206
|
}
|
|
195
207
|
async flushObservedChanges() {
|
|
196
|
-
|
|
197
|
-
const dirty = this.watchDirty
|
|
208
|
+
while (true) {
|
|
209
|
+
const dirty = this.watchDirty
|
|
210
|
+
|| [...this.watchers.values()].some((watcher) => watcher.suspicion.size > 0);
|
|
198
211
|
this.watchDirty = false;
|
|
199
|
-
if (dirty)
|
|
212
|
+
if (dirty) {
|
|
200
213
|
await this.syncNow();
|
|
201
|
-
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (this.syncing) {
|
|
202
217
|
await this.syncing;
|
|
203
|
-
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
204
222
|
}
|
|
205
223
|
async activateRuntimeTunnel(gatewayPort, runtimeToken) {
|
|
206
224
|
await this.flush();
|
|
@@ -212,12 +230,18 @@ export class UserGroundHost {
|
|
|
212
230
|
await this.wire.close();
|
|
213
231
|
}
|
|
214
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;
|
|
215
239
|
if (this.rescanTimer)
|
|
216
240
|
clearTimeout(this.rescanTimer);
|
|
217
241
|
this.rescanTimer = null;
|
|
218
242
|
await this.flushObservedChanges();
|
|
219
243
|
for (const watcher of this.watchers.values())
|
|
220
|
-
watcher.close();
|
|
244
|
+
watcher.handle.close();
|
|
221
245
|
this.watchers.clear();
|
|
222
246
|
if (this.rescanTimer)
|
|
223
247
|
clearTimeout(this.rescanTimer);
|
|
@@ -296,7 +320,7 @@ export class UserGroundHost {
|
|
|
296
320
|
database,
|
|
297
321
|
cacheDir,
|
|
298
322
|
cloud,
|
|
299
|
-
readContent: (
|
|
323
|
+
readContent: (artifact) => this.downloadContent(cloud.resourceId, cacheDir, artifact),
|
|
300
324
|
});
|
|
301
325
|
return localValue(identity, userRoot, database);
|
|
302
326
|
},
|
|
@@ -308,113 +332,158 @@ export class UserGroundHost {
|
|
|
308
332
|
}
|
|
309
333
|
async uploadSnapshotContent(identity, resourceId, records) {
|
|
310
334
|
const cache = this.cacheDir(identity);
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
335
|
+
const byHash = new Map();
|
|
336
|
+
for (const record of records) {
|
|
337
|
+
const artifact = artifactForRecord(record);
|
|
338
|
+
if (artifact)
|
|
339
|
+
byHash.set(artifact.contentHash, artifact);
|
|
340
|
+
}
|
|
341
|
+
const artifacts = [...byHash.values()];
|
|
342
|
+
const small = artifacts.filter((artifact) => statSync(contentCacheFile(cache, artifact.contentHash)).size <= CHUNK_BYTES);
|
|
343
|
+
const large = artifacts.filter((artifact) => statSync(contentCacheFile(cache, artifact.contentHash)).size > CHUNK_BYTES);
|
|
344
|
+
const transfer = async (artifact) => {
|
|
345
|
+
const file = contentCacheFile(cache, artifact.contentHash);
|
|
346
|
+
const fileBytes = statSync(file).size;
|
|
347
|
+
const cachedManifest = readCachedManifest(cache, artifact.contentHash);
|
|
348
|
+
const sourceFile = { handle: null };
|
|
349
|
+
let manifestPresent = false;
|
|
350
|
+
try {
|
|
351
|
+
const receipt = await uploadArtifact(artifact, {
|
|
352
|
+
bytes: fileBytes,
|
|
353
|
+
...(cachedManifest ? { manifest: cachedManifest } : {}),
|
|
354
|
+
verify: async (contentHash) => await sha256File(file) === contentHash,
|
|
355
|
+
read: async (offset, bytes) => {
|
|
356
|
+
sourceFile.handle ??= await open(file, "r");
|
|
357
|
+
const buffer = Buffer.allocUnsafe(bytes);
|
|
358
|
+
const { bytesRead } = await sourceFile.handle.read(buffer, 0, bytes, offset);
|
|
359
|
+
return buffer.subarray(0, bytesRead);
|
|
360
|
+
},
|
|
361
|
+
}, {
|
|
362
|
+
sha256Hex,
|
|
363
|
+
missingChunks: async (_candidate, manifest) => {
|
|
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");
|
|
373
|
+
}
|
|
374
|
+
return frame.missing;
|
|
375
|
+
},
|
|
376
|
+
putChunk: async (_candidate, manifest, index, bytes) => {
|
|
377
|
+
const chunk = manifest.chunks[index];
|
|
378
|
+
if (!chunk)
|
|
379
|
+
throw new Error(`content manifest has no chunk ${index}`);
|
|
380
|
+
await retryContent(() => this.wire.requestBinary({
|
|
381
|
+
type: "private.entity-content.put",
|
|
382
|
+
resource_id: resourceId,
|
|
383
|
+
content_hash: artifact.contentHash,
|
|
384
|
+
kind: "chunk",
|
|
385
|
+
part_index: index,
|
|
386
|
+
part_count: manifest.chunks.length,
|
|
387
|
+
sha256: chunk.sha256,
|
|
388
|
+
bytes: chunk.bytes,
|
|
389
|
+
}, bytes, ["private.entity-content.stored"]));
|
|
390
|
+
},
|
|
391
|
+
putManifest: async (_candidate, manifest) => {
|
|
392
|
+
if (manifestPresent)
|
|
393
|
+
return;
|
|
394
|
+
const bytes = Buffer.from(stableJson(manifest), "utf8");
|
|
395
|
+
await retryContent(() => this.wire.requestBinary({
|
|
396
|
+
type: "private.entity-content.put",
|
|
397
|
+
resource_id: resourceId,
|
|
398
|
+
content_hash: artifact.contentHash,
|
|
399
|
+
kind: "manifest",
|
|
400
|
+
part_index: manifest.chunks.length,
|
|
401
|
+
part_count: manifest.chunks.length,
|
|
402
|
+
sha256: sha256Hex(bytes),
|
|
403
|
+
bytes: bytes.length,
|
|
404
|
+
}, bytes, ["private.entity-content.stored"]));
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
atomicWrite(manifestCacheFile(cache, artifact.contentHash), stableJson(receipt.manifest));
|
|
325
408
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
for (let index = 0; index < chunks.length; index += 1) {
|
|
330
|
-
const chunk = chunks[index];
|
|
331
|
-
await retryContent(() => this.wire.request({
|
|
332
|
-
type: "private.entity-content.put",
|
|
333
|
-
resource_id: resourceId,
|
|
334
|
-
content_hash: contentHash,
|
|
335
|
-
kind: "chunk",
|
|
336
|
-
part_index: index,
|
|
337
|
-
part_count: chunks.length,
|
|
338
|
-
sha256: chunk.sha256,
|
|
339
|
-
bytes: chunk.bytes,
|
|
340
|
-
data_b64: chunk.data.toString("base64"),
|
|
341
|
-
}, ["private.entity-content.stored"]));
|
|
409
|
+
finally {
|
|
410
|
+
if (sourceFile.handle)
|
|
411
|
+
await sourceFile.handle.close();
|
|
342
412
|
}
|
|
343
|
-
const manifestBytes = Buffer.from(stableJson(checked.value), "utf8");
|
|
344
|
-
await retryContent(() => this.wire.request({
|
|
345
|
-
type: "private.entity-content.put",
|
|
346
|
-
resource_id: resourceId,
|
|
347
|
-
content_hash: contentHash,
|
|
348
|
-
kind: "manifest",
|
|
349
|
-
part_index: chunks.length,
|
|
350
|
-
part_count: chunks.length,
|
|
351
|
-
sha256: sha256Hex(manifestBytes),
|
|
352
|
-
bytes: manifestBytes.length,
|
|
353
|
-
data_b64: manifestBytes.toString("base64"),
|
|
354
|
-
}, ["private.entity-content.stored"]));
|
|
355
413
|
};
|
|
356
|
-
await boundedForEach(small, SMALL_CONTENT_UPLOAD_CONCURRENCY,
|
|
357
|
-
for (const
|
|
358
|
-
await
|
|
414
|
+
await boundedForEach(small, SMALL_CONTENT_UPLOAD_CONCURRENCY, transfer);
|
|
415
|
+
for (const artifact of large) {
|
|
416
|
+
await transfer(artifact);
|
|
359
417
|
}
|
|
360
418
|
}
|
|
361
|
-
async downloadContent(resourceId,
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
419
|
+
async downloadContent(resourceId, cacheDir, artifact) {
|
|
420
|
+
const receipt = await downloadArtifact(artifact, {
|
|
421
|
+
sha256Hex,
|
|
422
|
+
getManifest: async () => {
|
|
423
|
+
const frame = await retryContent(() => this.wire.request({
|
|
424
|
+
type: "private.entity-content.get",
|
|
425
|
+
resource_id: resourceId,
|
|
426
|
+
content_hash: artifact.contentHash,
|
|
427
|
+
kind: "manifest",
|
|
428
|
+
part_index: 0,
|
|
429
|
+
}, ["private.entity-content.data"]));
|
|
430
|
+
return JSON.parse(frameBytes(frame).toString("utf8"));
|
|
431
|
+
},
|
|
432
|
+
hasChunk: async (chunk) => {
|
|
433
|
+
try {
|
|
434
|
+
const bytes = readFileSync(chunkCacheFile(cacheDir, chunk.sha256));
|
|
435
|
+
return bytes.length === chunk.bytes && sha256Hex(bytes) === chunk.sha256;
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
getChunk: async (_candidate, manifest, index) => {
|
|
442
|
+
const chunk = manifest.chunks[index];
|
|
443
|
+
if (!chunk)
|
|
444
|
+
throw new Error(`content manifest has no chunk ${index}`);
|
|
445
|
+
const frame = await retryContent(() => this.wire.request({
|
|
446
|
+
type: "private.entity-content.get",
|
|
447
|
+
resource_id: resourceId,
|
|
448
|
+
content_hash: artifact.contentHash,
|
|
449
|
+
kind: "chunk",
|
|
450
|
+
part_index: index,
|
|
451
|
+
sha256: chunk.sha256,
|
|
452
|
+
}, ["private.entity-content.data"]), true);
|
|
453
|
+
return frameBytes(frame);
|
|
454
|
+
},
|
|
455
|
+
putChunk: async (chunk, bytes) => {
|
|
456
|
+
atomicWrite(chunkCacheFile(cacheDir, chunk.sha256), bytes);
|
|
457
|
+
},
|
|
458
|
+
sealArtifact: async (_candidate, manifest) => {
|
|
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) {
|
|
462
|
+
throw new Error(`cloud content ${artifact.contentHash} is corrupt`);
|
|
463
|
+
}
|
|
464
|
+
atomicWrite(manifestCacheFile(cacheDir, artifact.contentHash), stableJson(manifest));
|
|
465
|
+
return {
|
|
466
|
+
local: { file, bytes: sealed.bytes, contentHash: sealed.sha256 },
|
|
467
|
+
bytes: sealed.bytes,
|
|
468
|
+
contentHash: sealed.sha256,
|
|
469
|
+
};
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
return receipt.local;
|
|
395
473
|
}
|
|
396
474
|
ensureWatchers(identity) {
|
|
475
|
+
if (this.closing)
|
|
476
|
+
return;
|
|
397
477
|
const root = this.userRoot(identity);
|
|
398
478
|
const ignoreFile = join(root, ".amalgmignore");
|
|
399
479
|
const policy = createUserGroundEnrollmentPolicy(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "");
|
|
400
|
-
const wanted = new
|
|
401
|
-
const visit = (directory, enrollment, base = "") => {
|
|
402
|
-
wanted.add(directory);
|
|
403
|
-
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
404
|
-
const rel = base ? `${base}/${entry.name}` : entry.name;
|
|
405
|
-
if (!entry.isDirectory() || entry.isSymbolicLink() || !enrollment(rel))
|
|
406
|
-
continue;
|
|
407
|
-
const child = join(directory, entry.name);
|
|
408
|
-
visit(child, enrollment, rel);
|
|
409
|
-
}
|
|
410
|
-
};
|
|
411
|
-
visit(root, policy);
|
|
480
|
+
const wanted = new Map([[root, policy]]);
|
|
412
481
|
for (const row of readRows(this.databasePath(identity))) {
|
|
413
482
|
if (row.parentUUID !== null || row.absolutePath === root)
|
|
414
483
|
continue;
|
|
415
484
|
try {
|
|
416
485
|
if (statSync(row.absolutePath).isDirectory())
|
|
417
|
-
|
|
486
|
+
wanted.set(row.absolutePath, () => true);
|
|
418
487
|
}
|
|
419
488
|
catch {
|
|
420
489
|
// Unresolved ground keeps its rows but cannot hold a watcher handle.
|
|
@@ -422,35 +491,62 @@ export class UserGroundHost {
|
|
|
422
491
|
}
|
|
423
492
|
for (const [directory, watcher] of this.watchers) {
|
|
424
493
|
if (!wanted.has(directory)) {
|
|
425
|
-
watcher.close();
|
|
494
|
+
watcher.handle.close();
|
|
426
495
|
this.watchers.delete(directory);
|
|
427
496
|
}
|
|
428
497
|
}
|
|
429
|
-
for (const directory of wanted) {
|
|
498
|
+
for (const [directory, enrollment] of wanted) {
|
|
430
499
|
if (this.watchers.has(directory))
|
|
431
500
|
continue;
|
|
432
|
-
const
|
|
433
|
-
|
|
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"))
|
|
434
505
|
return;
|
|
506
|
+
if (relativePath && !enrollment(relativePath))
|
|
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
|
+
}
|
|
435
520
|
this.scheduleRescan();
|
|
436
521
|
});
|
|
437
|
-
|
|
438
|
-
|
|
522
|
+
handle.on("error", () => {
|
|
523
|
+
suspicion.suspectAll();
|
|
524
|
+
handle.close();
|
|
439
525
|
this.watchers.delete(directory);
|
|
526
|
+
this.scheduleRescan();
|
|
440
527
|
});
|
|
441
|
-
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();
|
|
442
533
|
}
|
|
443
534
|
}
|
|
444
535
|
scheduleRescan() {
|
|
536
|
+
if (this.closing)
|
|
537
|
+
return;
|
|
445
538
|
this.watchDirty = true;
|
|
446
539
|
if (this.rescanTimer)
|
|
447
540
|
clearTimeout(this.rescanTimer);
|
|
448
541
|
this.rescanTimer = setTimeout(() => {
|
|
449
542
|
this.rescanTimer = null;
|
|
450
543
|
const identity = this.activeIdentity;
|
|
451
|
-
if (!identity)
|
|
544
|
+
if (!identity || this.closing)
|
|
452
545
|
return;
|
|
453
|
-
void this.flushObservedChanges().then(() =>
|
|
546
|
+
void this.flushObservedChanges().then(() => {
|
|
547
|
+
if (!this.closing)
|
|
548
|
+
this.ensureWatchers(identity);
|
|
549
|
+
}).catch(() => {
|
|
454
550
|
// The durable outbox remains claimable by the next event or resume.
|
|
455
551
|
});
|
|
456
552
|
}, 150);
|
|
@@ -461,16 +557,25 @@ export class UserGroundHost {
|
|
|
461
557
|
const state = this.cloudState;
|
|
462
558
|
if (!state)
|
|
463
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
|
+
}
|
|
464
564
|
const localRecords = scanAndRegister({
|
|
465
565
|
identity,
|
|
466
566
|
userRoot: this.userRoot(identity),
|
|
467
567
|
database: this.databasePath(identity),
|
|
468
568
|
cacheDir: this.cacheDir(identity),
|
|
569
|
+
suspicions: new Map([...observations].map(([directory, snapshot]) => [directory, snapshot.paths])),
|
|
469
570
|
});
|
|
470
571
|
const snapshot = snapshotFromRecords(travelingRecords(mergeMaterializedRoots(state.records, localRecords)));
|
|
471
572
|
const checksum = sha256Hex(stableJson(snapshot));
|
|
472
|
-
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
|
+
}
|
|
473
577
|
return;
|
|
578
|
+
}
|
|
474
579
|
const database = initializeDatabase(this.databasePath(identity));
|
|
475
580
|
try {
|
|
476
581
|
database.prepare(`
|
|
@@ -484,6 +589,9 @@ export class UserGroundHost {
|
|
|
484
589
|
database.close();
|
|
485
590
|
}
|
|
486
591
|
await this.drainOutbox(identity);
|
|
592
|
+
for (const [directory, snapshot] of observations) {
|
|
593
|
+
this.watchers.get(directory)?.suspicion.settle(snapshot);
|
|
594
|
+
}
|
|
487
595
|
}
|
|
488
596
|
async drainOutboxBeforeLookup(identity, resourceId) {
|
|
489
597
|
const pending = readOutbox(this.databasePath(identity));
|
|
@@ -532,6 +640,30 @@ export class UserGroundHost {
|
|
|
532
640
|
}
|
|
533
641
|
}
|
|
534
642
|
const sha256Hex = (input) => createHash("sha256").update(input).digest("hex");
|
|
643
|
+
async function sha256File(file) {
|
|
644
|
+
const hash = createHash("sha256");
|
|
645
|
+
for await (const chunk of createReadStream(file))
|
|
646
|
+
hash.update(chunk);
|
|
647
|
+
return hash.digest("hex");
|
|
648
|
+
}
|
|
649
|
+
function contentCacheFile(cacheDir, contentHash) {
|
|
650
|
+
return join(cacheDir, `${contentHash}.bin`);
|
|
651
|
+
}
|
|
652
|
+
function manifestCacheFile(cacheDir, contentHash) {
|
|
653
|
+
return join(cacheDir, "manifests", `${contentHash}.json`);
|
|
654
|
+
}
|
|
655
|
+
function chunkCacheFile(cacheDir, chunkHash) {
|
|
656
|
+
return join(cacheDir, "chunks", `${chunkHash}.bin`);
|
|
657
|
+
}
|
|
658
|
+
function readCachedManifest(cacheDir, contentHash) {
|
|
659
|
+
try {
|
|
660
|
+
const checked = checkContentManifest(JSON.parse(readFileSync(manifestCacheFile(cacheDir, contentHash), "utf8")), contentHash);
|
|
661
|
+
return checked.ok ? checked.value : null;
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
535
667
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
536
668
|
function workspaceBindingDir(userRoot, deviceId) {
|
|
537
669
|
return join(liveMachineStateDir(userRoot, deviceId), "bindings");
|
|
@@ -693,10 +825,13 @@ function deleteOutbox(file, mutationId) {
|
|
|
693
825
|
database.close();
|
|
694
826
|
}
|
|
695
827
|
}
|
|
828
|
+
function portableRecord(row) {
|
|
829
|
+
return Object.fromEntries(PORTABLE_FIELDS.map((field) => [field, row[field]]));
|
|
830
|
+
}
|
|
696
831
|
function portableRecords(file) {
|
|
697
|
-
return readRows(file).map(
|
|
832
|
+
return readRows(file).map(portableRecord);
|
|
698
833
|
}
|
|
699
|
-
function persistRows(file, identity, resourceId, rootUUID, rows) {
|
|
834
|
+
function persistRows(file, identity, resourceId, rootUUID, rows, previousRows = []) {
|
|
700
835
|
const database = initializeDatabase(file);
|
|
701
836
|
try {
|
|
702
837
|
const replaceIdentity = database.prepare(`
|
|
@@ -707,18 +842,54 @@ function persistRows(file, identity, resourceId, rootUUID, rows) {
|
|
|
707
842
|
user_email = excluded.user_email,
|
|
708
843
|
device_id = excluded.device_id
|
|
709
844
|
`);
|
|
710
|
-
const
|
|
845
|
+
const upsert = database.prepare(`
|
|
711
846
|
INSERT INTO entities(
|
|
712
847
|
uuid, resource_id, root_uuid, type, parent_uuid, name, status, version, payload_version,
|
|
713
848
|
transport_version, relative_path, absolute_path, device_number, inode
|
|
714
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
|
|
715
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
|
+
});
|
|
716
885
|
const replace = database.transaction(() => {
|
|
717
886
|
replaceIdentity.run(identity.userId, identity.userEmail, identity.deviceId);
|
|
718
|
-
|
|
719
|
-
.
|
|
720
|
-
|
|
721
|
-
|
|
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);
|
|
722
893
|
});
|
|
723
894
|
replace();
|
|
724
895
|
}
|
|
@@ -788,12 +959,17 @@ function slashPath(path) {
|
|
|
788
959
|
return path.split(sep).join("/");
|
|
789
960
|
}
|
|
790
961
|
function scanAndRegister(input) {
|
|
791
|
-
const { identity, userRoot, database, cacheDir } = input;
|
|
962
|
+
const { identity, userRoot, database, cacheDir, suspicions } = input;
|
|
792
963
|
const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
|
|
793
964
|
const existing = readRows(database);
|
|
794
965
|
const ignoreFile = join(userRoot, ".amalgmignore");
|
|
795
966
|
const policy = createUserGroundEnrollmentPolicy(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "");
|
|
796
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
|
+
};
|
|
797
973
|
const existingCore = existing.find((row) => row.parentUUID === null && row.absolutePath === userRoot && row.type === "workspace");
|
|
798
974
|
const coreUUID = existingCore?.uuid || randomUUID();
|
|
799
975
|
const core = scanRoot({
|
|
@@ -807,9 +983,11 @@ function scanAndRegister(input) {
|
|
|
807
983
|
cacheDir,
|
|
808
984
|
policy,
|
|
809
985
|
bindingDir,
|
|
986
|
+
suspects: suspicionFor(userRoot),
|
|
987
|
+
existingRows: existing.filter((row) => row.rootUUID === coreUUID),
|
|
810
988
|
});
|
|
811
989
|
const targetRoots = new Set(core.referenceWorkspaceIds);
|
|
812
|
-
for (const row of
|
|
990
|
+
for (const row of existing) {
|
|
813
991
|
if (row.parentUUID === null && row.uuid !== coreUUID)
|
|
814
992
|
targetRoots.add(row.uuid);
|
|
815
993
|
}
|
|
@@ -827,7 +1005,8 @@ function scanAndRegister(input) {
|
|
|
827
1005
|
// so a move can be rescued; absence is never permission to delete it.
|
|
828
1006
|
continue;
|
|
829
1007
|
}
|
|
830
|
-
const existingRoot =
|
|
1008
|
+
const existingRoot = existing.find((row) => row.uuid === workspaceId && row.parentUUID === null);
|
|
1009
|
+
const existingWorkspaceRows = existing.filter((row) => row.rootUUID === workspaceId);
|
|
831
1010
|
scanRoot({
|
|
832
1011
|
identity,
|
|
833
1012
|
resourceId,
|
|
@@ -839,15 +1018,32 @@ function scanAndRegister(input) {
|
|
|
839
1018
|
cacheDir,
|
|
840
1019
|
policy: () => true,
|
|
841
1020
|
bindingDir,
|
|
1021
|
+
suspects: existingWorkspaceRows.length === 0 ? null : suspicionFor(rootPath),
|
|
1022
|
+
existingRows: existingWorkspaceRows,
|
|
842
1023
|
});
|
|
843
1024
|
}
|
|
844
1025
|
return portableRecords(database);
|
|
845
1026
|
}
|
|
846
1027
|
function scanRoot(input) {
|
|
847
|
-
const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir,
|
|
848
|
-
const
|
|
849
|
-
.filter((row) => row.resourceId === resourceId && row.rootUUID === rootUUID)
|
|
850
|
-
|
|
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
|
+
}
|
|
851
1047
|
const entries = [];
|
|
852
1048
|
const referenceWorkspaceIds = new Set();
|
|
853
1049
|
const rootStats = lstatSync(rootPath);
|
|
@@ -863,7 +1059,33 @@ function scanRoot(input) {
|
|
|
863
1059
|
deviceNumber: rootStats.dev,
|
|
864
1060
|
inode: rootStats.ino,
|
|
865
1061
|
});
|
|
866
|
-
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) => {
|
|
867
1089
|
const children = readdirSync(directory, { withFileTypes: true })
|
|
868
1090
|
.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
|
869
1091
|
for (const child of children) {
|
|
@@ -875,11 +1097,27 @@ function scanRoot(input) {
|
|
|
875
1097
|
const absolutePath = join(directory, child.name);
|
|
876
1098
|
const stats = lstatSync(absolutePath);
|
|
877
1099
|
const existing = existingByPath.get(relativePath);
|
|
878
|
-
const
|
|
879
|
-
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;
|
|
880
1111
|
let type;
|
|
881
1112
|
let payloadVersion = null;
|
|
882
|
-
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()) {
|
|
883
1121
|
const referenceId = referenceWorkspaceId(absolutePath, bindingDir);
|
|
884
1122
|
if (referenceId) {
|
|
885
1123
|
type = "reference";
|
|
@@ -888,28 +1126,36 @@ function scanRoot(input) {
|
|
|
888
1126
|
}
|
|
889
1127
|
else {
|
|
890
1128
|
type = "link";
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
+
}
|
|
894
1138
|
}
|
|
895
1139
|
}
|
|
896
1140
|
else if (stats.isDirectory()) {
|
|
897
1141
|
type = classifyDirectory({ repository: isRepository(absolutePath) });
|
|
898
1142
|
}
|
|
899
1143
|
else if (stats.isFile()) {
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
+
}
|
|
904
1155
|
}
|
|
905
1156
|
else {
|
|
906
1157
|
continue;
|
|
907
1158
|
}
|
|
908
|
-
if (seeded && seeded.type !== type
|
|
909
|
-
&& !(["file.text", "file.binary"].includes(seeded.type)
|
|
910
|
-
&& ["file.text", "file.binary"].includes(type))) {
|
|
911
|
-
throw new Error(`materialized entity ${relativePath} is ${type}, expected ${seeded.type}`);
|
|
912
|
-
}
|
|
913
1159
|
entries.push({
|
|
914
1160
|
uuid,
|
|
915
1161
|
type,
|
|
@@ -923,7 +1169,13 @@ function scanRoot(input) {
|
|
|
923
1169
|
inode: stats.ino,
|
|
924
1170
|
});
|
|
925
1171
|
if (stats.isDirectory() && !stats.isSymbolicLink()) {
|
|
926
|
-
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);
|
|
927
1179
|
}
|
|
928
1180
|
}
|
|
929
1181
|
};
|
|
@@ -943,42 +1195,50 @@ function scanRoot(input) {
|
|
|
943
1195
|
: entry.relativePath,
|
|
944
1196
|
uuid: entry.uuid,
|
|
945
1197
|
type: entry.type,
|
|
1198
|
+
payloadVersion: entry.payloadVersion,
|
|
946
1199
|
}))
|
|
947
1200
|
.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
948
|
-
const seededHead = repositoryHeads.get(repository.uuid);
|
|
949
|
-
if (seededHead) {
|
|
950
|
-
if (sha256Hex(seededHead.bytes) !== seededHead.transportVersion) {
|
|
951
|
-
throw new Error(`repository transport ${repository.uuid} is corrupt`);
|
|
952
|
-
}
|
|
953
|
-
const state = unpack(Buffer.from(seededHead.bytes).toString("utf8"));
|
|
954
|
-
if (state.stateId !== seededHead.stateId
|
|
955
|
-
|| JSON.stringify(state.identity ?? []) !== JSON.stringify(repoIdentity)) {
|
|
956
|
-
throw new Error(`repository transport ${repository.uuid} does not match its materialized identity`);
|
|
957
|
-
}
|
|
958
|
-
repository.payloadVersion = seededHead.stateId;
|
|
959
|
-
repository.transportVersion = seededHead.transportVersion;
|
|
960
|
-
immutableWrite(join(cacheDir, `${seededHead.transportVersion}.bin`), seededHead.bytes);
|
|
961
|
-
continue;
|
|
962
|
-
}
|
|
963
1201
|
const priorRow = existingByPath.get(repository.relativePath);
|
|
964
1202
|
let prior = null;
|
|
965
1203
|
if (priorRow?.type === "repo.git" && priorRow.payloadVersion && priorRow.transportVersion) {
|
|
966
1204
|
const priorPath = join(cacheDir, `${priorRow.transportVersion}.bin`);
|
|
967
1205
|
if (existsSync(priorPath)) {
|
|
968
|
-
const
|
|
969
|
-
if (
|
|
1206
|
+
const layout = inspectRepositoryTransportFile(priorPath);
|
|
1207
|
+
if (layout.stateId === priorRow.payloadVersion) {
|
|
970
1208
|
prior = {
|
|
971
1209
|
stateId: priorRow.payloadVersion,
|
|
972
1210
|
transportVersion: priorRow.transportVersion,
|
|
973
|
-
|
|
1211
|
+
card: layout.card,
|
|
1212
|
+
identityHash: layout.identityHash,
|
|
974
1213
|
};
|
|
975
1214
|
}
|
|
976
1215
|
}
|
|
977
1216
|
}
|
|
978
|
-
const
|
|
1217
|
+
const previousRepositories = [...existingByPath.values()]
|
|
1218
|
+
.filter((row) => row.type === "repo.git");
|
|
1219
|
+
const previousOwner = (row) => previousRepositories
|
|
1220
|
+
.filter((candidate) => candidate.uuid !== row.uuid
|
|
1221
|
+
&& properDescendantOf(row.relativePath, candidate.relativePath))
|
|
1222
|
+
.sort((left, right) => right.relativePath.length - left.relativePath.length)[0] ?? null;
|
|
1223
|
+
const previousIdentity = prior && priorRow
|
|
1224
|
+
? [...existingByPath.values()]
|
|
1225
|
+
.filter((row) => previousOwner(row)?.uuid === priorRow.uuid)
|
|
1226
|
+
.map((row) => ({
|
|
1227
|
+
path: priorRow.relativePath
|
|
1228
|
+
? row.relativePath.slice(priorRow.relativePath.length + 1)
|
|
1229
|
+
: row.relativePath,
|
|
1230
|
+
uuid: row.uuid,
|
|
1231
|
+
type: row.type,
|
|
1232
|
+
payloadVersion: row.payloadVersion,
|
|
1233
|
+
}))
|
|
1234
|
+
.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0)
|
|
1235
|
+
: null;
|
|
1236
|
+
const captured = captureRepository(repository.absolutePath, repoIdentity, prior, previousIdentity);
|
|
979
1237
|
repository.payloadVersion = captured.stateId;
|
|
980
1238
|
repository.transportVersion = captured.transportVersion;
|
|
981
|
-
|
|
1239
|
+
if (captured.bytes) {
|
|
1240
|
+
immutableWrite(join(cacheDir, `${captured.transportVersion}.bin`), captured.bytes);
|
|
1241
|
+
}
|
|
982
1242
|
}
|
|
983
1243
|
const children = new Map();
|
|
984
1244
|
for (const entry of entries) {
|
|
@@ -1016,8 +1276,7 @@ function scanRoot(input) {
|
|
|
1016
1276
|
inode: entry.inode,
|
|
1017
1277
|
};
|
|
1018
1278
|
});
|
|
1019
|
-
|
|
1020
|
-
persistRows(database, identity, resourceId, rootUUID, rows);
|
|
1279
|
+
persistRows(database, identity, resourceId, rootUUID, rows, existingRows);
|
|
1021
1280
|
return { records: rows.map((row) => row.record), rows, referenceWorkspaceIds: [...referenceWorkspaceIds] };
|
|
1022
1281
|
}
|
|
1023
1282
|
function portablePaths(records) {
|
|
@@ -1117,19 +1376,10 @@ function installCloudGround(input) {
|
|
|
1117
1376
|
bindingDir: workspaceBindingDir(userRoot, identity.deviceId),
|
|
1118
1377
|
readContent,
|
|
1119
1378
|
});
|
|
1120
|
-
const rows = materialized.repositories.
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
root: roots[0],
|
|
1125
|
-
records,
|
|
1126
|
-
repositories: materialized.repositories,
|
|
1127
|
-
rootPath: userRoot,
|
|
1128
|
-
database,
|
|
1129
|
-
cacheDir,
|
|
1130
|
-
bindingDir: workspaceBindingDir(userRoot, identity.deviceId),
|
|
1131
|
-
})
|
|
1132
|
-
: materialized.rows;
|
|
1379
|
+
const rows = projectMaterializedGraph(materialized.rows, materialized.repositories.map((repository) => ({
|
|
1380
|
+
record: repository.record,
|
|
1381
|
+
identity: repository.state.identity,
|
|
1382
|
+
})), sha256Hex);
|
|
1133
1383
|
persistRows(database, identity, cloud.resourceId, roots[0].uuid, rows);
|
|
1134
1384
|
}
|
|
1135
1385
|
catch (error) {
|
|
@@ -1147,17 +1397,35 @@ async function materializeRecords(input) {
|
|
|
1147
1397
|
if (!record.payloadVersion || !record.transportVersion) {
|
|
1148
1398
|
throw new Error(`cloud repository ${record.uuid} has no state transport`);
|
|
1149
1399
|
}
|
|
1150
|
-
const
|
|
1151
|
-
if (
|
|
1400
|
+
const artifact = artifactForRecord(record);
|
|
1401
|
+
if (!artifact)
|
|
1402
|
+
throw new Error(`cloud repository ${record.uuid} has no transfer artifact`);
|
|
1403
|
+
const local = await readContent(artifact);
|
|
1404
|
+
if (local.contentHash !== record.transportVersion) {
|
|
1152
1405
|
throw new Error(`cloud repository ${record.uuid} transport is corrupt`);
|
|
1153
1406
|
}
|
|
1154
|
-
const
|
|
1155
|
-
|
|
1407
|
+
const chain = [{ file: local.file, transportVersion: local.contentHash }];
|
|
1408
|
+
const versions = new Set([record.transportVersion]);
|
|
1409
|
+
let state = inspectRepositoryTransportFile(local.file);
|
|
1410
|
+
while (state.parentTransportVersion) {
|
|
1411
|
+
if (versions.has(state.parentTransportVersion)) {
|
|
1412
|
+
throw new Error(`cloud repository ${record.uuid} transport chain has a cycle`);
|
|
1413
|
+
}
|
|
1414
|
+
const parentVersion = state.parentTransportVersion;
|
|
1415
|
+
versions.add(parentVersion);
|
|
1416
|
+
const parent = await readContent({ ...artifact, contentHash: parentVersion });
|
|
1417
|
+
if (parent.contentHash !== parentVersion) {
|
|
1418
|
+
throw new Error(`cloud repository ${record.uuid} parent transport is corrupt`);
|
|
1419
|
+
}
|
|
1420
|
+
chain.unshift({ file: parent.file, transportVersion: parent.contentHash });
|
|
1421
|
+
state = inspectRepositoryTransportFile(parent.file);
|
|
1422
|
+
}
|
|
1423
|
+
const latest = inspectRepositoryTransportFile(local.file);
|
|
1424
|
+
if (latest.stateId !== record.payloadVersion) {
|
|
1156
1425
|
throw new Error(`cloud repository ${record.uuid} transport does not name its declared state`);
|
|
1157
1426
|
}
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
repositories.push({ record, state: applied, bytes });
|
|
1427
|
+
const applied = await applyRepositoryFiles(destination, chain);
|
|
1428
|
+
repositories.push({ record, state: applied });
|
|
1161
1429
|
};
|
|
1162
1430
|
const paths = portablePaths(records);
|
|
1163
1431
|
const ordered = [...records].sort((left, right) => {
|
|
@@ -1203,13 +1471,15 @@ async function materializeRecords(input) {
|
|
|
1203
1471
|
throw new Error(`cloud leaf ${record.uuid} has no content head`);
|
|
1204
1472
|
if (pathExists(destination))
|
|
1205
1473
|
throw new Error(`cloud materialization conflicts with ${relativePath}`);
|
|
1206
|
-
const
|
|
1474
|
+
const artifact = artifactForRecord(record);
|
|
1475
|
+
if (!artifact)
|
|
1476
|
+
throw new Error(`cloud leaf ${record.uuid} has no transfer artifact`);
|
|
1477
|
+
const local = await readContent(artifact);
|
|
1207
1478
|
ensurePrivateDir(dirname(destination));
|
|
1208
1479
|
if (record.type === "link")
|
|
1209
|
-
symlinkSync(
|
|
1480
|
+
symlinkSync(readFileSync(local.file, "utf8"), destination);
|
|
1210
1481
|
else
|
|
1211
|
-
|
|
1212
|
-
immutableWrite(join(cacheDir, `${record.payloadVersion}.bin`), bytes);
|
|
1482
|
+
atomicCopy(local.file, destination);
|
|
1213
1483
|
}
|
|
1214
1484
|
else {
|
|
1215
1485
|
throw new Error(`materialization for ${record.type} requires its dedicated adapter`);
|
|
@@ -1229,50 +1499,6 @@ async function materializeRecords(input) {
|
|
|
1229
1499
|
});
|
|
1230
1500
|
return { rows, repositories };
|
|
1231
1501
|
}
|
|
1232
|
-
function captureMaterializedGraph(input) {
|
|
1233
|
-
const { identity, resourceId, root, records, repositories, rootPath, database, cacheDir, bindingDir, } = input;
|
|
1234
|
-
if (root.type !== "workspace" && root.type !== "repo.git") {
|
|
1235
|
-
throw new Error(`cloud root ${root.uuid} cannot be scanned as a workspace`);
|
|
1236
|
-
}
|
|
1237
|
-
const paths = portablePaths(records);
|
|
1238
|
-
const seedByPath = new Map();
|
|
1239
|
-
for (const record of records) {
|
|
1240
|
-
const path = paths.get(record.uuid);
|
|
1241
|
-
if (path)
|
|
1242
|
-
seedByPath.set(path, { uuid: record.uuid, type: record.type });
|
|
1243
|
-
}
|
|
1244
|
-
const repositoryHeads = new Map();
|
|
1245
|
-
for (const repository of repositories) {
|
|
1246
|
-
if (!repository.record.transportVersion || !repository.record.payloadVersion) {
|
|
1247
|
-
throw new Error(`cloud repository ${repository.record.uuid} has no transport identity`);
|
|
1248
|
-
}
|
|
1249
|
-
const base = paths.get(repository.record.uuid);
|
|
1250
|
-
for (const entry of repository.state.identity ?? []) {
|
|
1251
|
-
const path = [base, entry.path].filter(Boolean).join("/");
|
|
1252
|
-
seedByPath.set(path, { uuid: entry.uuid, type: entry.type });
|
|
1253
|
-
}
|
|
1254
|
-
repositoryHeads.set(repository.record.uuid, {
|
|
1255
|
-
stateId: repository.record.payloadVersion,
|
|
1256
|
-
transportVersion: repository.record.transportVersion,
|
|
1257
|
-
bytes: repository.bytes,
|
|
1258
|
-
});
|
|
1259
|
-
}
|
|
1260
|
-
return scanRoot({
|
|
1261
|
-
identity,
|
|
1262
|
-
resourceId,
|
|
1263
|
-
rootUUID: root.uuid,
|
|
1264
|
-
rootName: root.name,
|
|
1265
|
-
rootPath,
|
|
1266
|
-
rootType: root.type,
|
|
1267
|
-
database,
|
|
1268
|
-
cacheDir,
|
|
1269
|
-
policy: () => true,
|
|
1270
|
-
bindingDir,
|
|
1271
|
-
seedByPath,
|
|
1272
|
-
repositoryHeads,
|
|
1273
|
-
persist: false,
|
|
1274
|
-
}).rows;
|
|
1275
|
-
}
|
|
1276
1502
|
async function installCloudWorkspace(input) {
|
|
1277
1503
|
const { identity, database, cacheDir, bindingDir, resourceId, workspace, records, destinationParent, readContent, } = input;
|
|
1278
1504
|
const existing = readRows(database).filter((row) => row.rootUUID === workspace.uuid);
|
|
@@ -1306,19 +1532,10 @@ async function installCloudWorkspace(input) {
|
|
|
1306
1532
|
const materialized = await materializeRecords({
|
|
1307
1533
|
records, rootPath: destination, cacheDir, bindingDir, readContent,
|
|
1308
1534
|
});
|
|
1309
|
-
const rows = materialized.repositories.
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
root: workspace,
|
|
1314
|
-
records,
|
|
1315
|
-
repositories: materialized.repositories,
|
|
1316
|
-
rootPath: destination,
|
|
1317
|
-
database,
|
|
1318
|
-
cacheDir,
|
|
1319
|
-
bindingDir,
|
|
1320
|
-
})
|
|
1321
|
-
: materialized.rows;
|
|
1535
|
+
const rows = projectMaterializedGraph(materialized.rows, materialized.repositories.map((repository) => ({
|
|
1536
|
+
record: repository.record,
|
|
1537
|
+
identity: repository.state.identity,
|
|
1538
|
+
})), sha256Hex);
|
|
1322
1539
|
symlinkSync(destination, binding, "dir");
|
|
1323
1540
|
persistRows(database, identity, resourceId, workspace.uuid, rows);
|
|
1324
1541
|
return {
|
|
@@ -1356,7 +1573,10 @@ function readyFromFrame(frame) {
|
|
|
1356
1573
|
};
|
|
1357
1574
|
}
|
|
1358
1575
|
function frameBytes(frame) {
|
|
1359
|
-
|
|
1576
|
+
if (!(frame.data_bin instanceof Uint8Array)) {
|
|
1577
|
+
throw new Error("wire content response is not a binary frame");
|
|
1578
|
+
}
|
|
1579
|
+
const bytes = Buffer.from(frame.data_bin);
|
|
1360
1580
|
if (bytes.length !== Number(frame.bytes) || sha256Hex(bytes) !== String(frame.sha256 || "")) {
|
|
1361
1581
|
throw new Error("wire content bytes do not match their checksum");
|
|
1362
1582
|
}
|