@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.
@@ -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
- groundEffectTail = Promise.resolve();
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.acquireGroundEffect();
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.acquireGroundEffect();
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
- releaseGround = await this.acquireGroundEffect();
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 whole-root suspicion is
683
- // the synchronous proof boundary used only when Apply observed a
684
- // save racing its reveal and must make that save durable first.
685
- this.watchHost.suspectAll();
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
- await this.syncNow();
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.acquireGroundEffect(APPLY_GROUND_WAIT_TIMEOUT_MS),
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
- // Path cones and repository territories are wider than one UUID. Until
721
- // the host exposes those locks, one serial machine-effect owner is the
722
- // only honest concurrency setting.
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
- async acquireGroundEffect(timeoutMs) {
1315
- let release;
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({ primitive: "detect", stage: "ground-wait", status: "started" });
1361
- const release = await this.acquireGroundEffect();
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: observations.length,
1386
- completeRoots: observations.filter(({ suspicion }) => suspicion.paths === null).length,
1387
- namedPaths: observations.reduce((count, { suspicion }) => count + (suspicion.paths?.length ?? 0), 0),
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
- let widenedRoots = new Set();
1391
- if (this.namedDetect) {
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 to
1394
- // reconciliation; it must never poison a Watch generation in a timer
1395
- // rejection that only another filesystem event can revive.
1396
- const named = await this.namedDetect.detect(observations).catch(() => null);
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?.rootId ?? evidence.directory,
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
- this.watchHost.settle(observations);
1416
- this.applyRail?.localStateChanged();
1417
- this.recordRail?.localRecordsAvailable();
1418
- this.stage({
1419
- primitive: "detect",
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
- widenedRoots = new Set(named?.widenedRoots ?? []);
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: new Map(observations.map((observation) => [observation.directory, widenedRoots.has(observation.directory)
1446
- ? { ...observation.suspicion, paths: null }
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(observations);
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: { lane: "reconciliation", records: detectedRecords.length },
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 new Promise((resolve) => setImmediate(resolve));
2878
+ await yieldEventLoop();
2763
2879
  return result;
2764
2880
  };
2765
- await scan({
2766
- identity,
2767
- resourceId,
2768
- rootUUID: coreUUID,
2769
- rootName: identity.userEmail,
2770
- rootPath: userRoot,
2771
- rootType: "workspace",
2772
- database,
2773
- cacheDir,
2774
- policy,
2775
- bindingDir,
2776
- suspects: missingPortableReference && coreSuspicion !== null
2777
- ? [...new Set([...coreSuspicion, "workspaces"])]
2778
- : coreSuspicion,
2779
- existingRows: existing.filter((row) => row.rootUUID === coreUUID),
2780
- suspicion: observationFor(userRoot),
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))