@amalgm/shell 0.1.53 → 0.1.55

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.
@@ -3,16 +3,18 @@ import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlin
3
3
  import { open } from "node:fs/promises";
4
4
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { buildUserHomeManifest, buildUserManifest, liveMachineStateDir, scopedAmalgmDir, shippedUserHomeDeclaration, } from "@amalgm/core/identity";
6
- import { CHUNK_BYTES, CONTENT_CONTRACT, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, acceptOutboxRow, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, createLocalEntityRecord, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, download as downloadArtifact, decodeAcceptedEntityRecord, decodeLocalEntityRecord, encodeEntityRecord, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, nextEntitySends, receiveEntityRecord, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
6
+ import { CHUNK_BYTES, CONTENT_CONTRACT, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, createLocalEntityRecord, createEntityRecordAuthorityPort, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, download as downloadArtifact, encodeEntityRecord, EntityApplyRail, EntityRecordRail, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, 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 { EntityApplyHost } from "./entity-apply-host.js";
10
+ import { EntityRecordSqliteStore } from "./entity-record-store.js";
9
11
  import { ContentCacheDownload, captureContentFile, hashContentFile, sealCapturedArtifact, } from "./content-cache-host.js";
10
12
  import { decodeContentWireBytes, encodeContentWireBytes } from "./content-wire-codec.js";
11
13
  import { exactTextReplay, planGroundDetection, reconcileGroundUUIDs, } from "./detection/portable.js";
12
14
  import { NamedDetectRuntime } from "./detection/runtime.js";
13
15
  import { WORKSPACE_UUID as UUID, createFilesRegisterPorts, ensureWorkspaceBinding, ensureWorkspaceReference, pathExists, pathWithin, referenceWorkspaceId, selectKnownRegistrationId, workspaceBindingDir, } from "./files-register-host.js";
14
16
  import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, hasGitMarker, } from "./git-repository-host.js";
15
- import { inspectGitRegistration, } from "./git-registration-host.js";
17
+ import { canonicalizeGitIdentity, inspectGitRegistration, sameGitIdentity, } from "./git-registration-host.js";
16
18
  import { projectMaterializedGraph } from "./materialized-graph.js";
17
19
  import { NodeWatchHost, } from "./watching/index.js";
18
20
  import { WireClient, WireRequestError } from "./wire-client.js";
@@ -22,9 +24,6 @@ const FILE_BATCH_DOWNLOAD_CONCURRENCY = 2;
22
24
  const DETECT_QUIET_MS = 12;
23
25
  const DETECT_MAX_DEFERRAL_MS = 75;
24
26
  const DETECT_RETRY_MS = 250;
25
- const RECORD_RETRY_MS = 1_000;
26
- const RECORD_POLL_MS = 1_000;
27
- const RECORD_TAIL_LIMIT = 256;
28
27
  const GROUND_ROW_COLUMNS = [
29
28
  "uuid", "resource_id", "root_uuid", "type", "parent_uuid", "name", "status", "version",
30
29
  "payload_version", "transport_version", "relative_path", "absolute_path", "device_number",
@@ -44,12 +43,14 @@ export class UserGroundHost {
44
43
  cloudState = null;
45
44
  converged = false;
46
45
  syncing = null;
47
- recordSyncing = null;
48
- recordSyncTimer = null;
49
- recordSyncStopped = false;
46
+ recordRail = null;
47
+ recordStore = null;
48
+ applyRail = null;
49
+ applyHost = null;
50
50
  namedDetect = null;
51
51
  coldOperation = false;
52
52
  closing = false;
53
+ groundEffectTail = Promise.resolve();
53
54
  port;
54
55
  constructor(options) {
55
56
  this.options = options;
@@ -90,8 +91,9 @@ export class UserGroundHost {
90
91
  watcherRoots: watchHealth.contentHandles,
91
92
  watching: true,
92
93
  };
94
+ await this.startRecordRail(this.activeIdentity);
95
+ await this.startApplyRail(this.activeIdentity);
93
96
  this.converged = true;
94
- this.scheduleRecordSync();
95
97
  return evidence;
96
98
  },
97
99
  };
@@ -151,26 +153,38 @@ export class UserGroundHost {
151
153
  return cloud;
152
154
  },
153
155
  materializeWorkspace: async (input) => {
154
- const installed = await installCloudWorkspace({
155
- identity,
156
- userRoot,
157
- database: this.databasePath(identity),
158
- cacheDir: this.cacheDir(identity),
159
- bindingDir,
160
- resourceId: privateEntityResourceId(identity.userId, sha256Hex),
161
- workspace: input.workspace,
162
- reference: input.reference,
163
- references: input.references,
164
- records: input.records,
165
- destinationParent: input.destinationParent,
166
- cloudHead: input.cloudHead,
167
- readContent: (artifact) => this.downloadContent(privateEntityResourceId(identity.userId, sha256Hex), this.cacheDir(identity), artifact),
168
- onStage: (stage) => this.options.onWorkspaceAddStage?.({
169
- workspaceId: input.workspace.uuid,
170
- stage,
171
- }),
172
- });
173
- return installed;
156
+ const release = await this.acquireGroundEffect();
157
+ try {
158
+ const installed = await installCloudWorkspace({
159
+ identity,
160
+ userRoot,
161
+ database: this.databasePath(identity),
162
+ cacheDir: this.cacheDir(identity),
163
+ bindingDir,
164
+ resourceId: privateEntityResourceId(identity.userId, sha256Hex),
165
+ workspace: input.workspace,
166
+ reference: input.reference,
167
+ references: input.references,
168
+ records: input.records,
169
+ destinationParent: input.destinationParent,
170
+ cloudHead: input.cloudHead,
171
+ readContent: (artifact) => this.downloadContent(privateEntityResourceId(identity.userId, sha256Hex), this.cacheDir(identity), artifact),
172
+ onStage: (stage) => this.options.onWorkspaceAddStage?.({
173
+ workspaceId: input.workspace.uuid,
174
+ stage,
175
+ }),
176
+ });
177
+ // Add changes both disk and the exact SQLite projection. Open the
178
+ // selected root's singular Watch coverage before Detect can observe
179
+ // either side of that transaction; the public cover port below
180
+ // independently proves the same coverage before clearing intent.
181
+ this.ensureWatchers(identity, installed.alreadyMaterialized ? [] : [input.workspace.uuid]);
182
+ assertHealthyWatch(this.watchHost.evidence(), input.workspace.uuid);
183
+ return installed;
184
+ }
185
+ finally {
186
+ release();
187
+ }
174
188
  },
175
189
  coverWorkspace: async ({ workspaceId, baseline }) => {
176
190
  const pending = readWorkspaceAddIntent(this.databasePath(identity), workspaceId);
@@ -192,37 +206,43 @@ export class UserGroundHost {
192
206
  const userRoot = this.userRoot(identity);
193
207
  const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
194
208
  for (const intent of readWorkspaceAddIntents(database)) {
195
- const selection = workspaceAddSelection(intent);
209
+ const release = await this.acquireGroundEffect();
196
210
  const visibleAcrossBlindInterval = pathExists(intent.destinationPath);
197
- const stagedAcrossBlindInterval = pathExists(intent.stagingPath);
198
- if (stagedAcrossBlindInterval && readRows(database).some((row) => row.resourceId === intent.resourceId && row.rootUUID === intent.workspaceId)) {
199
- // Rows prove that staging was verified before the crash, not that its
200
- // bytes remained unchanged while no watcher existed. Return this
201
- // private install to the intent-only stage and reproduce it from the
202
- // verified immutable cache before making it visible.
203
- deleteRootRows(database, intent.resourceId, intent.workspaceId);
211
+ try {
212
+ const selection = workspaceAddSelection(intent);
213
+ const stagedAcrossBlindInterval = pathExists(intent.stagingPath);
214
+ if (stagedAcrossBlindInterval && readRows(database).some((row) => row.resourceId === intent.resourceId && row.rootUUID === intent.workspaceId)) {
215
+ // Rows prove that staging was verified before the crash, not that its
216
+ // bytes remained unchanged while no watcher existed. Return this
217
+ // private install to the intent-only stage and reproduce it from the
218
+ // verified immutable cache before making it visible.
219
+ deleteRootRows(database, intent.resourceId, intent.workspaceId);
220
+ }
221
+ await installCloudWorkspace({
222
+ identity,
223
+ userRoot,
224
+ database,
225
+ cacheDir: this.cacheDir(identity),
226
+ bindingDir,
227
+ resourceId: intent.resourceId,
228
+ workspace: selection.workspace,
229
+ reference: selection.reference,
230
+ references: intent.references,
231
+ records: intent.records,
232
+ destinationParent: dirname(intent.destinationPath),
233
+ cloudHead: intent.cloudHead,
234
+ readContent: (artifact) => this.downloadContent(intent.resourceId, this.cacheDir(identity), artifact),
235
+ onStage: (stage) => this.options.onWorkspaceAddStage?.({
236
+ workspaceId: intent.workspaceId,
237
+ stage,
238
+ }),
239
+ });
240
+ this.ensureWatchers(identity, visibleAcrossBlindInterval ? [] : [intent.workspaceId]);
241
+ assertHealthyWatch(this.watchHost.evidence(), intent.workspaceId);
242
+ }
243
+ finally {
244
+ release();
204
245
  }
205
- await installCloudWorkspace({
206
- identity,
207
- userRoot,
208
- database,
209
- cacheDir: this.cacheDir(identity),
210
- bindingDir,
211
- resourceId: intent.resourceId,
212
- workspace: selection.workspace,
213
- reference: selection.reference,
214
- references: intent.references,
215
- records: intent.records,
216
- destinationParent: dirname(intent.destinationPath),
217
- cloudHead: intent.cloudHead,
218
- readContent: (artifact) => this.downloadContent(intent.resourceId, this.cacheDir(identity), artifact),
219
- onStage: (stage) => this.options.onWorkspaceAddStage?.({
220
- workspaceId: intent.workspaceId,
221
- stage,
222
- }),
223
- });
224
- this.ensureWatchers(identity, visibleAcrossBlindInterval ? [] : [intent.workspaceId]);
225
- assertHealthyWatch(this.watchHost.evidence(), intent.workspaceId);
226
246
  if (visibleAcrossBlindInterval) {
227
247
  // Ground that was visible while the runtime was absent may have been
228
248
  // edited after its verified reveal. It is an ordinary startup blind
@@ -261,8 +281,12 @@ export class UserGroundHost {
261
281
  throw stageError("Watch coverage", error);
262
282
  }
263
283
  let rows = readRows(this.databasePath(identity));
264
- if (rows.some((row) => row.uuid === workspaceId)
265
- && this.cloudState.records.some((record) => record.uuid === workspaceId)) {
284
+ const hasRegisteredBoundary = (records) => records.some((record) => record.uuid === workspaceId && record.status === "active")
285
+ && records.some((record) => record.type === "reference"
286
+ && record.status === "active"
287
+ && record.payloadVersion === workspaceId);
288
+ if (hasRegisteredBoundary(rows.map(portableRecord))
289
+ && hasRegisteredBoundary(this.cloudState.records)) {
266
290
  this.finishColdOperation();
267
291
  return {
268
292
  registered: true,
@@ -274,11 +298,13 @@ export class UserGroundHost {
274
298
  // Register is a cold declaration, not a filesystem edit. It performs one
275
299
  // complete reconciliation and publishes one graph head; it must not mint
276
300
  // a parallel stream of Detect records for the same initial contents.
277
- const observations = this.watchHost.observations();
278
- this.watchDirty = false;
301
+ let releaseGround = null;
279
302
  try {
280
303
  if (this.syncing)
281
304
  await this.syncing;
305
+ releaseGround = await this.acquireGroundEffect();
306
+ const observations = this.watchHost.observations();
307
+ this.watchDirty = false;
282
308
  const scanned = await reconcileGround({
283
309
  identity,
284
310
  userRoot: this.userRoot(identity),
@@ -309,6 +335,7 @@ export class UserGroundHost {
309
335
  throw stageError("entity registration or cloud publication", error);
310
336
  }
311
337
  finally {
338
+ releaseGround?.();
312
339
  this.finishColdOperation();
313
340
  }
314
341
  try {
@@ -320,11 +347,11 @@ export class UserGroundHost {
320
347
  throw stageError("Watch coverage", error);
321
348
  }
322
349
  rows = readRows(this.databasePath(identity));
323
- if (!rows.some((row) => row.uuid === workspaceId)) {
324
- throw stageError("entity registration", new Error(`workspace ${workspaceId} was not recorded`));
350
+ if (!hasRegisteredBoundary(rows.map(portableRecord))) {
351
+ throw stageError("entity registration", new Error(`workspace ${workspaceId} and its durable reference were not recorded`));
325
352
  }
326
- if (!this.cloudState.records.some((record) => record.uuid === workspaceId)) {
327
- throw stageError("cloud graph commit", new Error(`workspace ${workspaceId} is absent from the committed head`));
353
+ if (!hasRegisteredBoundary(this.cloudState.records)) {
354
+ throw stageError("cloud graph commit", new Error(`workspace ${workspaceId} or its durable reference is absent from the committed head`));
328
355
  }
329
356
  return {
330
357
  registered: true,
@@ -359,7 +386,8 @@ export class UserGroundHost {
359
386
  throw error;
360
387
  }
361
388
  } while (!this.watchHost.settled(cutoff));
362
- await this.syncRecordsNow();
389
+ await this.recordRail?.flush();
390
+ await this.applyRail?.flush();
363
391
  if (!this.closing)
364
392
  this.ensureWatchers(identity);
365
393
  }
@@ -383,31 +411,108 @@ export class UserGroundHost {
383
411
  if (this.syncing)
384
412
  await this.syncing;
385
413
  }
386
- scheduleRecordSync(delay = 0) {
387
- if (this.closing || this.recordSyncStopped || this.recordSyncTimer)
388
- return;
389
- this.recordSyncTimer = setTimeout(() => {
390
- this.recordSyncTimer = null;
391
- void this.syncRecordsNow().then(() => {
392
- if (!this.closing && !this.recordSyncStopped)
393
- this.scheduleRecordSync(RECORD_POLL_MS);
394
- }).catch(() => {
395
- if (!this.closing && !this.recordSyncStopped)
396
- this.scheduleRecordSync(RECORD_RETRY_MS);
397
- });
398
- }, delay);
399
- this.recordSyncTimer.unref();
414
+ async startRecordRail(identity) {
415
+ if (!this.cloudState)
416
+ throw new Error("entity Record rail requires cloud authority");
417
+ if (this.recordRail || this.recordStore)
418
+ throw new Error("entity Record rail already started");
419
+ await this.wire.open();
420
+ if (this.wire.protocolVersion < 4) {
421
+ throw new Error("gateway does not support the event-driven entity Record wire");
422
+ }
423
+ const store = new EntityRecordSqliteStore(initializeDatabase(this.databasePath(identity)));
424
+ const authority = createEntityRecordAuthorityPort({
425
+ request: (frame, acceptedTypes) => this.wire.request({ ...frame }, acceptedTypes),
426
+ onFrame: (listener) => this.wire.onEvent(listener),
427
+ onDisconnect: (listener) => this.wire.onDisconnect(listener),
428
+ });
429
+ const rail = new EntityRecordRail({
430
+ authorityId: this.cloudState.resourceId,
431
+ store,
432
+ authority,
433
+ cargo: {
434
+ ensure: (record) => this.uploadSnapshotContent(identity, record.authorityId, [record.change.result]),
435
+ },
436
+ schedule: {
437
+ schedule(delayMs, callback) {
438
+ const timer = setTimeout(callback, delayMs);
439
+ timer.unref();
440
+ return { cancel: () => clearTimeout(timer) };
441
+ },
442
+ random: Math.random,
443
+ },
444
+ onInboxCommitted: () => this.applyRail?.recordsAvailable(),
445
+ });
446
+ this.recordStore = store;
447
+ this.recordRail = rail;
448
+ try {
449
+ await rail.start();
450
+ }
451
+ catch (error) {
452
+ const stopping = rail.stop();
453
+ await this.wire.close();
454
+ await stopping;
455
+ store.close();
456
+ this.recordRail = null;
457
+ this.recordStore = null;
458
+ throw pipelineStageError("entity record subscribe", error);
459
+ }
400
460
  }
401
- async syncRecordsNow() {
402
- const identity = this.activeIdentity;
403
- if (this.recordSyncStopped || !identity || !this.cloudState)
404
- return;
405
- if (!this.recordSyncing) {
406
- this.recordSyncing = this.syncRecordQueues(identity).finally(() => {
407
- this.recordSyncing = null;
408
- });
461
+ async startApplyRail(identity) {
462
+ if (!this.cloudState)
463
+ throw new Error("entity Apply rail requires cloud authority");
464
+ if (this.applyRail || this.applyHost)
465
+ throw new Error("entity Apply rail already started");
466
+ let host;
467
+ host = new EntityApplyHost({
468
+ databaseFile: this.databasePath(identity),
469
+ userRoot: this.userRoot(identity),
470
+ cacheDir: this.cacheDir(identity),
471
+ bindingDir: workspaceBindingDir(this.userRoot(identity), identity.deviceId),
472
+ readContent: (artifact) => this.downloadContent(this.cloudState.resourceId, this.cacheDir(identity), artifact),
473
+ captureLocal: async (intent, plan, position) => {
474
+ // Apply never suppresses Watch. This explicit whole-root suspicion is
475
+ // the synchronous proof boundary used only when Apply observed a
476
+ // save racing its reveal and must make that save durable first.
477
+ this.watchHost.suspectAll();
478
+ this.watchDirty = true;
479
+ await this.syncNow();
480
+ if (!host.store.hasLocalWorkAfter(intent.authorityId, intent.entityId, intent.throughSequence)) {
481
+ throw new Error(`Apply ${position} capture for ${plan.target.type} ${plan.target.name} (${intent.entityId}) produced no durable local record`);
482
+ }
483
+ },
484
+ acquireGround: () => this.acquireGroundEffect(),
485
+ });
486
+ const rail = new EntityApplyRail({
487
+ store: host.store,
488
+ effects: host.effects,
489
+ schedule: {
490
+ schedule(delayMs, callback) {
491
+ const timer = setTimeout(callback, delayMs);
492
+ timer.unref();
493
+ return { cancel: () => clearTimeout(timer) };
494
+ },
495
+ random: Math.random,
496
+ },
497
+ sha256Hex,
498
+ now: Date.now,
499
+ // Path cones and repository territories are wider than one UUID. Until
500
+ // the host exposes those locks, one serial machine-effect owner is the
501
+ // only honest concurrency setting.
502
+ concurrency: 1,
503
+ });
504
+ this.applyHost = host;
505
+ this.applyRail = rail;
506
+ try {
507
+ await rail.start();
508
+ }
509
+ catch (error) {
510
+ await rail.stop();
511
+ host.close();
512
+ this.applyRail = null;
513
+ this.applyHost = null;
514
+ throw pipelineStageError("entity Apply resume", error);
409
515
  }
410
- await this.recordSyncing;
411
516
  }
412
517
  async activateRuntimeTunnel(gatewayPort, runtimeToken) {
413
518
  // Files convergence and Watch/Detect own ground currency. Advertising an
@@ -416,30 +521,41 @@ export class UserGroundHost {
416
521
  }
417
522
  async stopRuntimeTunnel() {
418
523
  this.wire.configureRuntime({});
419
- await this.wire.close();
420
524
  }
421
525
  async close(options = {}) {
422
526
  if (options.catchUp === false) {
423
527
  this.closing = true;
424
- this.recordSyncStopped = true;
425
528
  if (this.rescanTimer)
426
529
  clearTimeout(this.rescanTimer);
427
530
  this.rescanTimer = null;
428
531
  this.rescanGenerationStartedAt = null;
429
- if (this.recordSyncTimer)
430
- clearTimeout(this.recordSyncTimer);
431
- this.recordSyncTimer = null;
432
532
  this.watchHost.close({ catchUp: false });
433
533
  this.namedDetect?.close();
434
534
  this.namedDetect = null;
535
+ const applyStopped = this.applyRail?.stop();
536
+ const recordStopped = this.recordRail?.stop();
435
537
  await this.wire.close();
436
- await this.recordSyncing?.catch(() => undefined);
538
+ await applyStopped;
539
+ await recordStopped;
437
540
  // An upload already inside its retry helper may have reopened the wire
438
- // after the first close. The stopped flag prevents new queue work; this
541
+ // after the first close. The stopped rail prevents new queue work; this
439
542
  // second close seals that finite in-flight boundary.
440
543
  await this.wire.close();
544
+ this.recordStore?.close();
545
+ this.recordStore = null;
546
+ this.recordRail = null;
547
+ this.applyHost?.close();
548
+ this.applyHost = null;
549
+ this.applyRail = null;
441
550
  return;
442
551
  }
552
+ await this.recordRail?.flush();
553
+ await this.applyRail?.flush();
554
+ const applyStopped = this.applyRail?.stop();
555
+ await applyStopped;
556
+ this.applyHost?.close();
557
+ this.applyHost = null;
558
+ this.applyRail = null;
443
559
  // Command shutdown is the last catch-up boundary. A filesystem callback
444
560
  // may still be queued, so watcher silence cannot be used as evidence yet.
445
561
  this.watchHost.suspectAll();
@@ -449,20 +565,21 @@ export class UserGroundHost {
449
565
  clearTimeout(this.rescanTimer);
450
566
  this.rescanTimer = null;
451
567
  this.rescanGenerationStartedAt = null;
452
- if (this.recordSyncTimer)
453
- clearTimeout(this.recordSyncTimer);
454
- this.recordSyncTimer = null;
455
568
  // Full suspicion already contains every callback that could still be
456
569
  // queued. Retire physical coverage before the final observation so native
457
570
  // backends cannot continuously widen a finite shutdown pass. Changes
458
571
  // after this cutoff belong to the next startup blind interval.
459
572
  this.watchHost.close();
460
573
  await this.flushObservedChanges();
461
- await this.syncRecordsNow();
462
- this.recordSyncStopped = true;
574
+ await this.recordRail?.flush();
463
575
  this.namedDetect?.close();
464
576
  this.namedDetect = null;
577
+ const recordStopped = this.recordRail?.stop();
465
578
  await this.wire.close();
579
+ await recordStopped;
580
+ this.recordStore?.close();
581
+ this.recordStore = null;
582
+ this.recordRail = null;
466
583
  }
467
584
  userRoot(identity) {
468
585
  return resolve(scopedAmalgmDir(this.options.amalgmRoot, identity.userEmail));
@@ -781,6 +898,9 @@ export class UserGroundHost {
781
898
  rootId: workspaceId,
782
899
  directory,
783
900
  materialized: true,
901
+ // Every materialized root has one reserved machine-control subtree.
902
+ // Apply stages and retains recovery bytes here; Watch never enrolls it.
903
+ excludedSubtrees: [join(directory, ".amalgm")],
784
904
  });
785
905
  }
786
906
  const health = this.watchHost.reconcile([...wanted.values()], {
@@ -839,7 +959,24 @@ export class UserGroundHost {
839
959
  this.scheduleRescan();
840
960
  }
841
961
  }
962
+ async acquireGroundEffect() {
963
+ let release;
964
+ const held = new Promise((resolve) => { release = resolve; });
965
+ const preceding = this.groundEffectTail;
966
+ this.groundEffectTail = preceding.then(() => held);
967
+ await preceding;
968
+ return release;
969
+ }
842
970
  async syncLocalChanges(identity) {
971
+ const release = await this.acquireGroundEffect();
972
+ try {
973
+ await this.syncLocalChangesUnlocked(identity);
974
+ }
975
+ finally {
976
+ release();
977
+ }
978
+ }
979
+ async syncLocalChangesUnlocked(identity) {
843
980
  if (!this.cloudState)
844
981
  throw new Error("cloud state is unavailable for user-ground Watch");
845
982
  const observations = this.watchHost.observations();
@@ -868,7 +1005,8 @@ export class UserGroundHost {
868
1005
  });
869
1006
  }
870
1007
  this.watchHost.settle(observations);
871
- this.scheduleRecordSync();
1008
+ this.applyRail?.localStateChanged();
1009
+ this.recordRail?.localRecordsAvailable();
872
1010
  return;
873
1011
  }
874
1012
  }
@@ -900,7 +1038,7 @@ export class UserGroundHost {
900
1038
  throw pipelineStageError("detection", new Error(retry.reasons.join("; ")));
901
1039
  }
902
1040
  const authorityId = this.cloudState.resourceId;
903
- const received = readEntityReceiveCursors(this.databasePath(identity), authorityId);
1041
+ const received = this.recordStore?.readEntityCursors(authorityId) ?? new Map();
904
1042
  const detectedRecords = detection.flatMap((plan) => plan.kind === "ready"
905
1043
  ? plan.proposals.map((proposal) => ({
906
1044
  mutationId: randomUUID(),
@@ -918,91 +1056,8 @@ export class UserGroundHost {
918
1056
  });
919
1057
  this.namedDetect?.refreshEnrollmentPolicy();
920
1058
  this.watchHost.settle(observations);
921
- this.scheduleRecordSync();
922
- }
923
- async syncRecordQueues(identity) {
924
- if (this.recordSyncStopped)
925
- return;
926
- await this.receiveAcceptedRecords();
927
- while (!this.recordSyncStopped) {
928
- const selected = nextEntitySends(readOutbox(this.databasePath(identity)));
929
- if (selected.length === 0)
930
- break;
931
- const sent = await Promise.allSettled(selected.map(async (row) => {
932
- if (this.recordSyncStopped)
933
- return;
934
- await this.uploadSnapshotContent(identity, row.record.authorityId, [row.record.change.result]);
935
- if (this.recordSyncStopped)
936
- return;
937
- const frame = await this.wire.request({
938
- type: "entity.record.submit",
939
- record_json: encodeEntityRecord(row.record),
940
- }, ["entity.record.ack"]);
941
- const accepted = decodeAcceptedEntityRecord(String(frame.record_json ?? ""));
942
- stampOutboxAcceptance(this.databasePath(identity), row, accepted);
943
- }));
944
- const failed = sent.find((result) => result.status === "rejected");
945
- if (failed)
946
- throw pipelineStageError("entity record send", failed.reason);
947
- }
948
- if (!this.recordSyncStopped)
949
- await this.receiveAcceptedRecords();
950
- }
951
- async receiveAcceptedRecords() {
952
- const identity = this.activeIdentity;
953
- const authorityId = this.cloudState?.resourceId;
954
- if (!identity || !authorityId)
955
- return;
956
- const database = this.databasePath(identity);
957
- while (!this.recordSyncStopped) {
958
- const after = readAuthorityDeliveryCursor(database, authorityId);
959
- const frame = await this.wire.request({
960
- type: "entity.record.tail",
961
- authority_id: authorityId,
962
- after_delivery_sequence: after,
963
- limit: RECORD_TAIL_LIMIT,
964
- }, ["entity.record.tail-result"]);
965
- const deliveries = decodeDeliveredRecords(frame.records);
966
- if (this.recordSyncStopped)
967
- return;
968
- if (deliveries.length === 0)
969
- return;
970
- for (const delivery of deliveries) {
971
- await this.receiveDelivery(database, authorityId, delivery);
972
- }
973
- if (deliveries.length < RECORD_TAIL_LIMIT)
974
- return;
975
- }
976
- }
977
- async receiveDelivery(database, authorityId, delivery) {
978
- if (delivery.record.authorityId !== authorityId) {
979
- throw new Error(`entity record authority ${delivery.record.authorityId} does not match ${authorityId}`);
980
- }
981
- let decision = storeReceivedRecord(database, delivery);
982
- if (decision.kind === "gap") {
983
- const frame = await this.wire.request({
984
- type: "entity.record.entity-tail",
985
- authority_id: authorityId,
986
- entity_id: decision.entityId,
987
- after_global_sequence: decision.after,
988
- through_global_sequence: decision.received,
989
- }, ["entity.record.entity-tail-result"]);
990
- const missing = decodeDeliveredRecords(frame.records);
991
- if (missing.length === 0) {
992
- throw new Error(`entity ${decision.entityId} is missing sequence ${decision.after + 1}`);
993
- }
994
- for (const record of missing) {
995
- decision = storeReceivedRecord(database, record);
996
- if (decision.kind === "gap") {
997
- throw new Error(`entity ${decision.entityId} history remained gapped after backfill`);
998
- }
999
- }
1000
- decision = storeReceivedRecord(database, delivery);
1001
- if (decision.kind === "gap") {
1002
- throw new Error(`entity ${decision.entityId} history did not reach ${decision.received}`);
1003
- }
1004
- }
1005
- advanceAuthorityDeliveryCursor(database, authorityId, delivery.deliverySequence);
1059
+ this.applyRail?.localStateChanged();
1060
+ this.recordRail?.localRecordsAvailable();
1006
1061
  }
1007
1062
  async publishPendingSnapshotsBeforeLookup(identity, resourceId) {
1008
1063
  const pending = readSnapshotPublications(this.databasePath(identity));
@@ -1193,6 +1248,7 @@ function assertHealthyWatch(evidence, rootId) {
1193
1248
  function initializeDatabase(file) {
1194
1249
  ensurePrivateDir(dirname(file));
1195
1250
  const database = new Database(file);
1251
+ database.pragma("busy_timeout = 5000");
1196
1252
  database.pragma("journal_mode = WAL");
1197
1253
  database.pragma("synchronous = FULL");
1198
1254
  database.exec(`
@@ -1481,26 +1537,6 @@ function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
1481
1537
  database.close();
1482
1538
  }
1483
1539
  }
1484
- function readOutbox(file) {
1485
- if (!existsSync(file))
1486
- return [];
1487
- const database = initializeDatabase(file);
1488
- try {
1489
- return database.prepare(`
1490
- SELECT local_sequence AS localSequence, record_json AS recordJson,
1491
- status, global_sequence AS globalSequence
1492
- FROM record_outbox WHERE status = 'pending' ORDER BY local_sequence
1493
- `).all().map((row) => ({
1494
- localSequence: row.localSequence,
1495
- record: decodeLocalEntityRecord(row.recordJson),
1496
- status: "pending",
1497
- globalSequence: null,
1498
- }));
1499
- }
1500
- finally {
1501
- database.close();
1502
- }
1503
- }
1504
1540
  function readSnapshotPublications(file) {
1505
1541
  if (!existsSync(file))
1506
1542
  return [];
@@ -1526,153 +1562,6 @@ function readSnapshotPublications(file) {
1526
1562
  database.close();
1527
1563
  }
1528
1564
  }
1529
- function readEntityReceiveCursor(file, authorityId, entityId) {
1530
- if (!existsSync(file))
1531
- return 0;
1532
- const database = initializeDatabase(file);
1533
- try {
1534
- const row = database.prepare(`
1535
- SELECT received_through AS receivedThrough
1536
- FROM entity_receive_cursors
1537
- WHERE authority_id = ? AND entity_id = ?
1538
- `).get(authorityId, entityId);
1539
- return row?.receivedThrough ?? 0;
1540
- }
1541
- finally {
1542
- database.close();
1543
- }
1544
- }
1545
- function readEntityReceiveCursors(file, authorityId) {
1546
- if (!existsSync(file))
1547
- return new Map();
1548
- const database = initializeDatabase(file);
1549
- try {
1550
- return new Map(database.prepare(`
1551
- SELECT entity_id AS entityId, received_through AS receivedThrough
1552
- FROM entity_receive_cursors WHERE authority_id = ?
1553
- `).all(authorityId)
1554
- .map((row) => [row.entityId, row.receivedThrough]));
1555
- }
1556
- finally {
1557
- database.close();
1558
- }
1559
- }
1560
- function readAuthorityDeliveryCursor(file, authorityId) {
1561
- if (!existsSync(file))
1562
- return 0;
1563
- const database = initializeDatabase(file);
1564
- try {
1565
- const row = database.prepare(`
1566
- SELECT received_through AS receivedThrough
1567
- FROM authority_delivery_cursors WHERE authority_id = ?
1568
- `).get(authorityId);
1569
- return row?.receivedThrough ?? 0;
1570
- }
1571
- finally {
1572
- database.close();
1573
- }
1574
- }
1575
- function decodeDeliveredRecords(value) {
1576
- if (!Array.isArray(value))
1577
- throw new Error("entity record tail did not return a records array");
1578
- return value.map((item) => {
1579
- if (item === null || typeof item !== "object") {
1580
- throw new Error("entity record tail contained a non-record delivery");
1581
- }
1582
- const delivered = item;
1583
- const deliverySequence = Number(delivered.delivery_sequence);
1584
- if (!Number.isSafeInteger(deliverySequence) || deliverySequence < 1
1585
- || typeof delivered.record_json !== "string") {
1586
- throw new Error("entity record delivery has invalid sequence or bytes");
1587
- }
1588
- return {
1589
- deliverySequence,
1590
- record: decodeAcceptedEntityRecord(delivered.record_json),
1591
- };
1592
- });
1593
- }
1594
- function stampOutboxAcceptance(file, row, accepted) {
1595
- const receipt = acceptOutboxRow(row, accepted);
1596
- if (receipt.status !== "accepted")
1597
- throw new Error("authority acknowledgement was not accepted");
1598
- const database = initializeDatabase(file);
1599
- try {
1600
- database.transaction(() => {
1601
- const updated = database.prepare(`
1602
- UPDATE record_outbox
1603
- SET status = 'accepted', global_sequence = ?, accepted_at = ?
1604
- WHERE mutation_id = ? AND status = 'pending'
1605
- `).run(accepted.globalSequence, accepted.acceptedAt, accepted.mutationId);
1606
- if (updated.changes === 1)
1607
- return;
1608
- const existing = database.prepare(`
1609
- SELECT status, global_sequence AS globalSequence
1610
- FROM record_outbox WHERE mutation_id = ?
1611
- `).get(accepted.mutationId);
1612
- if (existing?.status !== "accepted"
1613
- || existing.globalSequence !== accepted.globalSequence) {
1614
- throw new Error("outbox acknowledgement did not match its retained row");
1615
- }
1616
- })();
1617
- }
1618
- finally {
1619
- database.close();
1620
- }
1621
- }
1622
- function storeReceivedRecord(file, delivery) {
1623
- const database = initializeDatabase(file);
1624
- try {
1625
- return database.transaction(() => {
1626
- const cursor = database.prepare(`
1627
- SELECT authority_id AS authorityId, received_through AS receivedThrough
1628
- FROM entity_receive_cursors WHERE entity_id = ?
1629
- `).get(delivery.record.entityId);
1630
- if (cursor && cursor.authorityId !== delivery.record.authorityId) {
1631
- throw new Error(`entity ${delivery.record.entityId} cannot change authority channels`);
1632
- }
1633
- const existingRow = database.prepare(`
1634
- SELECT record_json AS recordJson FROM record_inbox
1635
- WHERE authority_id = ? AND entity_id = ? AND global_sequence = ?
1636
- `).get(delivery.record.authorityId, delivery.record.entityId, delivery.record.globalSequence);
1637
- const existing = existingRow
1638
- ? decodeAcceptedEntityRecord(existingRow.recordJson)
1639
- : null;
1640
- const decision = receiveEntityRecord(cursor?.receivedThrough ?? 0, delivery.record, existing);
1641
- if (decision.kind !== "insert")
1642
- return decision;
1643
- database.prepare(`
1644
- INSERT INTO record_inbox(
1645
- authority_id, entity_id, global_sequence, delivery_sequence,
1646
- mutation_id, record_json, received_at
1647
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
1648
- `).run(delivery.record.authorityId, delivery.record.entityId, delivery.record.globalSequence, delivery.deliverySequence, delivery.record.mutationId, encodeEntityRecord(delivery.record), Date.now());
1649
- database.prepare(`
1650
- INSERT INTO entity_receive_cursors(authority_id, entity_id, received_through)
1651
- VALUES (?, ?, ?)
1652
- ON CONFLICT(entity_id) DO UPDATE SET
1653
- received_through = excluded.received_through
1654
- `).run(delivery.record.authorityId, delivery.record.entityId, decision.receivedThrough);
1655
- return decision;
1656
- })();
1657
- }
1658
- finally {
1659
- database.close();
1660
- }
1661
- }
1662
- function advanceAuthorityDeliveryCursor(file, authorityId, deliverySequence) {
1663
- const database = initializeDatabase(file);
1664
- try {
1665
- database.prepare(`
1666
- INSERT INTO authority_delivery_cursors(authority_id, received_through)
1667
- VALUES (?, ?)
1668
- ON CONFLICT(authority_id) DO UPDATE SET
1669
- received_through = MAX(received_through, excluded.received_through)
1670
- `).run(authorityId, deliverySequence);
1671
- }
1672
- finally {
1673
- database.close();
1674
- }
1675
- }
1676
1565
  function hasRecordsBeyondSnapshot(file) {
1677
1566
  if (!existsSync(file))
1678
1567
  return false;
@@ -1715,14 +1604,22 @@ function commitDetectedState(file, input) {
1715
1604
  for (const uuid of input.materializedRemovals ?? [])
1716
1605
  removeMaterialized.run(uuid);
1717
1606
  const upsertMaterialized = prepareGroundUpsert(database, "entities");
1607
+ // A valid graph may move or re-root several UUIDs through one another's
1608
+ // old address slots. Release every changed UUID's old slot first, then
1609
+ // install the complete new projection in this same transaction; row
1610
+ // iteration order must never decide whether an atomic graph is valid.
1718
1611
  for (const row of input.acceptedMaterializedRows ?? []) {
1719
- upsertMaterialized.run(...groundRowValues(row));
1612
+ removeMaterialized.run(row.uuid);
1613
+ }
1614
+ for (const row of input.acceptedMaterializedRows ?? []) {
1615
+ runGroundUpsert(database, "entities", upsertMaterialized, row);
1720
1616
  }
1721
1617
  for (const uuid of input.notebookRemovals)
1722
1618
  remove.run(uuid);
1723
1619
  const upsertNotebook = prepareGroundUpsert(database, "detection_notebook");
1724
- for (const row of input.acceptedNotebookRows)
1725
- upsertNotebook.run(...groundRowValues(row));
1620
+ for (const row of input.acceptedNotebookRows) {
1621
+ runGroundUpsert(database, "detection_notebook", upsertNotebook, row);
1622
+ }
1726
1623
  })();
1727
1624
  }
1728
1625
  finally {
@@ -1850,6 +1747,19 @@ function prepareGroundUpsert(database, table) {
1850
1747
  content_verified_at_ms = excluded.content_verified_at_ms
1851
1748
  `);
1852
1749
  }
1750
+ function runGroundUpsert(database, table, statement, row) {
1751
+ try {
1752
+ statement.run(...groundRowValues(row));
1753
+ }
1754
+ catch (error) {
1755
+ const occupant = database.prepare(`
1756
+ SELECT uuid, absolute_path AS absolutePath FROM ${table}
1757
+ WHERE resource_id = ? AND root_uuid = ? AND relative_path = ?
1758
+ `).get(row.resourceId, row.rootUUID, row.relativePath);
1759
+ throw new Error(`${table} projection could not place ${row.uuid} at ${row.rootUUID}:${row.relativePath}; `
1760
+ + `occupied by ${occupant?.uuid ?? "no retained row"} (${occupant?.absolutePath ?? "unknown"})`, { cause: error });
1761
+ }
1762
+ }
1853
1763
  function persistRows(file, identity, resourceId, rootUUID, rows, previousRows = [], options = {}) {
1854
1764
  const database = initializeDatabase(file);
1855
1765
  try {
@@ -1864,6 +1774,7 @@ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows =
1864
1774
  const upsert = prepareGroundUpsert(database, "entities");
1865
1775
  const upsertNotebook = prepareGroundUpsert(database, "detection_notebook");
1866
1776
  const remove = database.prepare("DELETE FROM entities WHERE uuid = ? AND resource_id = ? AND root_uuid = ?");
1777
+ const releaseChangedSlot = database.prepare("DELETE FROM entities WHERE uuid = ?");
1867
1778
  const removeNotebook = database.prepare("DELETE FROM detection_notebook WHERE uuid = ? AND resource_id = ? AND root_uuid = ?");
1868
1779
  const previousByUuid = new Map(previousRows.map((row) => [row.uuid, row]));
1869
1780
  const materializedIds = new Set(database.prepare("SELECT uuid FROM entities").all()
@@ -1904,10 +1815,13 @@ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows =
1904
1815
  }
1905
1816
  }
1906
1817
  for (const row of changed)
1907
- upsert.run(...groundRowValues(capturedGroundRow(resourceId, rootUUID, row)));
1818
+ releaseChangedSlot.run(row.record.uuid);
1819
+ for (const row of changed) {
1820
+ runGroundUpsert(database, "entities", upsert, capturedGroundRow(resourceId, rootUUID, row));
1821
+ }
1908
1822
  if (options.updateNotebook !== false) {
1909
1823
  for (const row of notebookChanged) {
1910
- upsertNotebook.run(...groundRowValues(capturedGroundRow(resourceId, rootUUID, row)));
1824
+ runGroundUpsert(database, "detection_notebook", upsertNotebook, capturedGroundRow(resourceId, rootUUID, row));
1911
1825
  }
1912
1826
  }
1913
1827
  });
@@ -2320,6 +2234,11 @@ async function reconcileRoot(input) {
2320
2234
  const children = readdirSync(directory, { withFileTypes: true })
2321
2235
  .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
2322
2236
  for (const child of children) {
2237
+ // Every materialization boundary reserves its root .amalgm/ directory
2238
+ // for machine intents, staging, and recovery. This is structural host
2239
+ // state, never enrollment policy and never an entity.
2240
+ if (base === "" && child.name === ".amalgm")
2241
+ continue;
2323
2242
  if (isRepositoryMetadataEntry(parentType, child.name))
2324
2243
  continue;
2325
2244
  const relativePath = base ? `${base}/${child.name}` : child.name;
@@ -2535,7 +2454,7 @@ async function reconcileRoot(input) {
2535
2454
  .sort((left, right) => right.relativePath.length - left.relativePath.length)[0] ?? null;
2536
2455
  try {
2537
2456
  for (const repository of repositories) {
2538
- const repoIdentity = entries
2457
+ let repoIdentity = entries
2539
2458
  .filter((entry) => repositoryOwner(entry)?.uuid === repository.uuid)
2540
2459
  .map((entry) => ({
2541
2460
  path: repository.relativePath
@@ -2598,7 +2517,30 @@ async function reconcileRoot(input) {
2598
2517
  .sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0)
2599
2518
  : null;
2600
2519
  evidence && (evidence.repositoryCaptures += 1);
2601
- const captured = captureRepository(repository.absolutePath, repoIdentity, prior, previousIdentity);
2520
+ let captured = null;
2521
+ let coherent = false;
2522
+ for (let attempt = 0; attempt < 3; attempt += 1) {
2523
+ captured = captureRepository(repository.absolutePath, repoIdentity, prior, previousIdentity);
2524
+ const after = canonicalizeGitIdentity(repoIdentity, inspectGitRegistration(repository.absolutePath));
2525
+ if (sameGitIdentity(after, repoIdentity)) {
2526
+ coherent = true;
2527
+ break;
2528
+ }
2529
+ repoIdentity = after;
2530
+ }
2531
+ if (!coherent || !captured) {
2532
+ throw new Error(`repository did not settle during capture: ${repository.absolutePath}`);
2533
+ }
2534
+ for (const identityEntry of repoIdentity) {
2535
+ const entry = entries.find((candidate) => repositoryOwner(candidate)?.uuid === repository.uuid
2536
+ && (repository.relativePath
2537
+ ? candidate.relativePath.slice(repository.relativePath.length + 1)
2538
+ : candidate.relativePath) === identityEntry.path);
2539
+ if (!entry)
2540
+ continue;
2541
+ entry.type = identityEntry.type;
2542
+ entry.payloadVersion = identityEntry.payloadVersion ?? null;
2543
+ }
2602
2544
  repository.payloadVersion = captured.stateId;
2603
2545
  repository.transportVersion = captured.transportVersion;
2604
2546
  if (captured.bytes) {