@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.
- package/PURPOSE.md +48 -11
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/detection/runtime.d.ts +10 -2
- package/dist/detection/runtime.js +51 -5
- package/dist/detection/runtime.js.map +1 -1
- package/dist/entity-apply-host.d.ts +11 -2
- package/dist/entity-apply-host.js +25 -3
- package/dist/entity-apply-host.js.map +1 -1
- package/dist/entity-record-observability.d.ts +13 -0
- package/dist/entity-record-observability.js +143 -0
- package/dist/entity-record-observability.js.map +1 -0
- package/dist/files-observability.d.ts +26 -0
- package/dist/files-observability.js +11 -0
- package/dist/files-observability.js.map +1 -0
- package/dist/git-command.js +14 -2
- package/dist/git-command.js.map +1 -1
- package/dist/ground-coordination.d.ts +26 -0
- package/dist/ground-coordination.js +83 -0
- package/dist/ground-coordination.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js.map +1 -1
- package/dist/machine-host.d.ts +3 -0
- package/dist/machine-host.js +67 -5
- package/dist/machine-host.js.map +1 -1
- package/dist/user-ground-host.d.ts +10 -2
- package/dist/user-ground-host.js +679 -131
- package/dist/user-ground-host.js.map +1 -1
- package/dist/watching/host.d.ts +14 -0
- package/dist/watching/host.js +26 -0
- package/dist/watching/host.js.map +1 -1
- package/dist/watching/index.d.ts +1 -1
- package/package.json +1 -1
package/dist/user-ground-host.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
178
|
-
|
|
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.
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
|
|
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
|
-
|
|
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.
|
|
498
|
-
onStage: (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
|
-
//
|
|
516
|
-
//
|
|
517
|
-
//
|
|
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.
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
989
|
-
|
|
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
|
-
|
|
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
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
|
1049
|
-
// reconciliation; it must never poison a Watch
|
|
1050
|
-
// rejection that only another
|
|
1051
|
-
const named = await this.namedDetect.detect(
|
|
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
|
-
|
|
1055
|
-
|
|
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
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
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
|
-
|
|
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.
|
|
1089
|
-
suspicions:
|
|
1090
|
-
|
|
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(
|
|
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
|
|
2878
|
+
await yieldEventLoop();
|
|
2347
2879
|
return result;
|
|
2348
2880
|
};
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
:
|
|
2363
|
-
|
|
2364
|
-
|
|
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))
|