@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.
- package/PURPOSE.md +65 -9
- package/dist/detection/runtime.js +83 -6
- package/dist/detection/runtime.js.map +1 -1
- package/dist/entity-apply-host.d.ts +28 -0
- package/dist/entity-apply-host.js +670 -0
- package/dist/entity-apply-host.js.map +1 -0
- package/dist/entity-apply-store.d.ts +42 -0
- package/dist/entity-apply-store.js +367 -0
- package/dist/entity-apply-store.js.map +1 -0
- package/dist/entity-record-store.d.ts +17 -0
- package/dist/entity-record-store.js +144 -0
- package/dist/entity-record-store.js.map +1 -0
- package/dist/git-registration-host.d.ts +8 -1
- package/dist/git-registration-host.js +21 -0
- package/dist/git-registration-host.js.map +1 -1
- package/dist/git-repository-host.js +16 -4
- package/dist/git-repository-host.js.map +1 -1
- package/dist/materialized-graph.js +27 -4
- package/dist/materialized-graph.js.map +1 -1
- package/dist/user-ground-host.d.ts +9 -8
- package/dist/user-ground-host.js +305 -363
- package/dist/user-ground-host.js.map +1 -1
- package/dist/wire-client.d.ts +6 -0
- package/dist/wire-client.js +19 -2
- package/dist/wire-client.js.map +1 -1
- package/package.json +2 -2
package/dist/user-ground-host.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
stage
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
209
|
+
const release = await this.acquireGroundEffect();
|
|
196
210
|
const visibleAcrossBlindInterval = pathExists(intent.destinationPath);
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
265
|
-
&&
|
|
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
|
-
|
|
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.
|
|
324
|
-
throw stageError("entity registration", new Error(`workspace ${workspaceId}
|
|
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
|
|
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.
|
|
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
|
-
|
|
387
|
-
if (this.
|
|
388
|
-
|
|
389
|
-
this.
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
})
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|