@amalgm/shell 0.1.74 → 0.1.76
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 +48 -11
- package/dist/cli.js +7 -0
- package/dist/cli.js.map +1 -1
- 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 +69 -6
- 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/git-repository-host.js +19 -1
- package/dist/git-repository-host.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 +12 -4
- package/dist/user-ground-host.js +353 -171
- 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/dist/wire-client.d.ts +9 -0
- package/dist/wire-client.js +79 -20
- package/dist/wire-client.js.map +1 -1
- package/package.json +2 -2
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",
|
|
@@ -326,62 +333,85 @@ export class UserGroundHost {
|
|
|
326
333
|
return { register, add };
|
|
327
334
|
}
|
|
328
335
|
/** Resume the same host effect used by the SDK command before readiness.
|
|
329
|
-
* Staged ground has no binding, so recovery cannot race Watch or Detect.
|
|
336
|
+
* Staged ground has no binding, so recovery cannot race Watch or Detect.
|
|
337
|
+
* Each intent is one workspace's durable responsibility, not the machine's:
|
|
338
|
+
* an intent that cannot be satisfied now is reported and stays durable for
|
|
339
|
+
* the next start or an explicit re-Add, while readiness proceeds. */
|
|
330
340
|
async resumeWorkspaceAdds(identity) {
|
|
331
|
-
const
|
|
332
|
-
const userRoot = this.userRoot(identity);
|
|
333
|
-
const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
|
|
334
|
-
for (const intent of readWorkspaceAddIntents(database)) {
|
|
335
|
-
const release = await this.acquireGroundEffect();
|
|
336
|
-
const visibleAcrossBlindInterval = pathExists(intent.destinationPath);
|
|
341
|
+
for (const intent of readWorkspaceAddIntents(this.databasePath(identity))) {
|
|
337
342
|
try {
|
|
338
|
-
|
|
339
|
-
const stagedAcrossBlindInterval = pathExists(intent.stagingPath);
|
|
340
|
-
if (stagedAcrossBlindInterval && hasRootRows(database, intent.resourceId, intent.workspaceId)) {
|
|
341
|
-
// Rows prove that staging was verified before the crash, not that its
|
|
342
|
-
// bytes remained unchanged while no watcher existed. Return this
|
|
343
|
-
// private install to the intent-only stage and reproduce it from the
|
|
344
|
-
// verified immutable cache before making it visible.
|
|
345
|
-
deleteRootRows(database, intent.resourceId, intent.workspaceId);
|
|
346
|
-
}
|
|
347
|
-
await installCloudWorkspace({
|
|
348
|
-
identity,
|
|
349
|
-
userRoot,
|
|
350
|
-
database,
|
|
351
|
-
cacheDir: this.cacheDir(identity),
|
|
352
|
-
bindingDir,
|
|
353
|
-
resourceId: intent.resourceId,
|
|
354
|
-
workspace: selection.workspace,
|
|
355
|
-
reference: selection.reference,
|
|
356
|
-
references: intent.references,
|
|
357
|
-
records: intent.records,
|
|
358
|
-
destinationParent: dirname(intent.destinationPath),
|
|
359
|
-
cloudHead: intent.cloudHead,
|
|
360
|
-
readContent: (artifact) => this.downloadContent(intent.resourceId, this.cacheDir(identity), artifact, 10, { journey: "add", workspaceId: intent.workspaceId }),
|
|
361
|
-
onStage: (stage) => this.options.onWorkspaceAddStage?.({
|
|
362
|
-
workspaceId: intent.workspaceId,
|
|
363
|
-
stage,
|
|
364
|
-
}),
|
|
365
|
-
});
|
|
366
|
-
this.ensureWatchers(identity, visibleAcrossBlindInterval ? [] : [intent.workspaceId]);
|
|
367
|
-
assertHealthyWatch(this.watchHost.evidence(), intent.workspaceId);
|
|
343
|
+
await this.resumeWorkspaceAdd(identity, intent);
|
|
368
344
|
}
|
|
369
|
-
|
|
370
|
-
|
|
345
|
+
catch (error) {
|
|
346
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
347
|
+
this.stage({
|
|
348
|
+
primitive: "download",
|
|
349
|
+
stage: "add-resume",
|
|
350
|
+
status: "failed",
|
|
351
|
+
workspaceId: intent.workspaceId,
|
|
352
|
+
measurements: { error: message },
|
|
353
|
+
});
|
|
354
|
+
this.options.onApplyError?.(new Error(`workspace Add ${intent.workspaceId} could not resume and stays pending: ${message}`, { cause: error }));
|
|
371
355
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
async resumeWorkspaceAdd(identity, intent) {
|
|
359
|
+
const database = this.databasePath(identity);
|
|
360
|
+
const userRoot = this.userRoot(identity);
|
|
361
|
+
const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
|
|
362
|
+
const release = await this.acquireGroundScope([
|
|
363
|
+
intent.destinationPath,
|
|
364
|
+
intent.stagingPath,
|
|
365
|
+
userRoot,
|
|
366
|
+
]);
|
|
367
|
+
const visibleAcrossBlindInterval = pathExists(intent.destinationPath);
|
|
368
|
+
try {
|
|
369
|
+
const selection = workspaceAddSelection(intent);
|
|
370
|
+
const stagedAcrossBlindInterval = pathExists(intent.stagingPath);
|
|
371
|
+
if (stagedAcrossBlindInterval && hasRootRows(database, intent.resourceId, intent.workspaceId)) {
|
|
372
|
+
// Rows prove that staging was verified before the crash, not that its
|
|
373
|
+
// bytes remained unchanged while no watcher existed. Return this
|
|
374
|
+
// private install to the intent-only stage and reproduce it from the
|
|
375
|
+
// verified immutable cache before making it visible.
|
|
376
|
+
deleteRootRows(database, intent.resourceId, intent.workspaceId);
|
|
378
377
|
}
|
|
379
|
-
await
|
|
380
|
-
|
|
381
|
-
|
|
378
|
+
await installCloudWorkspace({
|
|
379
|
+
identity,
|
|
380
|
+
userRoot,
|
|
381
|
+
database,
|
|
382
|
+
cacheDir: this.cacheDir(identity),
|
|
383
|
+
bindingDir,
|
|
384
|
+
resourceId: intent.resourceId,
|
|
385
|
+
workspace: selection.workspace,
|
|
386
|
+
reference: selection.reference,
|
|
387
|
+
references: intent.references,
|
|
388
|
+
records: intent.records,
|
|
389
|
+
destinationParent: dirname(intent.destinationPath),
|
|
390
|
+
cloudHead: intent.cloudHead,
|
|
391
|
+
readContent: (artifact) => this.downloadContent(intent.resourceId, this.cacheDir(identity), artifact, 10, { journey: "add", workspaceId: intent.workspaceId }),
|
|
392
|
+
onStage: (stage) => this.options.onWorkspaceAddStage?.({
|
|
393
|
+
workspaceId: intent.workspaceId,
|
|
394
|
+
stage,
|
|
395
|
+
}),
|
|
382
396
|
});
|
|
383
|
-
|
|
397
|
+
this.ensureWatchers(identity, visibleAcrossBlindInterval ? [] : [intent.workspaceId]);
|
|
398
|
+
assertHealthyWatch(this.watchHost.evidence(), intent.workspaceId);
|
|
384
399
|
}
|
|
400
|
+
finally {
|
|
401
|
+
release();
|
|
402
|
+
}
|
|
403
|
+
if (visibleAcrossBlindInterval) {
|
|
404
|
+
// Ground that was visible while the runtime was absent may have been
|
|
405
|
+
// edited after its verified reveal. It is an ordinary startup blind
|
|
406
|
+
// interval, so Watch must retain complete suspicion and Detect must
|
|
407
|
+
// settle it before the Add responsibility can be cleared.
|
|
408
|
+
await this.flushObservedChanges();
|
|
409
|
+
}
|
|
410
|
+
await this.options.onWorkspaceAddStage?.({
|
|
411
|
+
workspaceId: intent.workspaceId,
|
|
412
|
+
stage: "watching",
|
|
413
|
+
});
|
|
414
|
+
deleteWorkspaceAddIntent(database, intent.workspaceId);
|
|
385
415
|
}
|
|
386
416
|
/** Finish the user-visible register journey inside the one persistent Files
|
|
387
417
|
* owner. The declaration remains durable if any later stage fails, and a
|
|
@@ -445,9 +475,15 @@ export class UserGroundHost {
|
|
|
445
475
|
this.stage({
|
|
446
476
|
primitive: "register", stage: "ground-wait", status: "started", workspaceId,
|
|
447
477
|
});
|
|
478
|
+
const contentOwner = watch.roots.find((root) => root.rootId === workspaceId)?.contentOwner;
|
|
479
|
+
if (!contentOwner) {
|
|
480
|
+
throw new Error(`registered workspace ${workspaceId} has no content coverage owner`);
|
|
481
|
+
}
|
|
448
482
|
if (this.syncing)
|
|
449
483
|
await this.syncing;
|
|
450
|
-
|
|
484
|
+
// Registration reconciles the declared coverage family and core
|
|
485
|
+
// reference ground; unrelated roots keep detecting and applying.
|
|
486
|
+
releaseGround = await this.acquireGroundScope([contentOwner, this.userRoot(identity)]);
|
|
451
487
|
this.stage({
|
|
452
488
|
primitive: "register",
|
|
453
489
|
stage: "ground-wait",
|
|
@@ -455,10 +491,6 @@ export class UserGroundHost {
|
|
|
455
491
|
workspaceId,
|
|
456
492
|
durationMs: performance.now() - groundWaitStarted,
|
|
457
493
|
});
|
|
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
494
|
const observations = this.watchHost.observations().filter((observation) => observation.directory === this.userRoot(identity)
|
|
463
495
|
|| pathWithin(contentOwner, observation.directory));
|
|
464
496
|
this.watchDirty = false;
|
|
@@ -679,17 +711,34 @@ export class UserGroundHost {
|
|
|
679
711
|
bindingDir: workspaceBindingDir(this.userRoot(identity), identity.deviceId),
|
|
680
712
|
readContent: (artifact) => this.downloadContent(this.cloudState.resourceId, this.cacheDir(identity), artifact, DOWNLOAD_CONTENT_RETRY_ATTEMPTS, { journey: "apply" }),
|
|
681
713
|
captureLocal: async (intent, plan, position) => {
|
|
682
|
-
// Apply never suppresses Watch. This explicit
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
|
|
714
|
+
// Apply never suppresses Watch. This explicit named suspicion is the
|
|
715
|
+
// synchronous proof boundary used only when Apply observed a save
|
|
716
|
+
// racing its reveal and must make that save durable first. It rings
|
|
717
|
+
// exactly the addresses Apply compared, so the proof costs one named
|
|
718
|
+
// Detect lane, not a walk of every root.
|
|
719
|
+
let addresses = [];
|
|
720
|
+
try {
|
|
721
|
+
addresses = host.applyAddresses(intent, plan);
|
|
722
|
+
}
|
|
723
|
+
catch {
|
|
724
|
+
// Without an address the whole plan is suspect below.
|
|
725
|
+
}
|
|
726
|
+
if (addresses.length === 0)
|
|
727
|
+
this.watchHost.suspectAll();
|
|
728
|
+
for (const address of addresses)
|
|
729
|
+
this.watchHost.suspect(address);
|
|
686
730
|
this.watchDirty = true;
|
|
687
|
-
|
|
731
|
+
// A pass already queued may have frozen its observations before this
|
|
732
|
+
// ring; keep proving until the generation that holds it has settled.
|
|
733
|
+
const cutoff = this.watchHost.observations();
|
|
734
|
+
do {
|
|
735
|
+
await this.syncNow();
|
|
736
|
+
} while (!this.closing && !this.watchHost.settled(cutoff));
|
|
688
737
|
if (!host.store.hasLocalWorkAfter(intent.authorityId, intent.entityId, intent.throughSequence)) {
|
|
689
738
|
throw new Error(`Apply ${position} capture for ${plan.target.type} ${plan.target.name} (${intent.entityId}) produced no durable local record`);
|
|
690
739
|
}
|
|
691
740
|
},
|
|
692
|
-
acquireGround: () => this.
|
|
741
|
+
acquireGround: (cones) => this.acquireGroundScope(cones, APPLY_GROUND_WAIT_TIMEOUT_MS),
|
|
693
742
|
onStage: (evidence) => {
|
|
694
743
|
this.options.onApplyStage?.(evidence);
|
|
695
744
|
this.stage({
|
|
@@ -716,10 +765,23 @@ export class UserGroundHost {
|
|
|
716
765
|
},
|
|
717
766
|
sha256Hex,
|
|
718
767
|
now: Date.now,
|
|
719
|
-
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
|
|
768
|
+
// A failed Apply lane is one entity's outcome; the rail keeps every
|
|
769
|
+
// other lane moving and retries this one. Surface it as evidence so a
|
|
770
|
+
// persistently failing entity is visible rather than silent.
|
|
771
|
+
onBackgroundError: (error) => {
|
|
772
|
+
this.options.onApplyError?.(error);
|
|
773
|
+
this.stage({
|
|
774
|
+
primitive: "apply",
|
|
775
|
+
stage: "lane",
|
|
776
|
+
status: "failed",
|
|
777
|
+
measurements: { error: error instanceof Error ? error.message : String(error) },
|
|
778
|
+
});
|
|
779
|
+
},
|
|
780
|
+
// The host now coordinates by address cone, so Apply's final guard waits
|
|
781
|
+
// only for effects on its own addresses. The rail itself still drains
|
|
782
|
+
// one entity at a time: parallel private preparation multiplies content
|
|
783
|
+
// fetches per connection and has not been certified against the
|
|
784
|
+
// gateway's content lane. Raise deliberately, with that proof.
|
|
723
785
|
concurrency: 1,
|
|
724
786
|
});
|
|
725
787
|
this.applyHost = host;
|
|
@@ -1311,54 +1373,56 @@ export class UserGroundHost {
|
|
|
1311
1373
|
this.scheduleRescan();
|
|
1312
1374
|
}
|
|
1313
1375
|
}
|
|
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
|
-
}
|
|
1376
|
+
acquireGroundScope(cones, timeoutMs) {
|
|
1377
|
+
return this.ground.acquire(cones, timeoutMs === undefined ? {} : { timeoutMs });
|
|
1357
1378
|
}
|
|
1358
1379
|
async syncLocalChanges(identity) {
|
|
1380
|
+
if (!this.cloudState)
|
|
1381
|
+
throw new Error("cloud state is unavailable for user-ground Watch");
|
|
1382
|
+
const userRoot = this.userRoot(identity);
|
|
1383
|
+
const repair = registrationRepairGround(this.databasePath(identity), userRoot, identity.deviceId);
|
|
1384
|
+
if (repair.core || repair.emptyRoots.length > 0) {
|
|
1385
|
+
// A durable binding is registration evidence even if the native event
|
|
1386
|
+
// that created it was lost across a crash. Rebuild coverage first, then
|
|
1387
|
+
// express that uncertainty through Watch like every other Detect input.
|
|
1388
|
+
// These are ambiguous recovery facts, so each affected root is wholly
|
|
1389
|
+
// suspect; unrelated roots remain untouched.
|
|
1390
|
+
this.ensureWatchers(identity);
|
|
1391
|
+
if (repair.core)
|
|
1392
|
+
this.watchHost.suspect(userRoot);
|
|
1393
|
+
for (const root of repair.emptyRoots)
|
|
1394
|
+
this.watchHost.suspect(root);
|
|
1395
|
+
}
|
|
1396
|
+
// Detect is per root. A root without pending suspicion has nothing to
|
|
1397
|
+
// prove and never enters a pass: it costs no walk, no notebook plan, no
|
|
1398
|
+
// repository inspection, and no ground coordination. Only rung roots are
|
|
1399
|
+
// observed, and each rung root is proved on its own lane before any root
|
|
1400
|
+
// widens to reconciliation. Rings after this snapshot own the next pass.
|
|
1401
|
+
const observations = this.watchHost.observations();
|
|
1402
|
+
const pending = observations.filter(({ suspicion }) => suspicion.paths === null || suspicion.paths.length > 0);
|
|
1403
|
+
// An idle root's generation is already proved; settling it keeps every
|
|
1404
|
+
// root's settled generation current without any filesystem work.
|
|
1405
|
+
this.watchHost.settle(observations.filter((observation) => !pending.includes(observation)));
|
|
1406
|
+
if (pending.length === 0) {
|
|
1407
|
+
this.stage({
|
|
1408
|
+
primitive: "detect",
|
|
1409
|
+
stage: "pass",
|
|
1410
|
+
status: "completed",
|
|
1411
|
+
durationMs: 0,
|
|
1412
|
+
measurements: { lane: "idle", roots: 0, records: 0 },
|
|
1413
|
+
});
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
const cones = new Set(pending.map((observation) => observation.directory));
|
|
1417
|
+
const coreScan = cones.has(userRoot);
|
|
1359
1418
|
const waitStarted = performance.now();
|
|
1360
|
-
this.stage({
|
|
1361
|
-
|
|
1419
|
+
this.stage({
|
|
1420
|
+
primitive: "detect",
|
|
1421
|
+
stage: "ground-wait",
|
|
1422
|
+
status: "started",
|
|
1423
|
+
measurements: { roots: pending.length },
|
|
1424
|
+
});
|
|
1425
|
+
const release = await this.acquireGroundScope([...cones]);
|
|
1362
1426
|
this.stage({
|
|
1363
1427
|
primitive: "detect",
|
|
1364
1428
|
stage: "ground-wait",
|
|
@@ -1366,39 +1430,50 @@ export class UserGroundHost {
|
|
|
1366
1430
|
durationMs: performance.now() - waitStarted,
|
|
1367
1431
|
});
|
|
1368
1432
|
try {
|
|
1369
|
-
await this.syncLocalChangesUnlocked(identity);
|
|
1433
|
+
await this.syncLocalChangesUnlocked(identity, pending, coreScan);
|
|
1370
1434
|
}
|
|
1371
1435
|
finally {
|
|
1372
1436
|
release();
|
|
1373
1437
|
}
|
|
1374
1438
|
}
|
|
1375
|
-
async syncLocalChangesUnlocked(identity) {
|
|
1439
|
+
async syncLocalChangesUnlocked(identity, pending, coreScan) {
|
|
1376
1440
|
if (!this.cloudState)
|
|
1377
1441
|
throw new Error("cloud state is unavailable for user-ground Watch");
|
|
1378
|
-
const observations = this.watchHost.observations();
|
|
1379
1442
|
const passStarted = performance.now();
|
|
1380
1443
|
this.stage({
|
|
1381
1444
|
primitive: "detect",
|
|
1382
1445
|
stage: "pass",
|
|
1383
1446
|
status: "started",
|
|
1384
1447
|
measurements: {
|
|
1385
|
-
roots:
|
|
1386
|
-
completeRoots:
|
|
1387
|
-
namedPaths:
|
|
1448
|
+
roots: pending.length,
|
|
1449
|
+
completeRoots: pending.filter(({ suspicion }) => suspicion.paths === null).length,
|
|
1450
|
+
namedPaths: pending.reduce((count, { suspicion }) => count + (suspicion.paths?.length ?? 0), 0),
|
|
1388
1451
|
},
|
|
1389
1452
|
});
|
|
1390
|
-
|
|
1391
|
-
|
|
1453
|
+
const reconcileSuspicions = new Map();
|
|
1454
|
+
const reconcileObservations = [];
|
|
1455
|
+
let namedRoots = 0;
|
|
1456
|
+
let namedRecords = 0;
|
|
1457
|
+
let unsettled = false;
|
|
1458
|
+
for (const observation of pending) {
|
|
1459
|
+
if (observation.suspicion.paths === null || !this.namedDetect) {
|
|
1460
|
+
reconcileSuspicions.set(observation.directory, observation.suspicion);
|
|
1461
|
+
reconcileObservations.push(observation);
|
|
1462
|
+
continue;
|
|
1463
|
+
}
|
|
1392
1464
|
// 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(
|
|
1465
|
+
// Any proof failure occurs before its SQLite transaction and widens
|
|
1466
|
+
// only this root to reconciliation; it must never poison a Watch
|
|
1467
|
+
// generation in a timer rejection that only another event can revive.
|
|
1468
|
+
const named = await this.namedDetect.detect([observation]).catch(() => null);
|
|
1469
|
+
if (named?.unsettled) {
|
|
1470
|
+
unsettled = true;
|
|
1471
|
+
continue;
|
|
1472
|
+
}
|
|
1397
1473
|
if (named?.handled) {
|
|
1398
1474
|
for (const evidence of named.evidence) {
|
|
1399
|
-
const observation = observations.find((candidate) => candidate.directory === evidence.directory);
|
|
1400
1475
|
this.reportDetectScan({
|
|
1401
|
-
rootId: observation
|
|
1476
|
+
rootId: observation.rootId,
|
|
1402
1477
|
directory: evidence.directory,
|
|
1403
1478
|
scope: "paths",
|
|
1404
1479
|
entries: evidence.metadataReads,
|
|
@@ -1411,24 +1486,34 @@ export class UserGroundHost {
|
|
|
1411
1486
|
records: evidence.records,
|
|
1412
1487
|
recordBytes: evidence.recordBytes,
|
|
1413
1488
|
});
|
|
1489
|
+
namedRecords += evidence.records;
|
|
1414
1490
|
}
|
|
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;
|
|
1491
|
+
namedRoots += 1;
|
|
1492
|
+
// This root's truth is durable; its generation settles now so a later
|
|
1493
|
+
// failure in another root's reconciliation cannot make it re-prove.
|
|
1494
|
+
this.watchHost.settle([observation]);
|
|
1495
|
+
continue;
|
|
1430
1496
|
}
|
|
1431
|
-
|
|
1497
|
+
const widened = named?.widenedRoots?.includes(observation.directory) === true;
|
|
1498
|
+
reconcileSuspicions.set(observation.directory, widened ? { ...observation.suspicion, paths: null } : observation.suspicion);
|
|
1499
|
+
reconcileObservations.push(observation);
|
|
1500
|
+
}
|
|
1501
|
+
if (namedRoots > 0) {
|
|
1502
|
+
this.applyRail?.localStateChanged();
|
|
1503
|
+
this.recordRail?.localRecordsAvailable();
|
|
1504
|
+
}
|
|
1505
|
+
if (reconcileObservations.length === 0) {
|
|
1506
|
+
this.stage({
|
|
1507
|
+
primitive: "detect",
|
|
1508
|
+
stage: "pass",
|
|
1509
|
+
status: "completed",
|
|
1510
|
+
durationMs: performance.now() - passStarted,
|
|
1511
|
+
measurements: { lane: "named", roots: namedRoots, records: namedRecords },
|
|
1512
|
+
});
|
|
1513
|
+
if (unsettled) {
|
|
1514
|
+
throw pipelineStageError("detection", new Error("repository control operation is unsettled"));
|
|
1515
|
+
}
|
|
1516
|
+
return;
|
|
1432
1517
|
}
|
|
1433
1518
|
let detection;
|
|
1434
1519
|
let acceptedMaterializedRows;
|
|
@@ -1442,9 +1527,8 @@ export class UserGroundHost {
|
|
|
1442
1527
|
database: this.databasePath(identity),
|
|
1443
1528
|
cacheDir: this.cacheDir(identity),
|
|
1444
1529
|
onScan: (evidence) => this.reportDetectScan(evidence),
|
|
1445
|
-
suspicions:
|
|
1446
|
-
|
|
1447
|
-
: observation.suspicion])),
|
|
1530
|
+
suspicions: reconcileSuspicions,
|
|
1531
|
+
coreScan,
|
|
1448
1532
|
});
|
|
1449
1533
|
detection = scanned.detection;
|
|
1450
1534
|
acceptedMaterializedRows = scanned.acceptedMaterializedRows;
|
|
@@ -1501,7 +1585,7 @@ export class UserGroundHost {
|
|
|
1501
1585
|
}
|
|
1502
1586
|
}
|
|
1503
1587
|
this.namedDetect?.refreshEnrollmentPolicy();
|
|
1504
|
-
this.watchHost.settle(
|
|
1588
|
+
this.watchHost.settle(reconcileObservations);
|
|
1505
1589
|
this.applyRail?.localStateChanged();
|
|
1506
1590
|
this.recordRail?.localRecordsAvailable();
|
|
1507
1591
|
this.stage({
|
|
@@ -1509,8 +1593,16 @@ export class UserGroundHost {
|
|
|
1509
1593
|
stage: "pass",
|
|
1510
1594
|
status: "completed",
|
|
1511
1595
|
durationMs: performance.now() - passStarted,
|
|
1512
|
-
measurements: {
|
|
1596
|
+
measurements: {
|
|
1597
|
+
lane: "reconciliation",
|
|
1598
|
+
roots: reconcileObservations.length,
|
|
1599
|
+
namedRoots,
|
|
1600
|
+
records: detectedRecords.length + namedRecords,
|
|
1601
|
+
},
|
|
1513
1602
|
});
|
|
1603
|
+
if (unsettled) {
|
|
1604
|
+
throw pipelineStageError("detection", new Error("repository control operation is unsettled"));
|
|
1605
|
+
}
|
|
1514
1606
|
}
|
|
1515
1607
|
async publishPendingSnapshotsBeforeLookup(identity, resourceId) {
|
|
1516
1608
|
const pending = readSnapshotPublications(this.databasePath(identity));
|
|
@@ -2015,6 +2107,41 @@ function countDescendantRows(file, rootUUID) {
|
|
|
2015
2107
|
database.close();
|
|
2016
2108
|
}
|
|
2017
2109
|
}
|
|
2110
|
+
/** Recovery facts created before Watch could prove registration: a binding
|
|
2111
|
+
* whose portable reference is absent from the notebook, or a bound root that
|
|
2112
|
+
* has never produced a local entity row. They pull only those exact roots
|
|
2113
|
+
* into the next Detect pass. */
|
|
2114
|
+
function registrationRepairGround(file, userRoot, deviceId) {
|
|
2115
|
+
const bindings = materializedWorkspaceBindings(workspaceBindingDir(userRoot, deviceId));
|
|
2116
|
+
if (bindings.length === 0)
|
|
2117
|
+
return { core: false, emptyRoots: [] };
|
|
2118
|
+
if (!existsSync(file)) {
|
|
2119
|
+
return { core: true, emptyRoots: bindings.map(({ directory }) => directory) };
|
|
2120
|
+
}
|
|
2121
|
+
const core = readGroundRootAtPath(file, "detection_notebook", userRoot);
|
|
2122
|
+
if (!core) {
|
|
2123
|
+
return { core: true, emptyRoots: bindings.map(({ directory }) => directory) };
|
|
2124
|
+
}
|
|
2125
|
+
const database = initializeDatabase(file);
|
|
2126
|
+
try {
|
|
2127
|
+
const referenced = new Set(database.prepare(`
|
|
2128
|
+
SELECT payload_version AS payloadVersion FROM detection_notebook
|
|
2129
|
+
WHERE root_uuid = ? AND type = 'reference' AND status = 'active'
|
|
2130
|
+
AND payload_version IS NOT NULL
|
|
2131
|
+
`).all(core.uuid).map((row) => row.payloadVersion));
|
|
2132
|
+
const hasRows = database.prepare(`
|
|
2133
|
+
SELECT 1 AS present FROM entities WHERE root_uuid = ? LIMIT 1
|
|
2134
|
+
`);
|
|
2135
|
+
return {
|
|
2136
|
+
core: bindings.some(({ workspaceId }) => !referenced.has(workspaceId)),
|
|
2137
|
+
emptyRoots: bindings.filter(({ workspaceId }) => !hasRows.get(workspaceId))
|
|
2138
|
+
.map(({ directory }) => directory),
|
|
2139
|
+
};
|
|
2140
|
+
}
|
|
2141
|
+
finally {
|
|
2142
|
+
database.close();
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2018
2145
|
function readGroundUUIDs(file, table, rootUUIDs) {
|
|
2019
2146
|
if (!existsSync(file))
|
|
2020
2147
|
return new Set();
|
|
@@ -2036,6 +2163,20 @@ function readGroundUUIDs(file, table, rootUUIDs) {
|
|
|
2036
2163
|
database.close();
|
|
2037
2164
|
}
|
|
2038
2165
|
}
|
|
2166
|
+
/** The ground an Add materialization touches: its destination, its sibling
|
|
2167
|
+
* staging directory, and core ground where the binding and reference live.
|
|
2168
|
+
* An unresolvable parent still names itself; the install reports the error. */
|
|
2169
|
+
function workspaceAddCones(destinationParent, workspace, userRoot) {
|
|
2170
|
+
let parent = resolve(destinationParent);
|
|
2171
|
+
try {
|
|
2172
|
+
parent = realpathSync(parent);
|
|
2173
|
+
}
|
|
2174
|
+
catch {
|
|
2175
|
+
// Reported by installCloudWorkspace with its exact error.
|
|
2176
|
+
}
|
|
2177
|
+
const destination = join(parent, workspace.name);
|
|
2178
|
+
return [destination, workspaceAddStagingPath(destination, workspace.uuid), userRoot];
|
|
2179
|
+
}
|
|
2039
2180
|
function workspaceAddStagingPath(destinationPath, workspaceId) {
|
|
2040
2181
|
return join(dirname(destinationPath), `.amalgm-${workspaceId}.adding`);
|
|
2041
2182
|
}
|
|
@@ -2569,6 +2710,11 @@ function createDeclaredHome(input) {
|
|
|
2569
2710
|
}
|
|
2570
2711
|
}
|
|
2571
2712
|
}
|
|
2713
|
+
/** One macrotask turn: pending I/O, timers, and wire frames run before the
|
|
2714
|
+
* caller resumes. */
|
|
2715
|
+
function yieldEventLoop() {
|
|
2716
|
+
return new Promise((resolve) => setImmediate(resolve));
|
|
2717
|
+
}
|
|
2572
2718
|
function slashPath(path) {
|
|
2573
2719
|
return path.split(sep).join("/");
|
|
2574
2720
|
}
|
|
@@ -2629,7 +2775,7 @@ function materializedWorkspaceBindings(bindingDir) {
|
|
|
2629
2775
|
return bindings.sort((left, right) => left.workspaceId.localeCompare(right.workspaceId));
|
|
2630
2776
|
}
|
|
2631
2777
|
async function reconcileGround(input) {
|
|
2632
|
-
const { identity, userRoot, database, cacheDir, suspicions, registrationWorkspaceId, onScan, } = input;
|
|
2778
|
+
const { identity, userRoot, database, cacheDir, suspicions, coreScan, registrationWorkspaceId, onScan, } = input;
|
|
2633
2779
|
const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
|
|
2634
2780
|
const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
|
|
2635
2781
|
const boundRootEntries = materializedWorkspaceBindings(bindingDir);
|
|
@@ -2759,30 +2905,40 @@ async function reconcileGround(input) {
|
|
|
2759
2905
|
});
|
|
2760
2906
|
// Each root commits its local projection before Detect yields. Native
|
|
2761
2907
|
// callbacks can therefore preserve new suspicion between large roots.
|
|
2762
|
-
await
|
|
2908
|
+
await yieldEventLoop();
|
|
2763
2909
|
return result;
|
|
2764
2910
|
};
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
:
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2911
|
+
// A Watch pass names exactly the roots it observed. A root outside that
|
|
2912
|
+
// observation has nothing to prove and is not scanned, planned, or
|
|
2913
|
+
// inspected; its rows stay as they are. Only two facts can still pull an
|
|
2914
|
+
// unobserved root in: core ground missing a portable reference for a
|
|
2915
|
+
// binding, and a bound root that has never produced a row.
|
|
2916
|
+
const observed = (root) => !suspicions || suspicions.has(root);
|
|
2917
|
+
if (observed(userRoot) || (missingPortableReference && coreScan !== false)) {
|
|
2918
|
+
await scan({
|
|
2919
|
+
identity,
|
|
2920
|
+
resourceId,
|
|
2921
|
+
rootUUID: coreUUID,
|
|
2922
|
+
rootName: identity.userEmail,
|
|
2923
|
+
rootPath: userRoot,
|
|
2924
|
+
rootType: "workspace",
|
|
2925
|
+
database,
|
|
2926
|
+
cacheDir,
|
|
2927
|
+
policy,
|
|
2928
|
+
bindingDir,
|
|
2929
|
+
suspects: missingPortableReference && coreSuspicion !== null
|
|
2930
|
+
? [...new Set([...coreSuspicion, "workspaces"])]
|
|
2931
|
+
: coreSuspicion,
|
|
2932
|
+
existingRows: existing.filter((row) => row.rootUUID === coreUUID),
|
|
2933
|
+
suspicion: observationFor(userRoot),
|
|
2934
|
+
});
|
|
2935
|
+
}
|
|
2782
2936
|
const physicalRoots = registrationOwner ? [registrationOwner] : outerBoundRoots;
|
|
2783
2937
|
for (const { workspaceId, directory: rootPath } of physicalRoots) {
|
|
2784
2938
|
const existingRoot = existingByUuid.get(workspaceId);
|
|
2785
2939
|
const existingWorkspaceRows = existingByOutermostRoot.get(workspaceId) || [];
|
|
2940
|
+
if (!observed(rootPath) && existingWorkspaceRows.length > 0)
|
|
2941
|
+
continue;
|
|
2786
2942
|
const registeredPath = registrationBoundary
|
|
2787
2943
|
&& registrationOwner?.workspaceId === workspaceId
|
|
2788
2944
|
&& registrationBoundary.workspaceId !== workspaceId
|
|
@@ -2823,14 +2979,21 @@ async function reconcileGround(input) {
|
|
|
2823
2979
|
}
|
|
2824
2980
|
async function reconcileRoot(input) {
|
|
2825
2981
|
const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, evidence, } = input;
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2982
|
+
// Prior rows reach this scan from every binding this root encloses. A row's
|
|
2983
|
+
// stored relativePath is relative to the root that last committed it, which
|
|
2984
|
+
// is a different frame when an enclosing boundary was just registered
|
|
2985
|
+
// (child/README.md is stored as "README.md" under the child root). Address
|
|
2986
|
+
// every prior row in this scan's frame before anything compares it with an
|
|
2987
|
+
// observed entry; absolutePath is the only coordinate both frames share.
|
|
2988
|
+
const existingRows = (input.existingRows
|
|
2989
|
+
?? readRows(database).filter((row) => row.resourceId === resourceId && row.rootUUID === rootUUID))
|
|
2990
|
+
.flatMap((row) => {
|
|
2829
2991
|
if (!pathWithin(rootPath, row.absolutePath))
|
|
2830
2992
|
return [];
|
|
2831
|
-
const
|
|
2832
|
-
return [
|
|
2833
|
-
})
|
|
2993
|
+
const relativePath = slashPath(relative(rootPath, row.absolutePath));
|
|
2994
|
+
return [relativePath === row.relativePath ? row : { ...row, relativePath }];
|
|
2995
|
+
});
|
|
2996
|
+
const existingByPath = new Map(existingRows.map((row) => [row.relativePath, row]));
|
|
2834
2997
|
const registeredBoundaries = input.registeredBoundaries ?? new Map();
|
|
2835
2998
|
if (suspects !== null
|
|
2836
2999
|
&& suspects.length === 0
|
|
@@ -2970,6 +3133,7 @@ async function reconcileRoot(input) {
|
|
|
2970
3133
|
}
|
|
2971
3134
|
return [...new Set(paths)].sort();
|
|
2972
3135
|
};
|
|
3136
|
+
let visitedEntries = 0;
|
|
2973
3137
|
const visit = async (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
|
|
2974
3138
|
? {
|
|
2975
3139
|
root: directory,
|
|
@@ -2996,6 +3160,11 @@ async function reconcileRoot(input) {
|
|
|
2996
3160
|
const absolutePath = join(directory, child.name);
|
|
2997
3161
|
const stats = lstatSync(absolutePath);
|
|
2998
3162
|
evidence && (evidence.entries += 1);
|
|
3163
|
+
// The walk is synchronous metadata work. Yield periodically so the
|
|
3164
|
+
// wire, Receive, and Apply keep running while a large root is proved.
|
|
3165
|
+
// A Watch ring during the yield remains a later generation.
|
|
3166
|
+
if (++visitedEntries % DETECT_WALK_YIELD_ENTRIES === 0)
|
|
3167
|
+
await yieldEventLoop();
|
|
2999
3168
|
const existing = existingByPath.get(relativePath);
|
|
3000
3169
|
const registeredBoundaryId = stats.isDirectory()
|
|
3001
3170
|
? registeredBoundaries.get(resolve(absolutePath))
|
|
@@ -3994,6 +4163,19 @@ async function installCloudWorkspace(input) {
|
|
|
3994
4163
|
alreadyMaterialized: pending === null,
|
|
3995
4164
|
};
|
|
3996
4165
|
}
|
|
4166
|
+
// One machine materializes each UUID in exactly one place. The selected
|
|
4167
|
+
// closure may enclose a boundary this machine already added elsewhere; a
|
|
4168
|
+
// fresh install would re-root those rows under the new destination while
|
|
4169
|
+
// the old binding stayed bound and watched over ground with no rows, and
|
|
4170
|
+
// the next Detect pass there would mint a second identity for every file.
|
|
4171
|
+
const selectedUUIDs = new Set(records.map((record) => record.uuid));
|
|
4172
|
+
const enclosedElsewhere = allLocalRows.filter((row) => row.uuid !== workspace.uuid && selectedUUIDs.has(row.uuid));
|
|
4173
|
+
if (enclosedElsewhere.length > 0) {
|
|
4174
|
+
const roots = [...new Set(enclosedElsewhere.map((row) => row.rootUUID))]
|
|
4175
|
+
.map((rootUUID) => allLocalRows.find((row) => row.uuid === rootUUID)?.absolutePath ?? rootUUID);
|
|
4176
|
+
throw new Error(`files add: workspace ${workspace.uuid} encloses ${enclosedElsewhere.length} entities already `
|
|
4177
|
+
+ `materialized on this machine under ${roots.join(", ")}; remove that materialization first`);
|
|
4178
|
+
}
|
|
3997
4179
|
const parent = realpathSync(resolve(destinationParent));
|
|
3998
4180
|
if (!statSync(parent).isDirectory())
|
|
3999
4181
|
throw new Error("destination must be a directory");
|