@amalgm/shell 0.1.73 → 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.
@@ -6,8 +6,10 @@ import { buildUserHomeManifest, buildUserManifest, liveMachineStateDir, scopedAm
6
6
  import { CHUNK_BYTES, CONTENT_CONTRACT, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, createLocalEntityRecord, createEntityRecordAuthorityPort, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, indexRepositoryTerritory, download as downloadArtifact, encodeEntityRecord, EntityApplyRail, EntityRecordRail, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, rootReplacementFromRecords, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
7
7
  import Database from "better-sqlite3";
8
8
  import { atomicCopy, atomicWrite, ensurePrivateDir } from "./filesystem.js";
9
+ import { emitFilesStage, } from "./files-observability.js";
9
10
  import { EntityApplyHost, } from "./entity-apply-host.js";
10
11
  import { EntityRecordSqliteStore } from "./entity-record-store.js";
12
+ import { observeEntityRecordPorts } from "./entity-record-observability.js";
11
13
  import { indexRowsByOutermostRoot } from "./ground-root-index.js";
12
14
  import { ContentCacheDownload, captureContentFile, hashContentFile, sealCapturedArtifact, } from "./content-cache-host.js";
13
15
  import { decodeContentWireBytes, encodeContentWireBytes } from "./content-wire-codec.js";
@@ -19,6 +21,7 @@ import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile
19
21
  import { inspectGitRegistration, sameGitIdentity, } from "./git-registration-host.js";
20
22
  import { projectMaterializedGraph } from "./materialized-graph.js";
21
23
  import { NodeWatchHost, } from "./watching/index.js";
24
+ import { GroundCoordinator } from "./ground-coordination.js";
22
25
  import { WireClient, WireRequestError } from "./wire-client.js";
23
26
  const SMALL_CONTENT_UPLOAD_CONCURRENCY = 16;
24
27
  const FILE_CONTENT_DOWNLOAD_CONCURRENCY = 4;
@@ -26,6 +29,9 @@ const FILE_BATCH_DOWNLOAD_CONCURRENCY = 2;
26
29
  const DETECT_QUIET_MS = 12;
27
30
  const DETECT_MAX_DEFERRAL_MS = 75;
28
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;
29
35
  const DOWNLOAD_CONTENT_RETRY_ATTEMPTS = 3;
30
36
  const APPLY_GROUND_WAIT_TIMEOUT_MS = 15_000;
31
37
  const GROUND_ROW_COLUMNS = [
@@ -54,7 +60,10 @@ export class UserGroundHost {
54
60
  namedDetect = null;
55
61
  coldOperation = false;
56
62
  closing = false;
57
- 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();
58
67
  port;
59
68
  constructor(options) {
60
69
  this.options = options;
@@ -67,7 +76,29 @@ export class UserGroundHost {
67
76
  this.watchHost = new NodeWatchHost({
68
77
  open: options.openWatch,
69
78
  onSignal: () => this.scheduleRescan(),
70
- onHealth: options.onWatchHealth,
79
+ onEvent: (evidence) => this.stage({
80
+ primitive: "watch",
81
+ stage: "native-suspicion",
82
+ status: "observed",
83
+ path: evidence.path === null
84
+ ? evidence.directory
85
+ : join(evidence.directory, ...evidence.path.split("/")),
86
+ measurements: { kind: evidence.kind, named: evidence.path !== null },
87
+ }),
88
+ onHealth: (health) => {
89
+ options.onWatchHealth?.(health);
90
+ this.stage({
91
+ primitive: "watch",
92
+ stage: "coverage-health",
93
+ status: "observed",
94
+ measurements: {
95
+ state: health.state,
96
+ logicalRoots: health.logicalRoots,
97
+ contentHandles: health.contentHandles,
98
+ addressHandles: health.addressHandles,
99
+ },
100
+ });
101
+ },
71
102
  });
72
103
  this.port = {
73
104
  converge: async (identity) => {
@@ -80,7 +111,29 @@ export class UserGroundHost {
80
111
  local,
81
112
  });
82
113
  await this.resumeWorkspaceAdds(this.activeIdentity);
83
- this.namedDetect ??= new NamedDetectRuntime(this.databasePath(this.activeIdentity), this.cacheDir(this.activeIdentity), this.userRoot(this.activeIdentity));
114
+ this.namedDetect ??= new NamedDetectRuntime(this.databasePath(this.activeIdentity), this.cacheDir(this.activeIdentity), this.userRoot(this.activeIdentity), ({ durationMs, records }) => {
115
+ if (records.length === 0) {
116
+ this.stage({
117
+ primitive: "record",
118
+ stage: "sqlite-transaction",
119
+ status: "completed",
120
+ durationMs,
121
+ measurements: { records: 0 },
122
+ });
123
+ return;
124
+ }
125
+ for (const record of records) {
126
+ this.stage({
127
+ primitive: "record",
128
+ stage: "outbox-committed",
129
+ status: "completed",
130
+ entityId: record.entityId,
131
+ mutationId: record.mutationId,
132
+ durationMs,
133
+ measurements: { records: records.length },
134
+ });
135
+ }
136
+ });
84
137
  const watchHealth = this.watchHost.health();
85
138
  if (watchHealth.state !== "healthy") {
86
139
  throw new Error(`Watch coverage is degraded: ${watchHealth.reason ?? "unknown failure"}`);
@@ -103,6 +156,30 @@ export class UserGroundHost {
103
156
  },
104
157
  };
105
158
  }
159
+ stage(input) {
160
+ emitFilesStage(this.options.onFilesStage, input);
161
+ }
162
+ reportDetectScan(evidence) {
163
+ this.options.onDetectScan?.(evidence);
164
+ this.stage({
165
+ primitive: "detect",
166
+ stage: "scan",
167
+ status: "completed",
168
+ rootId: evidence.rootId,
169
+ path: evidence.directory,
170
+ durationMs: evidence.durationMs,
171
+ measurements: {
172
+ scope: evidence.scope,
173
+ entries: evidence.entries,
174
+ metadataReused: evidence.metadataReused,
175
+ contentReads: evidence.contentReads,
176
+ gitInspections: evidence.gitInspections,
177
+ repositoryCaptures: evidence.repositoryCaptures,
178
+ records: evidence.records ?? 0,
179
+ recordBytes: evidence.recordBytes ?? 0,
180
+ },
181
+ });
182
+ }
106
183
  databasePath(identity) {
107
184
  return join(liveMachineStateDir(this.userRoot(identity), identity.deviceId), "identity.db");
108
185
  }
@@ -151,14 +228,39 @@ export class UserGroundHost {
151
228
  });
152
229
  const add = {
153
230
  lookupRegistry: async () => {
231
+ const started = performance.now();
232
+ this.stage({
233
+ primitive: "download", stage: "registry-lookup", status: "started",
234
+ });
154
235
  const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
155
236
  const cloud = await this.cloudPort(identity).lookup(resourceId);
156
237
  if (!cloud)
157
238
  throw new Error("this user has no cloud entity registry");
239
+ this.stage({
240
+ primitive: "download",
241
+ stage: "registry-lookup",
242
+ status: "completed",
243
+ durationMs: performance.now() - started,
244
+ });
158
245
  return cloud;
159
246
  },
160
247
  materializeWorkspace: async (input) => {
161
- const release = await this.acquireGroundEffect();
248
+ const groundWaitStarted = performance.now();
249
+ this.stage({
250
+ primitive: "download",
251
+ stage: "ground-wait",
252
+ status: "started",
253
+ workspaceId: input.workspace.uuid,
254
+ });
255
+ const release = await this.acquireGroundScope(workspaceAddCones(input.destinationParent, input.workspace, userRoot));
256
+ this.stage({
257
+ primitive: "download",
258
+ stage: "ground-wait",
259
+ status: "completed",
260
+ workspaceId: input.workspace.uuid,
261
+ durationMs: performance.now() - groundWaitStarted,
262
+ });
263
+ let milestoneAt = performance.now();
162
264
  try {
163
265
  const installed = await installCloudWorkspace({
164
266
  identity,
@@ -173,11 +275,22 @@ export class UserGroundHost {
173
275
  records: input.records,
174
276
  destinationParent: input.destinationParent,
175
277
  cloudHead: input.cloudHead,
176
- readContent: (artifact) => this.downloadContent(privateEntityResourceId(identity.userId, sha256Hex), this.cacheDir(identity), artifact),
177
- onStage: (stage) => this.options.onWorkspaceAddStage?.({
178
- workspaceId: input.workspace.uuid,
179
- stage,
180
- }),
278
+ readContent: (artifact) => this.downloadContent(privateEntityResourceId(identity.userId, sha256Hex), this.cacheDir(identity), artifact, 10, { journey: "add", workspaceId: input.workspace.uuid }),
279
+ onStage: async (stage) => {
280
+ const now = performance.now();
281
+ this.stage({
282
+ primitive: stage === "watching" ? "watch" : "download",
283
+ stage,
284
+ status: "completed",
285
+ workspaceId: input.workspace.uuid,
286
+ durationMs: now - milestoneAt,
287
+ });
288
+ milestoneAt = now;
289
+ await this.options.onWorkspaceAddStage?.({
290
+ workspaceId: input.workspace.uuid,
291
+ stage,
292
+ });
293
+ },
181
294
  });
182
295
  // Add changes both disk and the exact SQLite projection. Open the
183
296
  // selected root's singular Watch coverage before Detect can observe
@@ -192,6 +305,10 @@ export class UserGroundHost {
192
305
  }
193
306
  },
194
307
  coverWorkspace: async ({ workspaceId, baseline }) => {
308
+ const started = performance.now();
309
+ this.stage({
310
+ primitive: "watch", stage: "add-coverage", status: "started", workspaceId,
311
+ });
195
312
  const pending = readWorkspaceAddIntent(this.databasePath(identity), workspaceId);
196
313
  const health = this.ensureWatchers(identity, pending || baseline === "authoritative-install" ? [workspaceId] : []);
197
314
  assertHealthyWatch(this.watchHost.evidence(), workspaceId);
@@ -199,6 +316,17 @@ export class UserGroundHost {
199
316
  await this.options.onWorkspaceAddStage?.({ workspaceId, stage: "watching" });
200
317
  deleteWorkspaceAddIntent(this.databasePath(identity), workspaceId);
201
318
  }
319
+ this.stage({
320
+ primitive: "watch",
321
+ stage: "add-coverage",
322
+ status: "completed",
323
+ workspaceId,
324
+ durationMs: performance.now() - started,
325
+ measurements: {
326
+ contentHandles: health.contentHandles,
327
+ addressHandles: health.addressHandles,
328
+ },
329
+ });
202
330
  return { active: true, roots: health.contentHandles };
203
331
  },
204
332
  };
@@ -211,7 +339,11 @@ export class UserGroundHost {
211
339
  const userRoot = this.userRoot(identity);
212
340
  const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
213
341
  for (const intent of readWorkspaceAddIntents(database)) {
214
- const release = await this.acquireGroundEffect();
342
+ const release = await this.acquireGroundScope([
343
+ intent.destinationPath,
344
+ intent.stagingPath,
345
+ userRoot,
346
+ ]);
215
347
  const visibleAcrossBlindInterval = pathExists(intent.destinationPath);
216
348
  try {
217
349
  const selection = workspaceAddSelection(intent);
@@ -236,7 +368,7 @@ export class UserGroundHost {
236
368
  records: intent.records,
237
369
  destinationParent: dirname(intent.destinationPath),
238
370
  cloudHead: intent.cloudHead,
239
- readContent: (artifact) => this.downloadContent(intent.resourceId, this.cacheDir(identity), artifact),
371
+ readContent: (artifact) => this.downloadContent(intent.resourceId, this.cacheDir(identity), artifact, 10, { journey: "add", workspaceId: intent.workspaceId }),
240
372
  onStage: (stage) => this.options.onWorkspaceAddStage?.({
241
373
  workspaceId: intent.workspaceId,
242
374
  stage,
@@ -277,9 +409,24 @@ export class UserGroundHost {
277
409
  this.rescanGenerationStartedAt = null;
278
410
  let watch;
279
411
  try {
412
+ const watchStarted = performance.now();
413
+ this.stage({
414
+ primitive: "watch", stage: "register-coverage", status: "started", workspaceId,
415
+ });
280
416
  this.ensureWatchers(identity);
281
417
  watch = this.watchHost.evidence();
282
418
  assertHealthyWatch(watch, workspaceId);
419
+ this.stage({
420
+ primitive: "watch",
421
+ stage: "register-coverage",
422
+ status: "completed",
423
+ workspaceId,
424
+ durationMs: performance.now() - watchStarted,
425
+ measurements: {
426
+ contentHandles: watch.health.contentHandles,
427
+ addressHandles: watch.health.addressHandles,
428
+ },
429
+ });
283
430
  }
284
431
  catch (error) {
285
432
  this.finishColdOperation();
@@ -305,22 +452,39 @@ export class UserGroundHost {
305
452
  // a parallel stream of Detect records for the same initial contents.
306
453
  let releaseGround = null;
307
454
  try {
308
- if (this.syncing)
309
- await this.syncing;
310
- releaseGround = await this.acquireGroundEffect();
455
+ const groundWaitStarted = performance.now();
456
+ this.stage({
457
+ primitive: "register", stage: "ground-wait", status: "started", workspaceId,
458
+ });
311
459
  const contentOwner = watch.roots.find((root) => root.rootId === workspaceId)?.contentOwner;
312
460
  if (!contentOwner) {
313
461
  throw new Error(`registered workspace ${workspaceId} has no content coverage owner`);
314
462
  }
463
+ if (this.syncing)
464
+ await this.syncing;
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)]);
468
+ this.stage({
469
+ primitive: "register",
470
+ stage: "ground-wait",
471
+ status: "completed",
472
+ workspaceId,
473
+ durationMs: performance.now() - groundWaitStarted,
474
+ });
315
475
  const observations = this.watchHost.observations().filter((observation) => observation.directory === this.userRoot(identity)
316
476
  || pathWithin(contentOwner, observation.directory));
317
477
  this.watchDirty = false;
478
+ const registrationStarted = performance.now();
479
+ this.stage({
480
+ primitive: "register", stage: "entity-registration", status: "started", workspaceId,
481
+ });
318
482
  const scanned = await reconcileGround({
319
483
  identity,
320
484
  userRoot: this.userRoot(identity),
321
485
  database: this.databasePath(identity),
322
486
  cacheDir: this.cacheDir(identity),
323
- onScan: this.options.onDetectScan,
487
+ onScan: (evidence) => this.reportDetectScan(evidence),
324
488
  registrationWorkspaceId: workspaceId,
325
489
  suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion])),
326
490
  });
@@ -328,17 +492,39 @@ export class UserGroundHost {
328
492
  if (retry?.kind === "retry") {
329
493
  throw new Error(retry.reasons.join("; "));
330
494
  }
495
+ this.stage({
496
+ primitive: "register",
497
+ stage: "entity-registration",
498
+ status: "completed",
499
+ workspaceId,
500
+ durationMs: performance.now() - registrationStarted,
501
+ measurements: {
502
+ roots: scanned.publicationRootIds?.length ?? 0,
503
+ rows: scanned.acceptedMaterializedRows.length,
504
+ },
505
+ });
506
+ const commitStarted = performance.now();
507
+ this.stage({
508
+ primitive: "register", stage: "entity-sqlite-commit", status: "started", workspaceId,
509
+ });
331
510
  commitDetectedState(this.databasePath(identity), {
332
511
  acceptedMaterializedRows: scanned.acceptedMaterializedRows,
333
512
  materializedRemovals: scanned.materializedRemovals,
334
513
  acceptedNotebookRows: scanned.acceptedNotebookRows,
335
514
  notebookRemovals: scanned.notebookRemovals,
336
515
  });
516
+ this.stage({
517
+ primitive: "register",
518
+ stage: "entity-sqlite-commit",
519
+ status: "completed",
520
+ workspaceId,
521
+ durationMs: performance.now() - commitStarted,
522
+ });
337
523
  this.namedDetect?.refreshEnrollmentPolicy();
338
524
  if (!scanned.publicationRootIds) {
339
525
  throw new Error("cold registration reconciliation did not name its publication roots");
340
526
  }
341
- await this.publishMaterializedSnapshot(identity, new Set(scanned.publicationRootIds));
527
+ await this.publishMaterializedSnapshot(identity, new Set(scanned.publicationRootIds), { journey: "register", workspaceId });
342
528
  this.watchHost.settle(observations);
343
529
  }
344
530
  catch (error) {
@@ -353,9 +539,21 @@ export class UserGroundHost {
353
539
  this.finishColdOperation();
354
540
  }
355
541
  try {
542
+ const finalWatchStarted = performance.now();
356
543
  this.ensureWatchers(identity);
357
544
  watch = this.watchHost.evidence();
358
545
  assertHealthyWatch(watch, workspaceId);
546
+ this.stage({
547
+ primitive: "watch",
548
+ stage: "register-final-proof",
549
+ status: "completed",
550
+ workspaceId,
551
+ durationMs: performance.now() - finalWatchStarted,
552
+ measurements: {
553
+ contentHandles: watch.health.contentHandles,
554
+ addressHandles: watch.health.addressHandles,
555
+ },
556
+ });
359
557
  }
360
558
  catch (error) {
361
559
  throw stageError("Watch coverage", error);
@@ -434,18 +632,28 @@ export class UserGroundHost {
434
632
  throw new Error("gateway does not support the event-driven entity Record wire");
435
633
  }
436
634
  const store = new EntityRecordSqliteStore(initializeDatabase(this.databasePath(identity)));
437
- const authority = createEntityRecordAuthorityPort({
635
+ const wireAuthority = createEntityRecordAuthorityPort({
438
636
  request: (frame, acceptedTypes) => this.wire.request({ ...frame }, acceptedTypes),
439
637
  onFrame: (listener) => this.wire.onEvent(listener),
440
638
  onDisconnect: (listener) => this.wire.onDisconnect(listener),
441
639
  });
442
- const rail = new EntityRecordRail({
443
- authorityId: this.cloudState.resourceId,
640
+ const ports = observeEntityRecordPorts({
444
641
  store,
445
- authority,
642
+ authority: wireAuthority,
446
643
  cargo: {
447
- ensure: (record) => this.uploadSnapshotContent(identity, record.authorityId, [record.change.result]),
644
+ ensure: (record) => this.uploadSnapshotContent(identity, record.authorityId, [record.change.result], {
645
+ journey: "send",
646
+ entityId: record.entityId,
647
+ mutationId: record.mutationId,
648
+ }),
448
649
  },
650
+ onStage: this.options.onFilesStage,
651
+ });
652
+ const rail = new EntityRecordRail({
653
+ authorityId: this.cloudState.resourceId,
654
+ store: ports.store,
655
+ authority: ports.authority,
656
+ cargo: ports.cargo,
449
657
  schedule: {
450
658
  schedule(delayMs, callback) {
451
659
  const timer = setTimeout(callback, delayMs);
@@ -482,20 +690,47 @@ export class UserGroundHost {
482
690
  userRoot: this.userRoot(identity),
483
691
  cacheDir: this.cacheDir(identity),
484
692
  bindingDir: workspaceBindingDir(this.userRoot(identity), identity.deviceId),
485
- readContent: (artifact) => this.downloadContent(this.cloudState.resourceId, this.cacheDir(identity), artifact, DOWNLOAD_CONTENT_RETRY_ATTEMPTS),
693
+ readContent: (artifact) => this.downloadContent(this.cloudState.resourceId, this.cacheDir(identity), artifact, DOWNLOAD_CONTENT_RETRY_ATTEMPTS, { journey: "apply" }),
486
694
  captureLocal: async (intent, plan, position) => {
487
- // Apply never suppresses Watch. This explicit whole-root suspicion is
488
- // the synchronous proof boundary used only when Apply observed a
489
- // save racing its reveal and must make that save durable first.
490
- 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);
491
711
  this.watchDirty = true;
492
- 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));
493
718
  if (!host.store.hasLocalWorkAfter(intent.authorityId, intent.entityId, intent.throughSequence)) {
494
719
  throw new Error(`Apply ${position} capture for ${plan.target.type} ${plan.target.name} (${intent.entityId}) produced no durable local record`);
495
720
  }
496
721
  },
497
- acquireGround: () => this.acquireGroundEffect(APPLY_GROUND_WAIT_TIMEOUT_MS),
498
- onStage: (evidence) => this.options.onApplyStage?.(evidence),
722
+ acquireGround: (cones) => this.acquireGroundScope(cones, APPLY_GROUND_WAIT_TIMEOUT_MS),
723
+ onStage: (evidence) => {
724
+ this.options.onApplyStage?.(evidence);
725
+ this.stage({
726
+ primitive: "apply",
727
+ stage: evidence.stage,
728
+ status: "observed",
729
+ entityId: evidence.entityId,
730
+ globalSequence: evidence.throughSequence,
731
+ measurements: { type: evidence.type, phase: evidence.phase },
732
+ });
733
+ },
499
734
  now: () => this.options.now?.().getTime() ?? Date.now(),
500
735
  });
501
736
  const rail = new EntityApplyRail({
@@ -512,9 +747,11 @@ export class UserGroundHost {
512
747
  sha256Hex,
513
748
  now: Date.now,
514
749
  onBackgroundError: (error) => this.options.onApplyError?.(error),
515
- // Path cones and repository territories are wider than one UUID. Until
516
- // the host exposes those locks, one serial machine-effect owner is the
517
- // 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.
518
755
  concurrency: 1,
519
756
  });
520
757
  this.applyHost = host;
@@ -672,7 +909,7 @@ export class UserGroundHost {
672
909
  });
673
910
  await reconcileGround({
674
911
  identity, userRoot, database, cacheDir,
675
- onScan: this.options.onDetectScan,
912
+ onScan: (evidence) => this.reportDetectScan(evidence),
676
913
  });
677
914
  return localValue(identity, userRoot, database);
678
915
  },
@@ -693,7 +930,7 @@ export class UserGroundHost {
693
930
  },
694
931
  };
695
932
  }
696
- async uploadSnapshotContent(identity, resourceId, records) {
933
+ async uploadSnapshotContent(identity, resourceId, records, trace) {
697
934
  const cache = this.cacheDir(identity);
698
935
  const byHash = new Map();
699
936
  for (const record of records) {
@@ -714,6 +951,27 @@ export class UserGroundHost {
714
951
  bytes: manifest?.bytes ?? statSync(file).size,
715
952
  };
716
953
  });
954
+ const started = performance.now();
955
+ let uploadedChunks = 0;
956
+ let reusedChunks = 0;
957
+ let uploadedBytes = 0;
958
+ let inventoryRequests = 0;
959
+ const identifiers = {
960
+ ...(trace?.workspaceId ? { workspaceId: trace.workspaceId } : {}),
961
+ ...(trace?.entityId ? { entityId: trace.entityId } : {}),
962
+ ...(trace?.mutationId ? { mutationId: trace.mutationId } : {}),
963
+ };
964
+ this.stage({
965
+ primitive: "upload",
966
+ stage: "immutable-content",
967
+ status: "started",
968
+ ...identifiers,
969
+ measurements: {
970
+ journey: trace?.journey ?? "bootstrap",
971
+ artifacts: sources.length,
972
+ declaredBytes: sources.reduce((total, source) => total + source.bytes, 0),
973
+ },
974
+ });
717
975
  const small = sources.filter((source) => source.bytes <= CHUNK_BYTES);
718
976
  const large = sources.filter((source) => source.bytes > CHUNK_BYTES);
719
977
  const transfer = async (source) => {
@@ -735,6 +993,7 @@ export class UserGroundHost {
735
993
  sha256: chunk.sha256,
736
994
  bytes: chunk.bytes,
737
995
  }, bytes, ["private.entity-content.stored"]));
996
+ uploadedBytes += bytes.byteLength;
738
997
  };
739
998
  try {
740
999
  const receipt = await uploadArtifact(artifact, {
@@ -752,6 +1011,7 @@ export class UserGroundHost {
752
1011
  }, {
753
1012
  sha256Hex,
754
1013
  missingChunks: async (_candidate, manifest) => {
1014
+ inventoryRequests += 1;
755
1015
  const frame = await retryContent(() => this.wire.request({
756
1016
  type: "private.entity-content.inventory",
757
1017
  resource_id: resourceId,
@@ -794,6 +1054,7 @@ export class UserGroundHost {
794
1054
  wire_bytes: encoded.bytes.byteLength,
795
1055
  ...(encoded.encoding ? { content_encoding: encoded.encoding } : {}),
796
1056
  }, encoded.bytes, ["private.entity-content.stored-batch"]));
1057
+ uploadedBytes += encoded.bytes.byteLength;
797
1058
  },
798
1059
  putManifest: async (_candidate, manifest) => {
799
1060
  if (manifestPresent)
@@ -810,8 +1071,11 @@ export class UserGroundHost {
810
1071
  sha256: sha256Hex(bytes),
811
1072
  bytes: bytes.length,
812
1073
  }, bytes, ["private.entity-content.stored"]));
1074
+ uploadedBytes += bytes.byteLength;
813
1075
  },
814
1076
  });
1077
+ uploadedChunks += receipt.uploadedChunks;
1078
+ reusedChunks += receipt.reusedChunks;
815
1079
  atomicWrite(manifestCacheFile(cache, artifact.contentHash), stableJson(receipt.manifest));
816
1080
  }
817
1081
  finally {
@@ -819,17 +1083,63 @@ export class UserGroundHost {
819
1083
  await (await sourceFile.handle).close();
820
1084
  }
821
1085
  };
822
- await boundedForEach(small, SMALL_CONTENT_UPLOAD_CONCURRENCY, transfer);
823
- for (const source of large) {
824
- await transfer(source);
1086
+ try {
1087
+ await boundedForEach(small, SMALL_CONTENT_UPLOAD_CONCURRENCY, transfer);
1088
+ for (const source of large) {
1089
+ await transfer(source);
1090
+ }
1091
+ this.stage({
1092
+ primitive: "upload",
1093
+ stage: "immutable-content",
1094
+ status: "completed",
1095
+ ...identifiers,
1096
+ durationMs: performance.now() - started,
1097
+ measurements: {
1098
+ journey: trace?.journey ?? "bootstrap",
1099
+ artifacts: sources.length,
1100
+ declaredBytes: sources.reduce((total, source) => total + source.bytes, 0),
1101
+ uploadedBytes,
1102
+ uploadedChunks,
1103
+ reusedChunks,
1104
+ inventoryRequests,
1105
+ },
1106
+ });
1107
+ }
1108
+ catch (error) {
1109
+ this.stage({
1110
+ primitive: "upload",
1111
+ stage: "immutable-content",
1112
+ status: "failed",
1113
+ ...identifiers,
1114
+ durationMs: performance.now() - started,
1115
+ measurements: { journey: trace?.journey ?? "bootstrap" },
1116
+ });
1117
+ throw error;
825
1118
  }
826
1119
  }
827
- async downloadContent(resourceId, cacheDir, artifact, retryAttempts = 10) {
1120
+ async downloadContent(resourceId, cacheDir, artifact, retryAttempts = 10, trace) {
1121
+ const started = performance.now();
1122
+ let downloadedBytes = 0;
1123
+ let requests = 0;
1124
+ const identifiers = {
1125
+ ...(trace?.workspaceId ? { workspaceId: trace.workspaceId } : {}),
1126
+ entityId: trace?.entityId ?? artifact.entityId,
1127
+ ...(trace?.mutationId ? { mutationId: trace.mutationId } : {}),
1128
+ ...(trace?.globalSequence ? { globalSequence: trace.globalSequence } : {}),
1129
+ };
1130
+ this.stage({
1131
+ primitive: "download",
1132
+ stage: "immutable-content",
1133
+ status: "started",
1134
+ ...identifiers,
1135
+ measurements: { journey: trace?.journey ?? "bootstrap", kind: artifact.kind },
1136
+ });
828
1137
  const cache = { target: null };
829
1138
  const getChunk = async (manifest, index) => {
830
1139
  const chunk = manifest.chunks[index];
831
1140
  if (!chunk)
832
1141
  throw new Error(`content manifest has no chunk ${index}`);
1142
+ requests += 1;
833
1143
  const frame = await retryContent(() => this.wire.request({
834
1144
  type: "private.entity-content.get",
835
1145
  resource_id: resourceId,
@@ -839,12 +1149,15 @@ export class UserGroundHost {
839
1149
  part_index: index,
840
1150
  sha256: chunk.sha256,
841
1151
  }, ["private.entity-content.data"]), true, retryAttempts);
842
- return binaryFrameBytes(frame);
1152
+ const bytes = binaryFrameBytes(frame);
1153
+ downloadedBytes += bytes.byteLength;
1154
+ return bytes;
843
1155
  };
844
1156
  try {
845
1157
  const receipt = await downloadArtifact(artifact, {
846
1158
  sha256Hex,
847
1159
  getManifest: async () => {
1160
+ requests += 1;
848
1161
  const frame = await retryContent(() => this.wire.request({
849
1162
  type: "private.entity-content.get",
850
1163
  resource_id: resourceId,
@@ -852,7 +1165,9 @@ export class UserGroundHost {
852
1165
  kind: "manifest",
853
1166
  part_index: 0,
854
1167
  }, ["private.entity-content.data"]), false, retryAttempts);
855
- const candidate = JSON.parse(verifiedFrameBytes(frame).toString("utf8"));
1168
+ const manifestBytes = verifiedFrameBytes(frame);
1169
+ downloadedBytes += manifestBytes.byteLength;
1170
+ const candidate = JSON.parse(manifestBytes.toString("utf8"));
856
1171
  const checked = checkContentManifest(candidate, artifact.contentHash);
857
1172
  if (!checked.ok)
858
1173
  throw new Error(checked.error);
@@ -883,7 +1198,10 @@ export class UserGroundHost {
883
1198
  bytes: manifest.chunks[index]?.bytes,
884
1199
  })),
885
1200
  }, ["private.entity-content.data-batch"]), true, retryAttempts);
886
- return decodeContentWireBytes(binaryWireFrameBytes(frame), frame.content_encoding, Number(frame.bytes));
1201
+ requests += 1;
1202
+ const decoded = await decodeContentWireBytes(binaryWireFrameBytes(frame), frame.content_encoding, Number(frame.bytes));
1203
+ downloadedBytes += Number(frame.wire_bytes ?? frame.bytes);
1204
+ return decoded;
887
1205
  },
888
1206
  putChunk: async (_candidate, _manifest, index, bytes) => {
889
1207
  if (!cache.target)
@@ -902,8 +1220,35 @@ export class UserGroundHost {
902
1220
  return { local: sealed, bytes: sealed.bytes, contentHash: sealed.contentHash };
903
1221
  },
904
1222
  }, artifact.kind === "file" ? { concurrency: FILE_BATCH_DOWNLOAD_CONCURRENCY } : {});
1223
+ this.stage({
1224
+ primitive: "download",
1225
+ stage: "immutable-content",
1226
+ status: "completed",
1227
+ ...identifiers,
1228
+ durationMs: performance.now() - started,
1229
+ measurements: {
1230
+ journey: trace?.journey ?? "bootstrap",
1231
+ kind: artifact.kind,
1232
+ declaredBytes: receipt.manifest.bytes,
1233
+ downloadedBytes,
1234
+ downloadedChunks: receipt.downloadedChunks,
1235
+ reusedChunks: receipt.reusedChunks,
1236
+ requests,
1237
+ },
1238
+ });
905
1239
  return receipt.local;
906
1240
  }
1241
+ catch (error) {
1242
+ this.stage({
1243
+ primitive: "download",
1244
+ stage: "immutable-content",
1245
+ status: "failed",
1246
+ ...identifiers,
1247
+ durationMs: performance.now() - started,
1248
+ measurements: { journey: trace?.journey ?? "bootstrap", kind: artifact.kind, requests },
1249
+ });
1250
+ throw error;
1251
+ }
907
1252
  finally {
908
1253
  await cache.target?.close();
909
1254
  }
@@ -911,6 +1256,7 @@ export class UserGroundHost {
911
1256
  ensureWatchers(identity, authoritativeBaselineRootIds = []) {
912
1257
  if (this.closing)
913
1258
  return this.watchHost.health();
1259
+ const started = performance.now();
914
1260
  const root = this.userRoot(identity);
915
1261
  const wanted = new Map([[root, {
916
1262
  rootId: `user:${identity.userId}`,
@@ -935,6 +1281,18 @@ export class UserGroundHost {
935
1281
  if (health.state !== "healthy" || health.contentHandles < 1) {
936
1282
  throw new Error(`Watch coverage is degraded: ${health.reason ?? "no content coverage"}`);
937
1283
  }
1284
+ this.stage({
1285
+ primitive: "watch",
1286
+ stage: "topology-reconcile",
1287
+ status: "completed",
1288
+ durationMs: performance.now() - started,
1289
+ measurements: {
1290
+ logicalRoots: health.logicalRoots,
1291
+ contentHandles: health.contentHandles,
1292
+ addressHandles: health.addressHandles,
1293
+ authoritativeBaselines: authoritativeBaselineRootIds.length,
1294
+ },
1295
+ });
938
1296
  return health;
939
1297
  }
940
1298
  scheduleRescan(retryDelayMs) {
@@ -985,75 +1343,107 @@ export class UserGroundHost {
985
1343
  this.scheduleRescan();
986
1344
  }
987
1345
  }
988
- async acquireGroundEffect(timeoutMs) {
989
- let release;
990
- const held = new Promise((resolve) => { release = resolve; });
991
- const preceding = this.groundEffectTail;
992
- this.groundEffectTail = preceding.then(() => held);
993
- let released = false;
994
- const releaseOnce = () => {
995
- if (released)
996
- return;
997
- released = true;
998
- release();
999
- };
1000
- if (timeoutMs === undefined) {
1001
- await preceding;
1002
- return releaseOnce;
1003
- }
1004
- if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
1005
- // This queued slot has not become active. Let it pass through as soon as
1006
- // the preceding owner finishes so invalid input cannot poison the tail.
1007
- void preceding.then(releaseOnce, releaseOnce);
1008
- throw new Error("ground-effect wait timeout must be a positive integer");
1009
- }
1010
- let timeout = null;
1011
- try {
1012
- await Promise.race([
1013
- preceding,
1014
- new Promise((_resolve, reject) => {
1015
- timeout = setTimeout(() => reject(new Error(`ground-effect wait exceeded ${timeoutMs}ms`)), timeoutMs);
1016
- }),
1017
- ]);
1018
- return releaseOnce;
1019
- }
1020
- catch (error) {
1021
- // The timed-out waiter still owns one FIFO slot. Automatically release
1022
- // that slot when it reaches the front; otherwise one timeout would block
1023
- // every later filesystem effect forever.
1024
- void preceding.then(releaseOnce, releaseOnce);
1025
- throw error;
1026
- }
1027
- finally {
1028
- if (timeout)
1029
- clearTimeout(timeout);
1030
- }
1346
+ acquireGroundScope(cones, timeoutMs) {
1347
+ return this.ground.acquire(cones, timeoutMs === undefined ? {} : { timeoutMs });
1031
1348
  }
1032
1349
  async syncLocalChanges(identity) {
1033
- const release = await this.acquireGroundEffect();
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);
1388
+ const waitStarted = performance.now();
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]);
1396
+ this.stage({
1397
+ primitive: "detect",
1398
+ stage: "ground-wait",
1399
+ status: "completed",
1400
+ durationMs: performance.now() - waitStarted,
1401
+ });
1034
1402
  try {
1035
- await this.syncLocalChangesUnlocked(identity);
1403
+ await this.syncLocalChangesUnlocked(identity, pending, coreScan);
1036
1404
  }
1037
1405
  finally {
1038
1406
  release();
1039
1407
  }
1040
1408
  }
1041
- async syncLocalChangesUnlocked(identity) {
1409
+ async syncLocalChangesUnlocked(identity, pending, coreScan) {
1042
1410
  if (!this.cloudState)
1043
1411
  throw new Error("cloud state is unavailable for user-ground Watch");
1044
- const observations = this.watchHost.observations();
1045
- let widenedRoots = new Set();
1046
- if (this.namedDetect) {
1412
+ const passStarted = performance.now();
1413
+ this.stage({
1414
+ primitive: "detect",
1415
+ stage: "pass",
1416
+ status: "started",
1417
+ measurements: {
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),
1421
+ },
1422
+ });
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
+ }
1047
1434
  // The named lane is an optimization over the same settled evidence.
1048
- // Any proof failure occurs before its SQLite transaction and widens to
1049
- // reconciliation; it must never poison a Watch generation in a timer
1050
- // rejection that only another filesystem event can revive.
1051
- 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
+ }
1052
1443
  if (named?.handled) {
1053
1444
  for (const evidence of named.evidence) {
1054
- const observation = observations.find((candidate) => candidate.directory === evidence.directory);
1055
- this.options.onDetectScan?.({
1056
- rootId: observation?.rootId ?? evidence.directory,
1445
+ this.reportDetectScan({
1446
+ rootId: observation.rootId,
1057
1447
  directory: evidence.directory,
1058
1448
  scope: "paths",
1059
1449
  entries: evidence.metadataReads,
@@ -1066,13 +1456,34 @@ export class UserGroundHost {
1066
1456
  records: evidence.records,
1067
1457
  recordBytes: evidence.recordBytes,
1068
1458
  });
1459
+ namedRecords += evidence.records;
1069
1460
  }
1070
- this.watchHost.settle(observations);
1071
- this.applyRail?.localStateChanged();
1072
- this.recordRail?.localRecordsAvailable();
1073
- 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;
1466
+ }
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"));
1074
1485
  }
1075
- widenedRoots = new Set(named?.widenedRoots ?? []);
1486
+ return;
1076
1487
  }
1077
1488
  let detection;
1078
1489
  let acceptedMaterializedRows;
@@ -1085,10 +1496,9 @@ export class UserGroundHost {
1085
1496
  userRoot: this.userRoot(identity),
1086
1497
  database: this.databasePath(identity),
1087
1498
  cacheDir: this.cacheDir(identity),
1088
- onScan: this.options.onDetectScan,
1089
- suspicions: new Map(observations.map((observation) => [observation.directory, widenedRoots.has(observation.directory)
1090
- ? { ...observation.suspicion, paths: null }
1091
- : observation.suspicion])),
1499
+ onScan: (evidence) => this.reportDetectScan(evidence),
1500
+ suspicions: reconcileSuspicions,
1501
+ coreScan,
1092
1502
  });
1093
1503
  detection = scanned.detection;
1094
1504
  acceptedMaterializedRows = scanned.acceptedMaterializedRows;
@@ -1113,6 +1523,7 @@ export class UserGroundHost {
1113
1523
  proposal,
1114
1524
  }))
1115
1525
  : []);
1526
+ const recordCommitStarted = performance.now();
1116
1527
  commitDetectedState(this.databasePath(identity), {
1117
1528
  acceptedMaterializedRows,
1118
1529
  materializedRemovals,
@@ -1120,10 +1531,48 @@ export class UserGroundHost {
1120
1531
  notebookRemovals,
1121
1532
  proposals: detectedRecords,
1122
1533
  });
1534
+ const recordCommitDuration = performance.now() - recordCommitStarted;
1535
+ if (detectedRecords.length === 0) {
1536
+ this.stage({
1537
+ primitive: "record",
1538
+ stage: "sqlite-transaction",
1539
+ status: "completed",
1540
+ durationMs: recordCommitDuration,
1541
+ measurements: { records: 0 },
1542
+ });
1543
+ }
1544
+ else {
1545
+ for (const record of detectedRecords) {
1546
+ this.stage({
1547
+ primitive: "record",
1548
+ stage: "outbox-committed",
1549
+ status: "completed",
1550
+ entityId: record.proposal.entityId,
1551
+ mutationId: record.mutationId,
1552
+ durationMs: recordCommitDuration,
1553
+ measurements: { records: detectedRecords.length },
1554
+ });
1555
+ }
1556
+ }
1123
1557
  this.namedDetect?.refreshEnrollmentPolicy();
1124
- this.watchHost.settle(observations);
1558
+ this.watchHost.settle(reconcileObservations);
1125
1559
  this.applyRail?.localStateChanged();
1126
1560
  this.recordRail?.localRecordsAvailable();
1561
+ this.stage({
1562
+ primitive: "detect",
1563
+ stage: "pass",
1564
+ status: "completed",
1565
+ durationMs: performance.now() - passStarted,
1566
+ measurements: {
1567
+ lane: "reconciliation",
1568
+ roots: reconcileObservations.length,
1569
+ namedRoots,
1570
+ records: detectedRecords.length + namedRecords,
1571
+ },
1572
+ });
1573
+ if (unsettled) {
1574
+ throw pipelineStageError("detection", new Error("repository control operation is unsettled"));
1575
+ }
1127
1576
  }
1128
1577
  async publishPendingSnapshotsBeforeLookup(identity, resourceId) {
1129
1578
  const pending = readSnapshotPublications(this.databasePath(identity));
@@ -1145,7 +1594,7 @@ export class UserGroundHost {
1145
1594
  /** Register is the deliberate cold graph-publication boundary. Ordinary
1146
1595
  * Watch detection stops at a durable exact record and never rebuilds this
1147
1596
  * resource snapshot. */
1148
- async publishMaterializedSnapshot(identity, publicationRootIds) {
1597
+ async publishMaterializedSnapshot(identity, publicationRootIds, trace) {
1149
1598
  const state = this.cloudState;
1150
1599
  if (!state)
1151
1600
  throw new Error("cloud state is unavailable for graph publication");
@@ -1169,7 +1618,7 @@ export class UserGroundHost {
1169
1618
  const identical = pending.find((entry) => entry.resourceId === state.resourceId
1170
1619
  && entry.snapshotChecksum === checksum);
1171
1620
  if (identical) {
1172
- await this.publishPendingSnapshots(identity);
1621
+ await this.publishPendingSnapshots(identity, trace);
1173
1622
  return;
1174
1623
  }
1175
1624
  if (pending.length > 0) {
@@ -1207,19 +1656,29 @@ export class UserGroundHost {
1207
1656
  replacementRootIds: replacement.rootIds,
1208
1657
  replacementRecords: replacement.records,
1209
1658
  });
1210
- await this.publishPendingSnapshots(identity);
1659
+ await this.publishPendingSnapshots(identity, trace);
1211
1660
  }
1212
- async publishPendingSnapshots(identity) {
1661
+ async publishPendingSnapshots(identity, trace) {
1213
1662
  for (const pending of readSnapshotPublications(this.databasePath(identity))) {
1214
1663
  const snapshot = snapshotFromRecords(JSON.parse(pending.snapshotJson).records || []);
1215
1664
  const replacement = rootReplacementFromRecords(pending.replacementRootIds, pending.replacementRecords);
1216
1665
  try {
1217
- await this.uploadSnapshotContent(identity, pending.resourceId, pending.uploadRecords);
1666
+ await this.uploadSnapshotContent(identity, pending.resourceId, pending.uploadRecords, trace);
1218
1667
  }
1219
1668
  catch (error) {
1220
1669
  throw pipelineStageError("cloud upload", error);
1221
1670
  }
1222
1671
  let frame;
1672
+ const graphCommitStarted = performance.now();
1673
+ if (trace?.journey === "register") {
1674
+ this.stage({
1675
+ primitive: "register",
1676
+ stage: "cloud-graph-commit",
1677
+ status: "started",
1678
+ ...(trace.workspaceId ? { workspaceId: trace.workspaceId } : {}),
1679
+ ...(trace.mutationId ? { mutationId: trace.mutationId } : {}),
1680
+ });
1681
+ }
1223
1682
  try {
1224
1683
  frame = await this.wire.request({
1225
1684
  type: "shared.mutation.submit",
@@ -1237,8 +1696,27 @@ export class UserGroundHost {
1237
1696
  }, ["shared.mutation.ack"]);
1238
1697
  }
1239
1698
  catch (error) {
1699
+ if (trace?.journey === "register") {
1700
+ this.stage({
1701
+ primitive: "register",
1702
+ stage: "cloud-graph-commit",
1703
+ status: "failed",
1704
+ ...(trace.workspaceId ? { workspaceId: trace.workspaceId } : {}),
1705
+ durationMs: performance.now() - graphCommitStarted,
1706
+ });
1707
+ }
1240
1708
  throw pipelineStageError("cloud graph commit", error);
1241
1709
  }
1710
+ if (trace?.journey === "register") {
1711
+ this.stage({
1712
+ primitive: "register",
1713
+ stage: "cloud-graph-commit",
1714
+ status: "completed",
1715
+ ...(trace.workspaceId ? { workspaceId: trace.workspaceId } : {}),
1716
+ durationMs: performance.now() - graphCommitStarted,
1717
+ measurements: { version: Number(frame.version) },
1718
+ });
1719
+ }
1242
1720
  this.cloudState = {
1243
1721
  resourceId: pending.resourceId,
1244
1722
  authorityEpoch: Number(frame.authority_epoch),
@@ -1599,6 +2077,41 @@ function countDescendantRows(file, rootUUID) {
1599
2077
  database.close();
1600
2078
  }
1601
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
+ }
1602
2115
  function readGroundUUIDs(file, table, rootUUIDs) {
1603
2116
  if (!existsSync(file))
1604
2117
  return new Set();
@@ -1620,6 +2133,20 @@ function readGroundUUIDs(file, table, rootUUIDs) {
1620
2133
  database.close();
1621
2134
  }
1622
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
+ }
1623
2150
  function workspaceAddStagingPath(destinationPath, workspaceId) {
1624
2151
  return join(dirname(destinationPath), `.amalgm-${workspaceId}.adding`);
1625
2152
  }
@@ -2153,6 +2680,11 @@ function createDeclaredHome(input) {
2153
2680
  }
2154
2681
  }
2155
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
+ }
2156
2688
  function slashPath(path) {
2157
2689
  return path.split(sep).join("/");
2158
2690
  }
@@ -2213,7 +2745,7 @@ function materializedWorkspaceBindings(bindingDir) {
2213
2745
  return bindings.sort((left, right) => left.workspaceId.localeCompare(right.workspaceId));
2214
2746
  }
2215
2747
  async function reconcileGround(input) {
2216
- const { identity, userRoot, database, cacheDir, suspicions, registrationWorkspaceId, onScan, } = input;
2748
+ const { identity, userRoot, database, cacheDir, suspicions, coreScan, registrationWorkspaceId, onScan, } = input;
2217
2749
  const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
2218
2750
  const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
2219
2751
  const boundRootEntries = materializedWorkspaceBindings(bindingDir);
@@ -2343,30 +2875,40 @@ async function reconcileGround(input) {
2343
2875
  });
2344
2876
  // Each root commits its local projection before Detect yields. Native
2345
2877
  // callbacks can therefore preserve new suspicion between large roots.
2346
- await new Promise((resolve) => setImmediate(resolve));
2878
+ await yieldEventLoop();
2347
2879
  return result;
2348
2880
  };
2349
- await scan({
2350
- identity,
2351
- resourceId,
2352
- rootUUID: coreUUID,
2353
- rootName: identity.userEmail,
2354
- rootPath: userRoot,
2355
- rootType: "workspace",
2356
- database,
2357
- cacheDir,
2358
- policy,
2359
- bindingDir,
2360
- suspects: missingPortableReference && coreSuspicion !== null
2361
- ? [...new Set([...coreSuspicion, "workspaces"])]
2362
- : coreSuspicion,
2363
- existingRows: existing.filter((row) => row.rootUUID === coreUUID),
2364
- suspicion: observationFor(userRoot),
2365
- });
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
+ }
2366
2906
  const physicalRoots = registrationOwner ? [registrationOwner] : outerBoundRoots;
2367
2907
  for (const { workspaceId, directory: rootPath } of physicalRoots) {
2368
2908
  const existingRoot = existingByUuid.get(workspaceId);
2369
2909
  const existingWorkspaceRows = existingByOutermostRoot.get(workspaceId) || [];
2910
+ if (!observed(rootPath) && existingWorkspaceRows.length > 0)
2911
+ continue;
2370
2912
  const registeredPath = registrationBoundary
2371
2913
  && registrationOwner?.workspaceId === workspaceId
2372
2914
  && registrationBoundary.workspaceId !== workspaceId
@@ -2554,6 +3096,7 @@ async function reconcileRoot(input) {
2554
3096
  }
2555
3097
  return [...new Set(paths)].sort();
2556
3098
  };
3099
+ let visitedEntries = 0;
2557
3100
  const visit = async (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
2558
3101
  ? {
2559
3102
  root: directory,
@@ -2580,6 +3123,11 @@ async function reconcileRoot(input) {
2580
3123
  const absolutePath = join(directory, child.name);
2581
3124
  const stats = lstatSync(absolutePath);
2582
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();
2583
3131
  const existing = existingByPath.get(relativePath);
2584
3132
  const registeredBoundaryId = stats.isDirectory()
2585
3133
  ? registeredBoundaries.get(resolve(absolutePath))