@amalgm/shell 0.1.74 → 0.1.75
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 +39 -11
- package/dist/detection/runtime.d.ts +3 -0
- package/dist/detection/runtime.js +32 -1
- package/dist/detection/runtime.js.map +1 -1
- package/dist/entity-apply-host.d.ts +11 -2
- package/dist/entity-apply-host.js +25 -3
- package/dist/entity-apply-host.js.map +1 -1
- package/dist/git-command.js +14 -2
- package/dist/git-command.js.map +1 -1
- package/dist/ground-coordination.d.ts +26 -0
- package/dist/ground-coordination.js +83 -0
- package/dist/ground-coordination.js.map +1 -0
- package/dist/user-ground-host.d.ts +5 -2
- package/dist/user-ground-host.js +248 -116
- package/dist/user-ground-host.js.map +1 -1
- package/dist/watching/host.d.ts +5 -0
- package/dist/watching/host.js +22 -0
- package/dist/watching/host.js.map +1 -1
- package/package.json +1 -1
package/dist/user-ground-host.js
CHANGED
|
@@ -21,6 +21,7 @@ import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile
|
|
|
21
21
|
import { inspectGitRegistration, sameGitIdentity, } from "./git-registration-host.js";
|
|
22
22
|
import { projectMaterializedGraph } from "./materialized-graph.js";
|
|
23
23
|
import { NodeWatchHost, } from "./watching/index.js";
|
|
24
|
+
import { GroundCoordinator } from "./ground-coordination.js";
|
|
24
25
|
import { WireClient, WireRequestError } from "./wire-client.js";
|
|
25
26
|
const SMALL_CONTENT_UPLOAD_CONCURRENCY = 16;
|
|
26
27
|
const FILE_CONTENT_DOWNLOAD_CONCURRENCY = 4;
|
|
@@ -28,6 +29,9 @@ const FILE_BATCH_DOWNLOAD_CONCURRENCY = 2;
|
|
|
28
29
|
const DETECT_QUIET_MS = 12;
|
|
29
30
|
const DETECT_MAX_DEFERRAL_MS = 75;
|
|
30
31
|
const DETECT_RETRY_MS = 250;
|
|
32
|
+
/** A Detect walk yields the event loop this often so Send, Receive, and
|
|
33
|
+
* Apply progress during a large reconciliation instead of queueing behind it. */
|
|
34
|
+
const DETECT_WALK_YIELD_ENTRIES = 256;
|
|
31
35
|
const DOWNLOAD_CONTENT_RETRY_ATTEMPTS = 3;
|
|
32
36
|
const APPLY_GROUND_WAIT_TIMEOUT_MS = 15_000;
|
|
33
37
|
const GROUND_ROW_COLUMNS = [
|
|
@@ -56,7 +60,10 @@ export class UserGroundHost {
|
|
|
56
60
|
namedDetect = null;
|
|
57
61
|
coldOperation = false;
|
|
58
62
|
closing = false;
|
|
59
|
-
|
|
63
|
+
/** Visible-ground effects coordinate by address cone: Detect passes hold
|
|
64
|
+
* the roots they observe, Apply holds its target addresses, Register and
|
|
65
|
+
* Add hold the ground they materialize. Unrelated cones never wait. */
|
|
66
|
+
ground = new GroundCoordinator();
|
|
60
67
|
port;
|
|
61
68
|
constructor(options) {
|
|
62
69
|
this.options = options;
|
|
@@ -245,7 +252,7 @@ export class UserGroundHost {
|
|
|
245
252
|
status: "started",
|
|
246
253
|
workspaceId: input.workspace.uuid,
|
|
247
254
|
});
|
|
248
|
-
const release = await this.
|
|
255
|
+
const release = await this.acquireGroundScope(workspaceAddCones(input.destinationParent, input.workspace, userRoot));
|
|
249
256
|
this.stage({
|
|
250
257
|
primitive: "download",
|
|
251
258
|
stage: "ground-wait",
|
|
@@ -332,7 +339,11 @@ export class UserGroundHost {
|
|
|
332
339
|
const userRoot = this.userRoot(identity);
|
|
333
340
|
const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
|
|
334
341
|
for (const intent of readWorkspaceAddIntents(database)) {
|
|
335
|
-
const release = await this.
|
|
342
|
+
const release = await this.acquireGroundScope([
|
|
343
|
+
intent.destinationPath,
|
|
344
|
+
intent.stagingPath,
|
|
345
|
+
userRoot,
|
|
346
|
+
]);
|
|
336
347
|
const visibleAcrossBlindInterval = pathExists(intent.destinationPath);
|
|
337
348
|
try {
|
|
338
349
|
const selection = workspaceAddSelection(intent);
|
|
@@ -445,9 +456,15 @@ export class UserGroundHost {
|
|
|
445
456
|
this.stage({
|
|
446
457
|
primitive: "register", stage: "ground-wait", status: "started", workspaceId,
|
|
447
458
|
});
|
|
459
|
+
const contentOwner = watch.roots.find((root) => root.rootId === workspaceId)?.contentOwner;
|
|
460
|
+
if (!contentOwner) {
|
|
461
|
+
throw new Error(`registered workspace ${workspaceId} has no content coverage owner`);
|
|
462
|
+
}
|
|
448
463
|
if (this.syncing)
|
|
449
464
|
await this.syncing;
|
|
450
|
-
|
|
465
|
+
// Registration reconciles the declared coverage family and core
|
|
466
|
+
// reference ground; unrelated roots keep detecting and applying.
|
|
467
|
+
releaseGround = await this.acquireGroundScope([contentOwner, this.userRoot(identity)]);
|
|
451
468
|
this.stage({
|
|
452
469
|
primitive: "register",
|
|
453
470
|
stage: "ground-wait",
|
|
@@ -455,10 +472,6 @@ export class UserGroundHost {
|
|
|
455
472
|
workspaceId,
|
|
456
473
|
durationMs: performance.now() - groundWaitStarted,
|
|
457
474
|
});
|
|
458
|
-
const contentOwner = watch.roots.find((root) => root.rootId === workspaceId)?.contentOwner;
|
|
459
|
-
if (!contentOwner) {
|
|
460
|
-
throw new Error(`registered workspace ${workspaceId} has no content coverage owner`);
|
|
461
|
-
}
|
|
462
475
|
const observations = this.watchHost.observations().filter((observation) => observation.directory === this.userRoot(identity)
|
|
463
476
|
|| pathWithin(contentOwner, observation.directory));
|
|
464
477
|
this.watchDirty = false;
|
|
@@ -679,17 +692,34 @@ export class UserGroundHost {
|
|
|
679
692
|
bindingDir: workspaceBindingDir(this.userRoot(identity), identity.deviceId),
|
|
680
693
|
readContent: (artifact) => this.downloadContent(this.cloudState.resourceId, this.cacheDir(identity), artifact, DOWNLOAD_CONTENT_RETRY_ATTEMPTS, { journey: "apply" }),
|
|
681
694
|
captureLocal: async (intent, plan, position) => {
|
|
682
|
-
// Apply never suppresses Watch. This explicit
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
|
|
695
|
+
// Apply never suppresses Watch. This explicit named suspicion is the
|
|
696
|
+
// synchronous proof boundary used only when Apply observed a save
|
|
697
|
+
// racing its reveal and must make that save durable first. It rings
|
|
698
|
+
// exactly the addresses Apply compared, so the proof costs one named
|
|
699
|
+
// Detect lane, not a walk of every root.
|
|
700
|
+
let addresses = [];
|
|
701
|
+
try {
|
|
702
|
+
addresses = host.applyAddresses(intent, plan);
|
|
703
|
+
}
|
|
704
|
+
catch {
|
|
705
|
+
// Without an address the whole plan is suspect below.
|
|
706
|
+
}
|
|
707
|
+
if (addresses.length === 0)
|
|
708
|
+
this.watchHost.suspectAll();
|
|
709
|
+
for (const address of addresses)
|
|
710
|
+
this.watchHost.suspect(address);
|
|
686
711
|
this.watchDirty = true;
|
|
687
|
-
|
|
712
|
+
// A pass already queued may have frozen its observations before this
|
|
713
|
+
// ring; keep proving until the generation that holds it has settled.
|
|
714
|
+
const cutoff = this.watchHost.observations();
|
|
715
|
+
do {
|
|
716
|
+
await this.syncNow();
|
|
717
|
+
} while (!this.closing && !this.watchHost.settled(cutoff));
|
|
688
718
|
if (!host.store.hasLocalWorkAfter(intent.authorityId, intent.entityId, intent.throughSequence)) {
|
|
689
719
|
throw new Error(`Apply ${position} capture for ${plan.target.type} ${plan.target.name} (${intent.entityId}) produced no durable local record`);
|
|
690
720
|
}
|
|
691
721
|
},
|
|
692
|
-
acquireGround: () => this.
|
|
722
|
+
acquireGround: (cones) => this.acquireGroundScope(cones, APPLY_GROUND_WAIT_TIMEOUT_MS),
|
|
693
723
|
onStage: (evidence) => {
|
|
694
724
|
this.options.onApplyStage?.(evidence);
|
|
695
725
|
this.stage({
|
|
@@ -717,9 +747,11 @@ export class UserGroundHost {
|
|
|
717
747
|
sha256Hex,
|
|
718
748
|
now: Date.now,
|
|
719
749
|
onBackgroundError: (error) => this.options.onApplyError?.(error),
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
//
|
|
750
|
+
// The host now coordinates by address cone, so Apply's final guard waits
|
|
751
|
+
// only for effects on its own addresses. The rail itself still drains
|
|
752
|
+
// one entity at a time: parallel private preparation multiplies content
|
|
753
|
+
// fetches per connection and has not been certified against the
|
|
754
|
+
// gateway's content lane. Raise deliberately, with that proof.
|
|
723
755
|
concurrency: 1,
|
|
724
756
|
});
|
|
725
757
|
this.applyHost = host;
|
|
@@ -1311,54 +1343,56 @@ export class UserGroundHost {
|
|
|
1311
1343
|
this.scheduleRescan();
|
|
1312
1344
|
}
|
|
1313
1345
|
}
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
const held = new Promise((resolve) => { release = resolve; });
|
|
1317
|
-
const preceding = this.groundEffectTail;
|
|
1318
|
-
this.groundEffectTail = preceding.then(() => held);
|
|
1319
|
-
let released = false;
|
|
1320
|
-
const releaseOnce = () => {
|
|
1321
|
-
if (released)
|
|
1322
|
-
return;
|
|
1323
|
-
released = true;
|
|
1324
|
-
release();
|
|
1325
|
-
};
|
|
1326
|
-
if (timeoutMs === undefined) {
|
|
1327
|
-
await preceding;
|
|
1328
|
-
return releaseOnce;
|
|
1329
|
-
}
|
|
1330
|
-
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
|
|
1331
|
-
// This queued slot has not become active. Let it pass through as soon as
|
|
1332
|
-
// the preceding owner finishes so invalid input cannot poison the tail.
|
|
1333
|
-
void preceding.then(releaseOnce, releaseOnce);
|
|
1334
|
-
throw new Error("ground-effect wait timeout must be a positive integer");
|
|
1335
|
-
}
|
|
1336
|
-
let timeout = null;
|
|
1337
|
-
try {
|
|
1338
|
-
await Promise.race([
|
|
1339
|
-
preceding,
|
|
1340
|
-
new Promise((_resolve, reject) => {
|
|
1341
|
-
timeout = setTimeout(() => reject(new Error(`ground-effect wait exceeded ${timeoutMs}ms`)), timeoutMs);
|
|
1342
|
-
}),
|
|
1343
|
-
]);
|
|
1344
|
-
return releaseOnce;
|
|
1345
|
-
}
|
|
1346
|
-
catch (error) {
|
|
1347
|
-
// The timed-out waiter still owns one FIFO slot. Automatically release
|
|
1348
|
-
// that slot when it reaches the front; otherwise one timeout would block
|
|
1349
|
-
// every later filesystem effect forever.
|
|
1350
|
-
void preceding.then(releaseOnce, releaseOnce);
|
|
1351
|
-
throw error;
|
|
1352
|
-
}
|
|
1353
|
-
finally {
|
|
1354
|
-
if (timeout)
|
|
1355
|
-
clearTimeout(timeout);
|
|
1356
|
-
}
|
|
1346
|
+
acquireGroundScope(cones, timeoutMs) {
|
|
1347
|
+
return this.ground.acquire(cones, timeoutMs === undefined ? {} : { timeoutMs });
|
|
1357
1348
|
}
|
|
1358
1349
|
async syncLocalChanges(identity) {
|
|
1350
|
+
if (!this.cloudState)
|
|
1351
|
+
throw new Error("cloud state is unavailable for user-ground Watch");
|
|
1352
|
+
const userRoot = this.userRoot(identity);
|
|
1353
|
+
const repair = registrationRepairGround(this.databasePath(identity), userRoot, identity.deviceId);
|
|
1354
|
+
if (repair.core || repair.emptyRoots.length > 0) {
|
|
1355
|
+
// A durable binding is registration evidence even if the native event
|
|
1356
|
+
// that created it was lost across a crash. Rebuild coverage first, then
|
|
1357
|
+
// express that uncertainty through Watch like every other Detect input.
|
|
1358
|
+
// These are ambiguous recovery facts, so each affected root is wholly
|
|
1359
|
+
// suspect; unrelated roots remain untouched.
|
|
1360
|
+
this.ensureWatchers(identity);
|
|
1361
|
+
if (repair.core)
|
|
1362
|
+
this.watchHost.suspect(userRoot);
|
|
1363
|
+
for (const root of repair.emptyRoots)
|
|
1364
|
+
this.watchHost.suspect(root);
|
|
1365
|
+
}
|
|
1366
|
+
// Detect is per root. A root without pending suspicion has nothing to
|
|
1367
|
+
// prove and never enters a pass: it costs no walk, no notebook plan, no
|
|
1368
|
+
// repository inspection, and no ground coordination. Only rung roots are
|
|
1369
|
+
// observed, and each rung root is proved on its own lane before any root
|
|
1370
|
+
// widens to reconciliation. Rings after this snapshot own the next pass.
|
|
1371
|
+
const observations = this.watchHost.observations();
|
|
1372
|
+
const pending = observations.filter(({ suspicion }) => suspicion.paths === null || suspicion.paths.length > 0);
|
|
1373
|
+
// An idle root's generation is already proved; settling it keeps every
|
|
1374
|
+
// root's settled generation current without any filesystem work.
|
|
1375
|
+
this.watchHost.settle(observations.filter((observation) => !pending.includes(observation)));
|
|
1376
|
+
if (pending.length === 0) {
|
|
1377
|
+
this.stage({
|
|
1378
|
+
primitive: "detect",
|
|
1379
|
+
stage: "pass",
|
|
1380
|
+
status: "completed",
|
|
1381
|
+
durationMs: 0,
|
|
1382
|
+
measurements: { lane: "idle", roots: 0, records: 0 },
|
|
1383
|
+
});
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
const cones = new Set(pending.map((observation) => observation.directory));
|
|
1387
|
+
const coreScan = cones.has(userRoot);
|
|
1359
1388
|
const waitStarted = performance.now();
|
|
1360
|
-
this.stage({
|
|
1361
|
-
|
|
1389
|
+
this.stage({
|
|
1390
|
+
primitive: "detect",
|
|
1391
|
+
stage: "ground-wait",
|
|
1392
|
+
status: "started",
|
|
1393
|
+
measurements: { roots: pending.length },
|
|
1394
|
+
});
|
|
1395
|
+
const release = await this.acquireGroundScope([...cones]);
|
|
1362
1396
|
this.stage({
|
|
1363
1397
|
primitive: "detect",
|
|
1364
1398
|
stage: "ground-wait",
|
|
@@ -1366,39 +1400,50 @@ export class UserGroundHost {
|
|
|
1366
1400
|
durationMs: performance.now() - waitStarted,
|
|
1367
1401
|
});
|
|
1368
1402
|
try {
|
|
1369
|
-
await this.syncLocalChangesUnlocked(identity);
|
|
1403
|
+
await this.syncLocalChangesUnlocked(identity, pending, coreScan);
|
|
1370
1404
|
}
|
|
1371
1405
|
finally {
|
|
1372
1406
|
release();
|
|
1373
1407
|
}
|
|
1374
1408
|
}
|
|
1375
|
-
async syncLocalChangesUnlocked(identity) {
|
|
1409
|
+
async syncLocalChangesUnlocked(identity, pending, coreScan) {
|
|
1376
1410
|
if (!this.cloudState)
|
|
1377
1411
|
throw new Error("cloud state is unavailable for user-ground Watch");
|
|
1378
|
-
const observations = this.watchHost.observations();
|
|
1379
1412
|
const passStarted = performance.now();
|
|
1380
1413
|
this.stage({
|
|
1381
1414
|
primitive: "detect",
|
|
1382
1415
|
stage: "pass",
|
|
1383
1416
|
status: "started",
|
|
1384
1417
|
measurements: {
|
|
1385
|
-
roots:
|
|
1386
|
-
completeRoots:
|
|
1387
|
-
namedPaths:
|
|
1418
|
+
roots: pending.length,
|
|
1419
|
+
completeRoots: pending.filter(({ suspicion }) => suspicion.paths === null).length,
|
|
1420
|
+
namedPaths: pending.reduce((count, { suspicion }) => count + (suspicion.paths?.length ?? 0), 0),
|
|
1388
1421
|
},
|
|
1389
1422
|
});
|
|
1390
|
-
|
|
1391
|
-
|
|
1423
|
+
const reconcileSuspicions = new Map();
|
|
1424
|
+
const reconcileObservations = [];
|
|
1425
|
+
let namedRoots = 0;
|
|
1426
|
+
let namedRecords = 0;
|
|
1427
|
+
let unsettled = false;
|
|
1428
|
+
for (const observation of pending) {
|
|
1429
|
+
if (observation.suspicion.paths === null || !this.namedDetect) {
|
|
1430
|
+
reconcileSuspicions.set(observation.directory, observation.suspicion);
|
|
1431
|
+
reconcileObservations.push(observation);
|
|
1432
|
+
continue;
|
|
1433
|
+
}
|
|
1392
1434
|
// The named lane is an optimization over the same settled evidence.
|
|
1393
|
-
// Any proof failure occurs before its SQLite transaction and widens
|
|
1394
|
-
// reconciliation; it must never poison a Watch
|
|
1395
|
-
// rejection that only another
|
|
1396
|
-
const named = await this.namedDetect.detect(
|
|
1435
|
+
// Any proof failure occurs before its SQLite transaction and widens
|
|
1436
|
+
// only this root to reconciliation; it must never poison a Watch
|
|
1437
|
+
// generation in a timer rejection that only another event can revive.
|
|
1438
|
+
const named = await this.namedDetect.detect([observation]).catch(() => null);
|
|
1439
|
+
if (named?.unsettled) {
|
|
1440
|
+
unsettled = true;
|
|
1441
|
+
continue;
|
|
1442
|
+
}
|
|
1397
1443
|
if (named?.handled) {
|
|
1398
1444
|
for (const evidence of named.evidence) {
|
|
1399
|
-
const observation = observations.find((candidate) => candidate.directory === evidence.directory);
|
|
1400
1445
|
this.reportDetectScan({
|
|
1401
|
-
rootId: observation
|
|
1446
|
+
rootId: observation.rootId,
|
|
1402
1447
|
directory: evidence.directory,
|
|
1403
1448
|
scope: "paths",
|
|
1404
1449
|
entries: evidence.metadataReads,
|
|
@@ -1411,24 +1456,34 @@ export class UserGroundHost {
|
|
|
1411
1456
|
records: evidence.records,
|
|
1412
1457
|
recordBytes: evidence.recordBytes,
|
|
1413
1458
|
});
|
|
1459
|
+
namedRecords += evidence.records;
|
|
1414
1460
|
}
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
this.
|
|
1419
|
-
|
|
1420
|
-
stage: "pass",
|
|
1421
|
-
status: "completed",
|
|
1422
|
-
durationMs: performance.now() - passStarted,
|
|
1423
|
-
measurements: {
|
|
1424
|
-
lane: "named",
|
|
1425
|
-
roots: named.evidence.length,
|
|
1426
|
-
records: named.evidence.reduce((count, evidence) => count + evidence.records, 0),
|
|
1427
|
-
},
|
|
1428
|
-
});
|
|
1429
|
-
return;
|
|
1461
|
+
namedRoots += 1;
|
|
1462
|
+
// This root's truth is durable; its generation settles now so a later
|
|
1463
|
+
// failure in another root's reconciliation cannot make it re-prove.
|
|
1464
|
+
this.watchHost.settle([observation]);
|
|
1465
|
+
continue;
|
|
1430
1466
|
}
|
|
1431
|
-
|
|
1467
|
+
const widened = named?.widenedRoots?.includes(observation.directory) === true;
|
|
1468
|
+
reconcileSuspicions.set(observation.directory, widened ? { ...observation.suspicion, paths: null } : observation.suspicion);
|
|
1469
|
+
reconcileObservations.push(observation);
|
|
1470
|
+
}
|
|
1471
|
+
if (namedRoots > 0) {
|
|
1472
|
+
this.applyRail?.localStateChanged();
|
|
1473
|
+
this.recordRail?.localRecordsAvailable();
|
|
1474
|
+
}
|
|
1475
|
+
if (reconcileObservations.length === 0) {
|
|
1476
|
+
this.stage({
|
|
1477
|
+
primitive: "detect",
|
|
1478
|
+
stage: "pass",
|
|
1479
|
+
status: "completed",
|
|
1480
|
+
durationMs: performance.now() - passStarted,
|
|
1481
|
+
measurements: { lane: "named", roots: namedRoots, records: namedRecords },
|
|
1482
|
+
});
|
|
1483
|
+
if (unsettled) {
|
|
1484
|
+
throw pipelineStageError("detection", new Error("repository control operation is unsettled"));
|
|
1485
|
+
}
|
|
1486
|
+
return;
|
|
1432
1487
|
}
|
|
1433
1488
|
let detection;
|
|
1434
1489
|
let acceptedMaterializedRows;
|
|
@@ -1442,9 +1497,8 @@ export class UserGroundHost {
|
|
|
1442
1497
|
database: this.databasePath(identity),
|
|
1443
1498
|
cacheDir: this.cacheDir(identity),
|
|
1444
1499
|
onScan: (evidence) => this.reportDetectScan(evidence),
|
|
1445
|
-
suspicions:
|
|
1446
|
-
|
|
1447
|
-
: observation.suspicion])),
|
|
1500
|
+
suspicions: reconcileSuspicions,
|
|
1501
|
+
coreScan,
|
|
1448
1502
|
});
|
|
1449
1503
|
detection = scanned.detection;
|
|
1450
1504
|
acceptedMaterializedRows = scanned.acceptedMaterializedRows;
|
|
@@ -1501,7 +1555,7 @@ export class UserGroundHost {
|
|
|
1501
1555
|
}
|
|
1502
1556
|
}
|
|
1503
1557
|
this.namedDetect?.refreshEnrollmentPolicy();
|
|
1504
|
-
this.watchHost.settle(
|
|
1558
|
+
this.watchHost.settle(reconcileObservations);
|
|
1505
1559
|
this.applyRail?.localStateChanged();
|
|
1506
1560
|
this.recordRail?.localRecordsAvailable();
|
|
1507
1561
|
this.stage({
|
|
@@ -1509,8 +1563,16 @@ export class UserGroundHost {
|
|
|
1509
1563
|
stage: "pass",
|
|
1510
1564
|
status: "completed",
|
|
1511
1565
|
durationMs: performance.now() - passStarted,
|
|
1512
|
-
measurements: {
|
|
1566
|
+
measurements: {
|
|
1567
|
+
lane: "reconciliation",
|
|
1568
|
+
roots: reconcileObservations.length,
|
|
1569
|
+
namedRoots,
|
|
1570
|
+
records: detectedRecords.length + namedRecords,
|
|
1571
|
+
},
|
|
1513
1572
|
});
|
|
1573
|
+
if (unsettled) {
|
|
1574
|
+
throw pipelineStageError("detection", new Error("repository control operation is unsettled"));
|
|
1575
|
+
}
|
|
1514
1576
|
}
|
|
1515
1577
|
async publishPendingSnapshotsBeforeLookup(identity, resourceId) {
|
|
1516
1578
|
const pending = readSnapshotPublications(this.databasePath(identity));
|
|
@@ -2015,6 +2077,41 @@ function countDescendantRows(file, rootUUID) {
|
|
|
2015
2077
|
database.close();
|
|
2016
2078
|
}
|
|
2017
2079
|
}
|
|
2080
|
+
/** Recovery facts created before Watch could prove registration: a binding
|
|
2081
|
+
* whose portable reference is absent from the notebook, or a bound root that
|
|
2082
|
+
* has never produced a local entity row. They pull only those exact roots
|
|
2083
|
+
* into the next Detect pass. */
|
|
2084
|
+
function registrationRepairGround(file, userRoot, deviceId) {
|
|
2085
|
+
const bindings = materializedWorkspaceBindings(workspaceBindingDir(userRoot, deviceId));
|
|
2086
|
+
if (bindings.length === 0)
|
|
2087
|
+
return { core: false, emptyRoots: [] };
|
|
2088
|
+
if (!existsSync(file)) {
|
|
2089
|
+
return { core: true, emptyRoots: bindings.map(({ directory }) => directory) };
|
|
2090
|
+
}
|
|
2091
|
+
const core = readGroundRootAtPath(file, "detection_notebook", userRoot);
|
|
2092
|
+
if (!core) {
|
|
2093
|
+
return { core: true, emptyRoots: bindings.map(({ directory }) => directory) };
|
|
2094
|
+
}
|
|
2095
|
+
const database = initializeDatabase(file);
|
|
2096
|
+
try {
|
|
2097
|
+
const referenced = new Set(database.prepare(`
|
|
2098
|
+
SELECT payload_version AS payloadVersion FROM detection_notebook
|
|
2099
|
+
WHERE root_uuid = ? AND type = 'reference' AND status = 'active'
|
|
2100
|
+
AND payload_version IS NOT NULL
|
|
2101
|
+
`).all(core.uuid).map((row) => row.payloadVersion));
|
|
2102
|
+
const hasRows = database.prepare(`
|
|
2103
|
+
SELECT 1 AS present FROM entities WHERE root_uuid = ? LIMIT 1
|
|
2104
|
+
`);
|
|
2105
|
+
return {
|
|
2106
|
+
core: bindings.some(({ workspaceId }) => !referenced.has(workspaceId)),
|
|
2107
|
+
emptyRoots: bindings.filter(({ workspaceId }) => !hasRows.get(workspaceId))
|
|
2108
|
+
.map(({ directory }) => directory),
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
2111
|
+
finally {
|
|
2112
|
+
database.close();
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2018
2115
|
function readGroundUUIDs(file, table, rootUUIDs) {
|
|
2019
2116
|
if (!existsSync(file))
|
|
2020
2117
|
return new Set();
|
|
@@ -2036,6 +2133,20 @@ function readGroundUUIDs(file, table, rootUUIDs) {
|
|
|
2036
2133
|
database.close();
|
|
2037
2134
|
}
|
|
2038
2135
|
}
|
|
2136
|
+
/** The ground an Add materialization touches: its destination, its sibling
|
|
2137
|
+
* staging directory, and core ground where the binding and reference live.
|
|
2138
|
+
* An unresolvable parent still names itself; the install reports the error. */
|
|
2139
|
+
function workspaceAddCones(destinationParent, workspace, userRoot) {
|
|
2140
|
+
let parent = resolve(destinationParent);
|
|
2141
|
+
try {
|
|
2142
|
+
parent = realpathSync(parent);
|
|
2143
|
+
}
|
|
2144
|
+
catch {
|
|
2145
|
+
// Reported by installCloudWorkspace with its exact error.
|
|
2146
|
+
}
|
|
2147
|
+
const destination = join(parent, workspace.name);
|
|
2148
|
+
return [destination, workspaceAddStagingPath(destination, workspace.uuid), userRoot];
|
|
2149
|
+
}
|
|
2039
2150
|
function workspaceAddStagingPath(destinationPath, workspaceId) {
|
|
2040
2151
|
return join(dirname(destinationPath), `.amalgm-${workspaceId}.adding`);
|
|
2041
2152
|
}
|
|
@@ -2569,6 +2680,11 @@ function createDeclaredHome(input) {
|
|
|
2569
2680
|
}
|
|
2570
2681
|
}
|
|
2571
2682
|
}
|
|
2683
|
+
/** One macrotask turn: pending I/O, timers, and wire frames run before the
|
|
2684
|
+
* caller resumes. */
|
|
2685
|
+
function yieldEventLoop() {
|
|
2686
|
+
return new Promise((resolve) => setImmediate(resolve));
|
|
2687
|
+
}
|
|
2572
2688
|
function slashPath(path) {
|
|
2573
2689
|
return path.split(sep).join("/");
|
|
2574
2690
|
}
|
|
@@ -2629,7 +2745,7 @@ function materializedWorkspaceBindings(bindingDir) {
|
|
|
2629
2745
|
return bindings.sort((left, right) => left.workspaceId.localeCompare(right.workspaceId));
|
|
2630
2746
|
}
|
|
2631
2747
|
async function reconcileGround(input) {
|
|
2632
|
-
const { identity, userRoot, database, cacheDir, suspicions, registrationWorkspaceId, onScan, } = input;
|
|
2748
|
+
const { identity, userRoot, database, cacheDir, suspicions, coreScan, registrationWorkspaceId, onScan, } = input;
|
|
2633
2749
|
const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
|
|
2634
2750
|
const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
|
|
2635
2751
|
const boundRootEntries = materializedWorkspaceBindings(bindingDir);
|
|
@@ -2759,30 +2875,40 @@ async function reconcileGround(input) {
|
|
|
2759
2875
|
});
|
|
2760
2876
|
// Each root commits its local projection before Detect yields. Native
|
|
2761
2877
|
// callbacks can therefore preserve new suspicion between large roots.
|
|
2762
|
-
await
|
|
2878
|
+
await yieldEventLoop();
|
|
2763
2879
|
return result;
|
|
2764
2880
|
};
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
:
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2881
|
+
// A Watch pass names exactly the roots it observed. A root outside that
|
|
2882
|
+
// observation has nothing to prove and is not scanned, planned, or
|
|
2883
|
+
// inspected; its rows stay as they are. Only two facts can still pull an
|
|
2884
|
+
// unobserved root in: core ground missing a portable reference for a
|
|
2885
|
+
// binding, and a bound root that has never produced a row.
|
|
2886
|
+
const observed = (root) => !suspicions || suspicions.has(root);
|
|
2887
|
+
if (observed(userRoot) || (missingPortableReference && coreScan !== false)) {
|
|
2888
|
+
await scan({
|
|
2889
|
+
identity,
|
|
2890
|
+
resourceId,
|
|
2891
|
+
rootUUID: coreUUID,
|
|
2892
|
+
rootName: identity.userEmail,
|
|
2893
|
+
rootPath: userRoot,
|
|
2894
|
+
rootType: "workspace",
|
|
2895
|
+
database,
|
|
2896
|
+
cacheDir,
|
|
2897
|
+
policy,
|
|
2898
|
+
bindingDir,
|
|
2899
|
+
suspects: missingPortableReference && coreSuspicion !== null
|
|
2900
|
+
? [...new Set([...coreSuspicion, "workspaces"])]
|
|
2901
|
+
: coreSuspicion,
|
|
2902
|
+
existingRows: existing.filter((row) => row.rootUUID === coreUUID),
|
|
2903
|
+
suspicion: observationFor(userRoot),
|
|
2904
|
+
});
|
|
2905
|
+
}
|
|
2782
2906
|
const physicalRoots = registrationOwner ? [registrationOwner] : outerBoundRoots;
|
|
2783
2907
|
for (const { workspaceId, directory: rootPath } of physicalRoots) {
|
|
2784
2908
|
const existingRoot = existingByUuid.get(workspaceId);
|
|
2785
2909
|
const existingWorkspaceRows = existingByOutermostRoot.get(workspaceId) || [];
|
|
2910
|
+
if (!observed(rootPath) && existingWorkspaceRows.length > 0)
|
|
2911
|
+
continue;
|
|
2786
2912
|
const registeredPath = registrationBoundary
|
|
2787
2913
|
&& registrationOwner?.workspaceId === workspaceId
|
|
2788
2914
|
&& registrationBoundary.workspaceId !== workspaceId
|
|
@@ -2970,6 +3096,7 @@ async function reconcileRoot(input) {
|
|
|
2970
3096
|
}
|
|
2971
3097
|
return [...new Set(paths)].sort();
|
|
2972
3098
|
};
|
|
3099
|
+
let visitedEntries = 0;
|
|
2973
3100
|
const visit = async (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
|
|
2974
3101
|
? {
|
|
2975
3102
|
root: directory,
|
|
@@ -2996,6 +3123,11 @@ async function reconcileRoot(input) {
|
|
|
2996
3123
|
const absolutePath = join(directory, child.name);
|
|
2997
3124
|
const stats = lstatSync(absolutePath);
|
|
2998
3125
|
evidence && (evidence.entries += 1);
|
|
3126
|
+
// The walk is synchronous metadata work. Yield periodically so the
|
|
3127
|
+
// wire, Receive, and Apply keep running while a large root is proved.
|
|
3128
|
+
// A Watch ring during the yield remains a later generation.
|
|
3129
|
+
if (++visitedEntries % DETECT_WALK_YIELD_ENTRIES === 0)
|
|
3130
|
+
await yieldEventLoop();
|
|
2999
3131
|
const existing = existingByPath.get(relativePath);
|
|
3000
3132
|
const registeredBoundaryId = stats.isDirectory()
|
|
3001
3133
|
? registeredBoundaries.get(resolve(absolutePath))
|