amalgm 0.1.263 → 0.1.265
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/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/registration/index.js +313 -7
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +18 -3
- package/runtime/scripts/amalgm-mcp/registration/service.js +33 -5
- package/runtime/scripts/amalgm-mcp/registration/tree.js +67 -2
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +16 -14
- package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +23 -1
- package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +7 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +80 -0
package/package.json
CHANGED
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
* (its own future head and rail), never an identity boundary. Every
|
|
16
16
|
* top-level enrolled ground — a workspace, a repository, a single file,
|
|
17
17
|
* or an explicitly added link — is such a tree, with one evidence LENS
|
|
18
|
-
* over its observer root
|
|
19
|
-
* tree
|
|
18
|
+
* over its observer root. A Git territory noticed inside another watched
|
|
19
|
+
* tree is observer-only until an explicit registration admits it; that
|
|
20
|
+
* selected repo is then a tree of its own even if the outer watcher covers
|
|
21
|
+
* its local address. A
|
|
20
22
|
* TREE'S IDENTITY IS ITS UUID, PERMANENT; its root path is only the
|
|
21
23
|
* current local address — lenses are keyed by the observer's rootId
|
|
22
24
|
* (the witness), the observer rescues moved ground by physical
|
|
@@ -65,7 +67,8 @@ const { capture } = require('../repocard');
|
|
|
65
67
|
const { refusal } = require('./refusal');
|
|
66
68
|
const { createRepoFollowers } = require('./repo-followers');
|
|
67
69
|
const { createRepoStates } = require('./repo-states');
|
|
68
|
-
const { registerTree } = require('./tree');
|
|
70
|
+
const { collectWorktreeIdentityAsync, registerTree, transportIdentity } = require('./tree');
|
|
71
|
+
const { gitDirTruth } = require('../observer/scan');
|
|
69
72
|
|
|
70
73
|
// Events that can change what the recorded edges reach: a new or
|
|
71
74
|
// retargeted pointer, repo ground moving under its card (a worktree
|
|
@@ -85,6 +88,10 @@ function createRegistration(options) {
|
|
|
85
88
|
|
|
86
89
|
const opened = openIdentity();
|
|
87
90
|
const captureRepo = opened.captureRepo || ((repoPath) => capture(repoPath, { withBundle: false }));
|
|
91
|
+
const captureRepoAsync = opened.captureRepoAsync ?? null;
|
|
92
|
+
if (captureRepoAsync !== null && typeof captureRepoAsync !== 'function') {
|
|
93
|
+
throw new Error('openIdentity.captureRepoAsync must be a function when supplied');
|
|
94
|
+
}
|
|
88
95
|
const repoStateAt = opened.repoStateAt ?? null;
|
|
89
96
|
if (repoStateAt !== null && typeof repoStateAt !== 'function') {
|
|
90
97
|
throw new Error('openIdentity.repoStateAt must be a function when supplied');
|
|
@@ -108,16 +115,32 @@ function createRegistration(options) {
|
|
|
108
115
|
registry,
|
|
109
116
|
repoStates,
|
|
110
117
|
prepareRepo: opened.prepareRepo ?? null,
|
|
118
|
+
captureRepoAsync,
|
|
111
119
|
recordForRoot: repoRecordForRoot,
|
|
112
120
|
onSettled: reconcileSettledRepo,
|
|
113
121
|
});
|
|
114
122
|
|
|
115
|
-
/**
|
|
116
|
-
*
|
|
123
|
+
/**
|
|
124
|
+
* A semantic tree is defined by its parentless identity record, never by
|
|
125
|
+
* directory containment. `amalgm add` may materialize a cloud repo inside
|
|
126
|
+
* the local `workspaces/` directory, and `amalgm register` may select a
|
|
127
|
+
* repo already sitting there: both remain their own cloud root. The outer
|
|
128
|
+
* workspace watcher supplies coverage, but it does not own the repo's
|
|
129
|
+
* Card + Checkpoint or identity tree.
|
|
130
|
+
*
|
|
131
|
+
* An unbound observer root is a tree candidate only when no other observer
|
|
132
|
+
* root physically encloses it. That preserves the admission rule for a
|
|
133
|
+
* newly discovered nested repo: Watch can notice it, but only explicit
|
|
134
|
+
* registration can give it a cloud identity and state.
|
|
135
|
+
*/
|
|
117
136
|
function treeRoots() {
|
|
118
137
|
const roots = observer.listRoots();
|
|
119
|
-
return roots.filter((root) =>
|
|
120
|
-
|
|
138
|
+
return roots.filter((root) => {
|
|
139
|
+
const record = rootRecordOf(root.rootId);
|
|
140
|
+
if (record) return record.parentUUID === null;
|
|
141
|
+
return !roots.some((other) => other.rootId !== root.rootId
|
|
142
|
+
&& root.path.startsWith(`${other.path}/`));
|
|
143
|
+
});
|
|
121
144
|
}
|
|
122
145
|
|
|
123
146
|
function treeRootCovering(absPath) {
|
|
@@ -413,6 +436,239 @@ function createRegistration(options) {
|
|
|
413
436
|
}
|
|
414
437
|
}
|
|
415
438
|
|
|
439
|
+
/**
|
|
440
|
+
* A repository selected inside already-watched ground is still a new
|
|
441
|
+
* registered TREE, not a child record of the outer workspace. The outer
|
|
442
|
+
* watcher already knows its territory (or this enrollment establishes that
|
|
443
|
+
* same territory), so the only missing fact is the repo's atomic
|
|
444
|
+
* Card+Checkpoint-backed identity birth.
|
|
445
|
+
*/
|
|
446
|
+
function registerRepositoryInsideCoveredGround(ground, {
|
|
447
|
+
identityAt = null,
|
|
448
|
+
suppressJournal = false,
|
|
449
|
+
preparedRepoAt = null,
|
|
450
|
+
watcherAlreadyHeld = false,
|
|
451
|
+
} = {}) {
|
|
452
|
+
let root = observer.listRoots().find((candidate) => candidate.path === ground) || null;
|
|
453
|
+
const rootAlreadyExisted = root !== null;
|
|
454
|
+
if (!root) root = observer.enrollRoot(ground, { scan: false, watch: false });
|
|
455
|
+
if (root.kind !== 'repo') {
|
|
456
|
+
throw new Error(`expected Git repository ground at ${ground}, found ${root.kind}`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const bound = memory.boundTo(root.rootId);
|
|
460
|
+
if (bound !== undefined) {
|
|
461
|
+
const record = registry.record(bound);
|
|
462
|
+
if (record?.type !== 'repo.git') {
|
|
463
|
+
throw new Error(`repository watcher ${ground} is bound to non-repository entity ${bound}`);
|
|
464
|
+
}
|
|
465
|
+
if (record.parentUUID !== null) {
|
|
466
|
+
// This is the explicit nested-boundary case created as part of an
|
|
467
|
+
// already-registered enclosing repo. It already has a complete
|
|
468
|
+
// Card+Checkpoint state and cannot be duplicated at one address.
|
|
469
|
+
requireLiveWatchers(ground);
|
|
470
|
+
return { rootPath: ground, record };
|
|
471
|
+
}
|
|
472
|
+
requireLiveWatchers(ground);
|
|
473
|
+
return { rootPath: ground, record };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
let watcherHeld = watcherAlreadyHeld;
|
|
477
|
+
try {
|
|
478
|
+
if (!watcherHeld) {
|
|
479
|
+
requireWatchBirth(root);
|
|
480
|
+
watcherHeld = true;
|
|
481
|
+
}
|
|
482
|
+
assertWitnessed({ observer, registry, memory });
|
|
483
|
+
const register = () => registry.transaction(() => {
|
|
484
|
+
registerTree({
|
|
485
|
+
root,
|
|
486
|
+
registry,
|
|
487
|
+
memory,
|
|
488
|
+
shouldEnroll: observer.shouldEnroll,
|
|
489
|
+
captureRepo,
|
|
490
|
+
repoStates,
|
|
491
|
+
repoStateAt,
|
|
492
|
+
identityAt,
|
|
493
|
+
preparedRepoAt,
|
|
494
|
+
});
|
|
495
|
+
assertWatchedThroughCommit(root.path);
|
|
496
|
+
});
|
|
497
|
+
if (suppressJournal) registry.withoutJournal(register);
|
|
498
|
+
else register();
|
|
499
|
+
} catch (error) {
|
|
500
|
+
// A root that existed only because the outer watcher discovered its
|
|
501
|
+
// territory must remain coverage after a refused explicit admission;
|
|
502
|
+
// do not remove it (the parent still owns the OS watcher). Release a
|
|
503
|
+
// held doorbell before returning the refusal; otherwise a failed
|
|
504
|
+
// registration would silently turn a healthy observer territory into
|
|
505
|
+
// a permanently mute one. A root this call itself enrolled has no
|
|
506
|
+
// durable identity and can safely retire in the outer caller.
|
|
507
|
+
if (watcherHeld && rootAlreadyExisted) observer.start(root.rootId);
|
|
508
|
+
throw error;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const startWatch = () => {
|
|
512
|
+
observer.start(root.rootId);
|
|
513
|
+
ensureTrees();
|
|
514
|
+
reconcileInitialRepoWorktrees(root.path);
|
|
515
|
+
flushIfDirty();
|
|
516
|
+
requireLiveWatchers(root.path);
|
|
517
|
+
};
|
|
518
|
+
if (suppressJournal) registry.withoutJournal(startWatch);
|
|
519
|
+
else startWatch();
|
|
520
|
+
return { rootPath: root.path, record: rootRecordOf(root.rootId) };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Plan all repository cargo under one selected root before its identity
|
|
525
|
+
* transaction begins. This work can be slow (a Card contains a Git bundle
|
|
526
|
+
* and its Checkpoint may contain real work), but it creates no registry
|
|
527
|
+
* fact. Its watcher is already held by the caller, so every ding remains
|
|
528
|
+
* available to Start's catch-up once the plan commits.
|
|
529
|
+
*
|
|
530
|
+
* Every nested repo has its own identity map and own state parcel. The
|
|
531
|
+
* outer map names the nested repo boundary; the nested parcel names its
|
|
532
|
+
* children. That is the same travel law used later by Send, merely used
|
|
533
|
+
* here for the first state.
|
|
534
|
+
*/
|
|
535
|
+
async function prepareRepositoryPlans(root, identityAt) {
|
|
536
|
+
if (typeof captureRepoAsync !== 'function') return null;
|
|
537
|
+
const plans = new Map(); // absolute repo path -> { state, assignment }
|
|
538
|
+
|
|
539
|
+
const assertState = (repoPath, state) => {
|
|
540
|
+
if (state?.busy) {
|
|
541
|
+
throw new Error(`cannot register ${repoPath}: Git is unsettled (${state.busy})`);
|
|
542
|
+
}
|
|
543
|
+
if (!state?.stateId || !state.cardId || !state.checkpointId) {
|
|
544
|
+
throw new Error(`cannot register ${repoPath}: Git did not produce a Card + Checkpoint state`);
|
|
545
|
+
}
|
|
546
|
+
return state;
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
const planRepo = async (repoPath, rootRelativePath) => {
|
|
550
|
+
const assignment = await collectWorktreeIdentityAsync(repoPath, {
|
|
551
|
+
shouldEnroll: observer.shouldEnroll,
|
|
552
|
+
identityFor: (repoRelativePath) => identityAt?.(
|
|
553
|
+
rootRelativePath ? `${rootRelativePath}/${repoRelativePath}` : repoRelativePath,
|
|
554
|
+
)?.uuid || null,
|
|
555
|
+
});
|
|
556
|
+
const state = assertState(repoPath, await captureRepoAsync(repoPath, {
|
|
557
|
+
identity: transportIdentity(assignment),
|
|
558
|
+
}));
|
|
559
|
+
plans.set(repoPath, { state, assignment });
|
|
560
|
+
for (const [entryRelativePath, entry] of assignment) {
|
|
561
|
+
if (entry.type !== 'repo.git') continue;
|
|
562
|
+
await planRepo(
|
|
563
|
+
path.join(repoPath, entryRelativePath),
|
|
564
|
+
rootRelativePath ? `${rootRelativePath}/${entryRelativePath}` : entryRelativePath,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
// A direct repo is its own initial state. An ordinary folder can still
|
|
570
|
+
// contain repo boundaries, and those must never fall back to a blocking
|
|
571
|
+
// main-thread capture just because the user selected their parent.
|
|
572
|
+
if (root.kind === 'repo') {
|
|
573
|
+
await planRepo(root.path, '');
|
|
574
|
+
return (repoPath) => plans.get(repoPath) || null;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
let visited = 0;
|
|
578
|
+
const yieldIfNeeded = async () => {
|
|
579
|
+
visited += 1;
|
|
580
|
+
if (visited % 256 !== 0) return;
|
|
581
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
582
|
+
};
|
|
583
|
+
const discover = async (dirPath, relativePath) => {
|
|
584
|
+
let entries;
|
|
585
|
+
try {
|
|
586
|
+
entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
|
587
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
588
|
+
} catch (error) {
|
|
589
|
+
if (error.code === 'EACCES' || error.code === 'EPERM') return;
|
|
590
|
+
throw error;
|
|
591
|
+
}
|
|
592
|
+
for (const entry of entries) {
|
|
593
|
+
await yieldIfNeeded();
|
|
594
|
+
const entryPath = path.join(dirPath, entry.name);
|
|
595
|
+
const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
596
|
+
if (!observer.shouldEnroll(entryPath)) continue;
|
|
597
|
+
let stat;
|
|
598
|
+
try {
|
|
599
|
+
stat = fs.lstatSync(entryPath);
|
|
600
|
+
} catch {
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
if (!stat.isDirectory()) continue;
|
|
604
|
+
if (gitDirTruth(entryPath) === 'present') await planRepo(entryPath, entryRelativePath);
|
|
605
|
+
else await discover(entryPath, entryRelativePath);
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
await discover(root.path, '');
|
|
609
|
+
return (repoPath) => plans.get(repoPath) || null;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* The public operation remains synchronous in its result: it returns only
|
|
614
|
+
* after the watcher, complete identity tree, state parcel, and journal all
|
|
615
|
+
* agree. Its preparation is asynchronous solely so large immutable cargo
|
|
616
|
+
* cannot freeze the HTTP runtime that owns the watcher proof.
|
|
617
|
+
*/
|
|
618
|
+
async function addAsync(targetPath, options = {}) {
|
|
619
|
+
const identityAt = options.identityAt ?? null;
|
|
620
|
+
const suppressJournal = options.suppressJournal === true;
|
|
621
|
+
if (identityAt !== null && typeof identityAt !== 'function') {
|
|
622
|
+
throw new Error('addAsync identityAt must be a function when supplied');
|
|
623
|
+
}
|
|
624
|
+
if (typeof captureRepoAsync !== 'function') return add(targetPath, options);
|
|
625
|
+
|
|
626
|
+
let address;
|
|
627
|
+
let ground;
|
|
628
|
+
try {
|
|
629
|
+
const parent = fs.realpathSync(path.dirname(targetPath));
|
|
630
|
+
address = path.join(parent, path.basename(targetPath));
|
|
631
|
+
ground = fs.lstatSync(address).isSymbolicLink() ? address : fs.realpathSync(address);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
|
|
634
|
+
throw refusal(`refusing to add ${targetPath}: there is no ground at that address`);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Links intentionally retain link semantics even if their target is a
|
|
638
|
+
// repo. That is the same address law as `add`, not a hidden realpath.
|
|
639
|
+
const rootKind = fs.lstatSync(ground).isDirectory() && gitDirTruth(ground) === 'present'
|
|
640
|
+
? 'repo'
|
|
641
|
+
: null;
|
|
642
|
+
if (rootKind !== 'repo') return add(targetPath, options);
|
|
643
|
+
|
|
644
|
+
let root = observer.listRoots().find((candidate) => candidate.path === ground) || null;
|
|
645
|
+
const rootAlreadyExisted = root !== null;
|
|
646
|
+
if (!root) root = observer.enrollRoot(ground, { scan: false, watch: false });
|
|
647
|
+
if (root.kind !== 'repo') return add(targetPath, options);
|
|
648
|
+
const bound = memory.boundTo(root.rootId);
|
|
649
|
+
if (bound !== undefined) return add(targetPath, options);
|
|
650
|
+
|
|
651
|
+
let watcherHeld = false;
|
|
652
|
+
try {
|
|
653
|
+
requireWatchBirth(root);
|
|
654
|
+
watcherHeld = true;
|
|
655
|
+
const preparedRepoAt = await prepareRepositoryPlans(root, identityAt);
|
|
656
|
+
return registerRepositoryInsideCoveredGround(ground, {
|
|
657
|
+
identityAt,
|
|
658
|
+
suppressJournal,
|
|
659
|
+
preparedRepoAt,
|
|
660
|
+
watcherAlreadyHeld: true,
|
|
661
|
+
});
|
|
662
|
+
} catch (error) {
|
|
663
|
+
// No identity was committed before preparation. A new root is only
|
|
664
|
+
// this failed request's temporary witness; an existing discovered root
|
|
665
|
+
// stays covered, but its held doorbell must be released.
|
|
666
|
+
if (!rootAlreadyExisted) observer.removeRoot(root.rootId);
|
|
667
|
+
else if (watcherHeld) observer.start(root.rootId);
|
|
668
|
+
throw error;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
416
672
|
/** Real ground -> the UUID of the entity that owns it, across trees. */
|
|
417
673
|
function resolveGround(real) {
|
|
418
674
|
const home = treeRootCovering(real);
|
|
@@ -542,6 +798,23 @@ function createRegistration(options) {
|
|
|
542
798
|
}
|
|
543
799
|
const covering = treeRootCovering(ground);
|
|
544
800
|
if (covering) {
|
|
801
|
+
// A repository has a different state adapter from the workspace that
|
|
802
|
+
// happens to contain its local address. Its observer territory may
|
|
803
|
+
// already exist, but it is not registered until this explicit command
|
|
804
|
+
// creates the parentless repo tree with its initial Card + Checkpoint.
|
|
805
|
+
// The same branch is used by cloud `add`: materialized repos commonly
|
|
806
|
+
// land under the default `workspaces/` directory while retaining their
|
|
807
|
+
// cloud root UUID and parentless graph position.
|
|
808
|
+
const isRepository = fs.lstatSync(ground).isDirectory() && gitDirTruth(ground) === 'present';
|
|
809
|
+
if (isRepository) {
|
|
810
|
+
const exact = observer.listRoots().find((root) => root.path === ground);
|
|
811
|
+
const known = exact && memory.boundTo(exact.rootId) !== undefined
|
|
812
|
+
? registry.record(memory.boundTo(exact.rootId))
|
|
813
|
+
: null;
|
|
814
|
+
if (!known || known.type !== 'repo.git' || known.parentUUID === null) {
|
|
815
|
+
return registerRepositoryInsideCoveredGround(ground, { identityAt, suppressJournal });
|
|
816
|
+
}
|
|
817
|
+
}
|
|
545
818
|
// This address is already registered ground. Its prior Register →
|
|
546
819
|
// Watch transition remains the proof of its identity; repeating a
|
|
547
820
|
// harmless "ensure the safe home exists" request must not rescan every
|
|
@@ -642,6 +915,30 @@ function createRegistration(options) {
|
|
|
642
915
|
return normalized.map(({ targetPath, identityAt }) => add(targetPath, { identityAt }));
|
|
643
916
|
}
|
|
644
917
|
|
|
918
|
+
/** Same declared batch contract as `addMany`, while allowing a large Git
|
|
919
|
+
* cargo plan to run off the runtime event loop. Entries remain ordered and
|
|
920
|
+
* individually durable: this adds no second transaction model. */
|
|
921
|
+
async function addManyAsync(entries, { suppressJournal = false } = {}) {
|
|
922
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
923
|
+
throw new Error('addManyAsync requires one or more target paths');
|
|
924
|
+
}
|
|
925
|
+
const normalized = entries.map((entry) => {
|
|
926
|
+
if (typeof entry === 'string') return { targetPath: entry, identityAt: null };
|
|
927
|
+
if (!entry || typeof entry !== 'object' || typeof entry.targetPath !== 'string') {
|
|
928
|
+
throw new Error('addManyAsync entries must be paths or { targetPath, identityAt }');
|
|
929
|
+
}
|
|
930
|
+
if (entry.identityAt !== undefined && entry.identityAt !== null && typeof entry.identityAt !== 'function') {
|
|
931
|
+
throw new Error('addManyAsync identityAt must be a function when supplied');
|
|
932
|
+
}
|
|
933
|
+
return { targetPath: entry.targetPath, identityAt: entry.identityAt ?? null };
|
|
934
|
+
});
|
|
935
|
+
const results = [];
|
|
936
|
+
for (const { targetPath, identityAt } of normalized) {
|
|
937
|
+
results.push(await addAsync(targetPath, { identityAt, suppressJournal }));
|
|
938
|
+
}
|
|
939
|
+
return results;
|
|
940
|
+
}
|
|
941
|
+
|
|
645
942
|
/** Reconcile one already-watched tree after the platform has materialized
|
|
646
943
|
* a newly declared exact default inside it. This is deliberately not part
|
|
647
944
|
* of ordinary re-registration: the caller must have already named the
|
|
@@ -679,6 +976,13 @@ function createRegistration(options) {
|
|
|
679
976
|
return clean;
|
|
680
977
|
}
|
|
681
978
|
|
|
979
|
+
// Reopen every persisted observer before the first reconciliation. An
|
|
980
|
+
// identity database survives a runtime restart; its Watch proof must too.
|
|
981
|
+
// `start()` opens physical owners and restores covered roots through the
|
|
982
|
+
// same coverage law as a new registration, then its census catches up on
|
|
983
|
+
// dings that occurred while this process was absent.
|
|
984
|
+
observer.start();
|
|
985
|
+
|
|
682
986
|
// Startup is the same act as running — the one convergence pass,
|
|
683
987
|
// nothing less. The projection must not wait for an event: a
|
|
684
988
|
// rendering whose deletion was detected before a crash is equal
|
|
@@ -691,7 +995,9 @@ function createRegistration(options) {
|
|
|
691
995
|
// observer bindings remain private to this registration composition.
|
|
692
996
|
registry,
|
|
693
997
|
add,
|
|
998
|
+
addAsync,
|
|
694
999
|
addMany,
|
|
1000
|
+
addManyAsync,
|
|
695
1001
|
reconcileDeclared,
|
|
696
1002
|
|
|
697
1003
|
/**
|
|
@@ -25,12 +25,23 @@
|
|
|
25
25
|
|
|
26
26
|
const { createFollower, createCaptureWorker } = require('../repocard');
|
|
27
27
|
|
|
28
|
-
function createRepoFollowers({
|
|
28
|
+
function createRepoFollowers({
|
|
29
|
+
observer,
|
|
30
|
+
registry,
|
|
31
|
+
repoStates,
|
|
32
|
+
recordForRoot,
|
|
33
|
+
onSettled,
|
|
34
|
+
prepareRepo = null,
|
|
35
|
+
captureRepoAsync = null,
|
|
36
|
+
}) {
|
|
29
37
|
if (!observer || !registry || !repoStates || typeof recordForRoot !== 'function'
|
|
30
38
|
|| typeof onSettled !== 'function') {
|
|
31
39
|
throw new Error('createRepoFollowers requires { observer, registry, repoStates, recordForRoot, onSettled }');
|
|
32
40
|
}
|
|
33
41
|
|
|
42
|
+
// Production cloud-cargo captures are already complete in the service's
|
|
43
|
+
// dedicated worker (bundle -> transport -> immutable content head). The
|
|
44
|
+
// local/test fallback owns its lightweight capture worker here.
|
|
34
45
|
const worker = createCaptureWorker();
|
|
35
46
|
const followers = new Map(); // observer rootId -> { path, uuid, follower }
|
|
36
47
|
let stopped = false;
|
|
@@ -102,13 +113,17 @@ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, on
|
|
|
102
113
|
// cargo captures (withBundle) need content and read everything;
|
|
103
114
|
// capture() enforces that side of the contract itself.
|
|
104
115
|
const previous = withBundle ? null : repoStates.get(record.uuid);
|
|
105
|
-
const
|
|
116
|
+
const options = {
|
|
106
117
|
withBundle,
|
|
107
118
|
suspects,
|
|
108
119
|
previousUntracked: previous?.checkpoint?.untracked?.map(
|
|
109
120
|
({ path: rel, mode, sha256 }) => ({ path: rel, mode, sha256 }),
|
|
110
121
|
) ?? null,
|
|
111
|
-
}
|
|
122
|
+
};
|
|
123
|
+
if (withBundle && captureRepoAsync) {
|
|
124
|
+
return captureRepoAsync(repoPath, { entityUuid: record.uuid, ...options });
|
|
125
|
+
}
|
|
126
|
+
const captured = await worker.capture(repoPath, options);
|
|
112
127
|
return withBundle && !captured?.busy
|
|
113
128
|
? prepareRepo(captured, { entityUuid: record.uuid })
|
|
114
129
|
: captured;
|
|
@@ -58,7 +58,7 @@ const { classifyFile } = require('./classify');
|
|
|
58
58
|
const { createEntityCloud } = require('./entity-cloud');
|
|
59
59
|
const { createEntityContent } = require('./entity-content');
|
|
60
60
|
const { createRegistration } = require('./index');
|
|
61
|
-
const { capture, apply: applyRepo } = require('../repocard');
|
|
61
|
+
const { capture, apply: applyRepo, createCaptureWorker } = require('../repocard');
|
|
62
62
|
const { transportBytes, localState, unpack } = require('./repo-states');
|
|
63
63
|
const { machineId, userId } = require('../workspace/identity');
|
|
64
64
|
|
|
@@ -99,6 +99,11 @@ function createRegistrationService({
|
|
|
99
99
|
handle.pragma('synchronous = NORMAL');
|
|
100
100
|
}
|
|
101
101
|
const entityContent = createEntityContent({ dir });
|
|
102
|
+
// Git bundles and their encoded immutable transports can be hundreds of
|
|
103
|
+
// megabytes. Build and chunk them off the runtime thread so its local
|
|
104
|
+
// health endpoint and already-live watchers stay responsive throughout a
|
|
105
|
+
// synchronous Register operation.
|
|
106
|
+
const cargoCaptureWorker = createCaptureWorker();
|
|
102
107
|
// createRegistration owns the schema migration. Give entity-cloud a stable
|
|
103
108
|
// lookup facade now, then connect it to that one migrated state store once
|
|
104
109
|
// the boundary exists; never open a second registry just to read Git state.
|
|
@@ -142,6 +147,27 @@ function createRegistrationService({
|
|
|
142
147
|
return localState(captured, artifact.contentHash);
|
|
143
148
|
}
|
|
144
149
|
|
|
150
|
+
async function captureRepoAsync(repoPath, {
|
|
151
|
+
identity = null,
|
|
152
|
+
entityUuid = null,
|
|
153
|
+
suspects = null,
|
|
154
|
+
previousUntracked = null,
|
|
155
|
+
} = {}) {
|
|
156
|
+
const transportIdentity = identity ?? (entityUuid ? worktreeIdentity(entityUuid) : null);
|
|
157
|
+
const captured = await cargoCaptureWorker.capture(repoPath, {
|
|
158
|
+
withBundle: true,
|
|
159
|
+
suspects,
|
|
160
|
+
previousUntracked,
|
|
161
|
+
cargo: { dir, identity: transportIdentity },
|
|
162
|
+
});
|
|
163
|
+
if (captured?.busy) return captured;
|
|
164
|
+
if (!captured?.stateId || !captured?.transportVersion) {
|
|
165
|
+
throw new Error(`repo cargo capture did not produce a state and immutable transport: ${repoPath}`);
|
|
166
|
+
}
|
|
167
|
+
repoTransportHeads.set(captured.stateId, captured.transportVersion);
|
|
168
|
+
return captured;
|
|
169
|
+
}
|
|
170
|
+
|
|
145
171
|
// Entity-cloud needs only logical ancestry to choose a content plan. It
|
|
146
172
|
// never owns a watcher or address; this facade is available after the
|
|
147
173
|
// registration boundary is constructed below.
|
|
@@ -174,6 +200,7 @@ function createRegistrationService({
|
|
|
174
200
|
sentinelPath: path.join(dir, 'identity.epoch'),
|
|
175
201
|
journal: entityCloud.journal,
|
|
176
202
|
captureRepo: (repoPath, options) => prepareRepo(capture(repoPath, { withBundle: true }), options),
|
|
203
|
+
captureRepoAsync,
|
|
177
204
|
prepareRepo,
|
|
178
205
|
repoStateAt: (uuid) => importedRepoStates.get(uuid) || null,
|
|
179
206
|
}),
|
|
@@ -972,9 +999,9 @@ function createRegistrationService({
|
|
|
972
999
|
// signed-out registration is allowed.
|
|
973
1000
|
const cloudActive = entityCloud.replica()?.state === 'active';
|
|
974
1001
|
if (!cloudActive || estimatedRegistrationRecords(targetPath) < ATOMIC_REGISTRATION_THRESHOLD) {
|
|
975
|
-
return boundary.current.
|
|
1002
|
+
return (await boundary.current.addManyAsync([targetPath]))[0];
|
|
976
1003
|
}
|
|
977
|
-
const result =
|
|
1004
|
+
const result = (await boundary.current.addManyAsync([targetPath], { suppressJournal: true }))[0];
|
|
978
1005
|
entityCloud.replaceSnapshot(boundary.current.registry.syncRecords());
|
|
979
1006
|
return result;
|
|
980
1007
|
},
|
|
@@ -988,13 +1015,13 @@ function createRegistrationService({
|
|
|
988
1015
|
&& targetPaths.some((targetPath) => (
|
|
989
1016
|
estimatedRegistrationRecords(targetPath) >= ATOMIC_REGISTRATION_THRESHOLD
|
|
990
1017
|
));
|
|
991
|
-
if (!needsAtomicReplacement) return boundary.current.
|
|
1018
|
+
if (!needsAtomicReplacement) return boundary.current.addManyAsync(targetPaths);
|
|
992
1019
|
// The snapshot must describe whatever actually committed, even when a
|
|
993
1020
|
// later path refuses (each path is durable alone): without journal
|
|
994
1021
|
// rows, a committed-but-unsnapshotted registration would be local
|
|
995
1022
|
// truth with zero cloud delivery intent — a ghost nothing repairs.
|
|
996
1023
|
try {
|
|
997
|
-
return boundary.current.
|
|
1024
|
+
return await boundary.current.addManyAsync(targetPaths, { suppressJournal: true });
|
|
998
1025
|
} finally {
|
|
999
1026
|
entityCloud.replaceSnapshot(boundary.current.registry.syncRecords());
|
|
1000
1027
|
}
|
|
@@ -1049,6 +1076,7 @@ function createRegistrationService({
|
|
|
1049
1076
|
closed = true;
|
|
1050
1077
|
observer.stop(); // doorbells and their timers
|
|
1051
1078
|
boundary.current.stop(); // and the pass they may already have scheduled
|
|
1079
|
+
void cargoCaptureWorker.close();
|
|
1052
1080
|
evidenceDb.close();
|
|
1053
1081
|
identityDb.close();
|
|
1054
1082
|
},
|
|
@@ -91,6 +91,56 @@ function collectWorktreeIdentity(repoPath, { shouldEnroll, identityFor }) {
|
|
|
91
91
|
return assignment;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The asynchronous twin of the identity walk. It has exactly the same
|
|
96
|
+
* shape rules as `collectWorktreeIdentity`, but returns to Node between
|
|
97
|
+
* bounded batches. A large initial repo registration may take time to
|
|
98
|
+
* inspect, yet its held watcher and the runtime's health endpoint must stay
|
|
99
|
+
* alive while that inspection is in flight. This is planning only: no
|
|
100
|
+
* identity row exists until `registerTree` commits the finished plan.
|
|
101
|
+
*/
|
|
102
|
+
async function collectWorktreeIdentityAsync(repoPath, { shouldEnroll, identityFor, yieldEvery = 256 }) {
|
|
103
|
+
const assignment = new Map(); // repo-relative path -> { uuid, type }
|
|
104
|
+
let visited = 0;
|
|
105
|
+
const yieldIfNeeded = async () => {
|
|
106
|
+
visited += 1;
|
|
107
|
+
if (visited % yieldEvery !== 0) return;
|
|
108
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
109
|
+
};
|
|
110
|
+
const walk = async (dirPath, relativePath) => {
|
|
111
|
+
let entries;
|
|
112
|
+
try {
|
|
113
|
+
entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
|
114
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error.code === 'EACCES' || error.code === 'EPERM') return;
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
await yieldIfNeeded();
|
|
121
|
+
const entryPath = path.join(dirPath, entry.name);
|
|
122
|
+
const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
123
|
+
if (!shouldEnroll(entryPath)) continue;
|
|
124
|
+
let stat;
|
|
125
|
+
try {
|
|
126
|
+
stat = fs.lstatSync(entryPath);
|
|
127
|
+
} catch {
|
|
128
|
+
continue; // raced away between readdir and look; the create-walk decides
|
|
129
|
+
}
|
|
130
|
+
let type;
|
|
131
|
+
if (stat.isSymbolicLink()) type = 'link';
|
|
132
|
+
else if (stat.isDirectory()) type = isRepository(entryPath) ? 'repo.git' : 'folder';
|
|
133
|
+
else if (stat.isFile()) type = 'file.text';
|
|
134
|
+
else continue;
|
|
135
|
+
const uuid = identityFor?.(entryRelativePath) || crypto.randomUUID();
|
|
136
|
+
assignment.set(entryRelativePath, { uuid, type });
|
|
137
|
+
if (type === 'folder') await walk(entryPath, entryRelativePath);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
await walk(repoPath, '');
|
|
141
|
+
return assignment;
|
|
142
|
+
}
|
|
143
|
+
|
|
94
144
|
function transportIdentity(assignment) {
|
|
95
145
|
return [...assignment].map(([entryPath, { uuid, type }]) => ({ path: entryPath, uuid, type }));
|
|
96
146
|
}
|
|
@@ -100,7 +150,17 @@ function transportIdentity(assignment) {
|
|
|
100
150
|
* directory ground. The caller supplies a root that is already known to the
|
|
101
151
|
* observer but is not yet watched or scanned.
|
|
102
152
|
*/
|
|
103
|
-
function registerTree({
|
|
153
|
+
function registerTree({
|
|
154
|
+
root,
|
|
155
|
+
registry,
|
|
156
|
+
memory,
|
|
157
|
+
shouldEnroll,
|
|
158
|
+
captureRepo,
|
|
159
|
+
repoStates,
|
|
160
|
+
identityAt = null,
|
|
161
|
+
repoStateAt = null,
|
|
162
|
+
preparedRepoAt = null,
|
|
163
|
+
}) {
|
|
104
164
|
if (!root || !registry || !memory || typeof shouldEnroll !== 'function'
|
|
105
165
|
|| typeof captureRepo !== 'function' || !repoStates) {
|
|
106
166
|
throw new Error('registerTree requires { root, registry, memory, shouldEnroll, captureRepo, repoStates }');
|
|
@@ -109,6 +169,9 @@ function registerTree({ root, registry, memory, shouldEnroll, captureRepo, repoS
|
|
|
109
169
|
if (identityAt !== null && typeof identityAt !== 'function') {
|
|
110
170
|
throw new Error('registerTree identityAt must be a function when supplied');
|
|
111
171
|
}
|
|
172
|
+
if (preparedRepoAt !== null && typeof preparedRepoAt !== 'function') {
|
|
173
|
+
throw new Error('registerTree preparedRepoAt must be a function when supplied');
|
|
174
|
+
}
|
|
112
175
|
|
|
113
176
|
function seeded(relativePath, { type, parentUUID, name, root: isRoot }) {
|
|
114
177
|
const identity = identityAt?.(relativePath) || null;
|
|
@@ -151,6 +214,8 @@ function registerTree({ root, registry, memory, shouldEnroll, captureRepo, repoS
|
|
|
151
214
|
const saved = repoStates.get(identity.uuid);
|
|
152
215
|
if (saved?.stateId === identity.payloadVersion) return { state: saved, assignment: null };
|
|
153
216
|
}
|
|
217
|
+
const prepared = preparedRepoAt?.(repoPath) || null;
|
|
218
|
+
if (prepared) return prepared;
|
|
154
219
|
const assignment = collectWorktreeIdentity(repoPath, {
|
|
155
220
|
shouldEnroll,
|
|
156
221
|
identityFor: (repoRelativePath) => identityAt?.(
|
|
@@ -296,4 +361,4 @@ function registerDirectory(dirPath, parentUUID, {
|
|
|
296
361
|
}
|
|
297
362
|
}
|
|
298
363
|
|
|
299
|
-
module.exports = { registerTree };
|
|
364
|
+
module.exports = { collectWorktreeIdentityAsync, registerTree, transportIdentity };
|
|
@@ -80,7 +80,8 @@
|
|
|
80
80
|
* the folder record and every descendant's derived address follows,
|
|
81
81
|
* which is why folders reconcile before files, parents before
|
|
82
82
|
* children;
|
|
83
|
-
* - repo territories as
|
|
83
|
+
* - repo territories as observer truth, with an explicitly registered
|
|
84
|
+
* repository boundary supplying the corresponding repo.git record;
|
|
84
85
|
* - ADOPTION, the channel law completing across trees: unclaimed
|
|
85
86
|
* ground wearing the physical identity of an orphaned top-level tree
|
|
86
87
|
* is that tree's arrival — one lawful parent update on its
|
|
@@ -604,23 +605,24 @@ function createEvidence(options) {
|
|
|
604
605
|
};
|
|
605
606
|
settleClaims(claimsOf(desiredDirs, 'dir'), 'dir');
|
|
606
607
|
|
|
607
|
-
// Territories after folder continuity, before file ground
|
|
608
|
-
// boundary is
|
|
609
|
-
//
|
|
608
|
+
// Territories after folder continuity, before file ground. A Git
|
|
609
|
+
// boundary is NOT ordinary filesystem evidence: every repo.git record
|
|
610
|
+
// needs a Card + Checkpoint state and immutable transport before it can
|
|
611
|
+
// travel. This generic lens has neither, so it may only recognize a
|
|
612
|
+
// boundary that the registration adapter already made whole. An
|
|
613
|
+
// unregistered repository remains a local observer territory until
|
|
614
|
+
// `amalgm register <repo>` admits it through that adapter.
|
|
615
|
+
//
|
|
616
|
+
// This is deliberately not a convenience gate. Letting this layer
|
|
617
|
+
// mint or promote repo.git would create an identity record whose cloud
|
|
618
|
+
// head names no state — the exact invalid half-repository the cloud
|
|
619
|
+
// contract rejects. Folders and files are evidence-shaped; Git is a
|
|
620
|
+
// type-specific state carrier.
|
|
610
621
|
for (const territory of [...territories].sort()) {
|
|
611
622
|
const parentUuid = ensureDir(path.dirname(territory) === '.' ? '' : path.dirname(territory), root);
|
|
612
623
|
const name = path.basename(territory);
|
|
613
624
|
const at = registry.activeAt(parentUuid, name);
|
|
614
|
-
if (
|
|
615
|
-
// Adoption before birth here too: a registered top-level repo
|
|
616
|
-
// TREE that moved in arrives as its own record, never a fresh
|
|
617
|
-
// territory record. The enclosed observer root's identity is
|
|
618
|
-
// the evidence — the same physical dir the orphaned tree owns.
|
|
619
|
-
const enclosed = observer.listRoots().find((row) => row.path === path.join(groundPath, territory));
|
|
620
|
-
const arrivedRecord = enclosed ? arrivedTree(enclosed.device, enclosed.inode, REPO_TREE) : null;
|
|
621
|
-
if (arrivedRecord) registry.move(arrivedRecord.uuid, { parentUUID: parentUuid, name });
|
|
622
|
-
else registry.create({ type: 'repo.git', parentUUID: parentUuid, name });
|
|
623
|
-
} else if (at.type !== 'repo.git') registry.changeType(at.uuid, 'repo.git');
|
|
625
|
+
if (at?.type !== 'repo.git') continue;
|
|
624
626
|
}
|
|
625
627
|
// A lifted lens is the reverse transition on the same record.
|
|
626
628
|
for (const { record, rel } of walkActive(root.uuid, '', [])) {
|
|
@@ -20,9 +20,31 @@ const { Worker, isMainThread, parentPort } = require('worker_threads');
|
|
|
20
20
|
|
|
21
21
|
if (!isMainThread) {
|
|
22
22
|
const { capture } = require('./capture');
|
|
23
|
+
const { createEntityContent } = require('../registration/entity-content');
|
|
24
|
+
const { transportBytes, localState } = require('../registration/repo-states');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A cloud-bound initial repo capture is deliberately completed here, not
|
|
28
|
+
* handed back as a giant Git bundle for the runtime thread to JSON-encode
|
|
29
|
+
* and chunk. The runtime's job is to remain the responsive authority for
|
|
30
|
+
* Watch + Register; this worker owns the expensive, immutable cargo build.
|
|
31
|
+
* It returns only the bundle-free state plus its content head.
|
|
32
|
+
*/
|
|
33
|
+
function captureCargo(repoPath, options) {
|
|
34
|
+
const cargo = options?.cargo;
|
|
35
|
+
if (!cargo || typeof cargo.dir !== 'string') return capture(repoPath, options);
|
|
36
|
+
const snap = capture(repoPath, { ...options, withBundle: true });
|
|
37
|
+
if (snap?.busy) return snap;
|
|
38
|
+
const content = createEntityContent({ dir: cargo.dir });
|
|
39
|
+
const artifact = content.captureBytes({
|
|
40
|
+
bytes: transportBytes(snap, Array.isArray(cargo.identity) ? cargo.identity : null),
|
|
41
|
+
});
|
|
42
|
+
return localState(snap, artifact.contentHash);
|
|
43
|
+
}
|
|
44
|
+
|
|
23
45
|
parentPort.on('message', ({ id, repoPath, options }) => {
|
|
24
46
|
try {
|
|
25
|
-
parentPort.postMessage({ id, snap:
|
|
47
|
+
parentPort.postMessage({ id, snap: captureCargo(repoPath, options) });
|
|
26
48
|
} catch (error) {
|
|
27
49
|
parentPort.postMessage({ id, error: error?.message || String(error) });
|
|
28
50
|
}
|
|
@@ -369,6 +369,11 @@ test('add recreates an actual Git repository from verified Repocard cargo, then
|
|
|
369
369
|
snapshotChecksum: sha256(snapshotBytes),
|
|
370
370
|
});
|
|
371
371
|
|
|
372
|
+
// This is the real user destination: a repo lands below an already-live
|
|
373
|
+
// local workspaces-like container. Physical containment supplies watcher
|
|
374
|
+
// coverage only; the cloud repo must retain its parentless root identity.
|
|
375
|
+
target.registration.add(targetParent, { suppressJournal: true });
|
|
376
|
+
|
|
372
377
|
const planned = source.entityCloud.contentHeads(records);
|
|
373
378
|
assert.deepEqual(
|
|
374
379
|
planned.map(({ contentHash }) => contentHash),
|
|
@@ -406,6 +411,8 @@ test('add recreates an actual Git repository from verified Repocard cargo, then
|
|
|
406
411
|
const expected = source.registration.repoState(registered.record.uuid);
|
|
407
412
|
assert.equal(verify(destination, expected).ok, true, 'the downloaded Git state reproduces its Card + Checkpoint IDs');
|
|
408
413
|
assert.equal(target.registration.registry.syncRecord(registered.record.uuid).uuid, registered.record.uuid);
|
|
414
|
+
assert.equal(target.registration.registry.syncRecord(registered.record.uuid).parentUUID, null,
|
|
415
|
+
'a repo materialized beneath an existing local workspace keeps its cloud root position');
|
|
409
416
|
assert.equal(target.observer.status().find((entry) => entry.path === fs.realpathSync(destination))?.watching, true,
|
|
410
417
|
'the same successful add starts the destination repository watcher');
|
|
411
418
|
|
|
@@ -657,6 +657,28 @@ test('adding a repository registers its allowed tree and saves its initial Repoc
|
|
|
657
657
|
assert.equal(tree.root().uuid, after.uuid);
|
|
658
658
|
});
|
|
659
659
|
|
|
660
|
+
test('a production repository register builds its immutable Git cargo off the runtime thread', async () => {
|
|
661
|
+
const service = makeService('repo-cargo-worker');
|
|
662
|
+
const repo = makeGround('repo-cargo-worker');
|
|
663
|
+
execFileSync('git', ['init', '-q'], { cwd: repo });
|
|
664
|
+
fs.writeFileSync(path.join(repo, 'history.md'), '# committed history\n');
|
|
665
|
+
execFileSync('git', ['add', '.'], { cwd: repo });
|
|
666
|
+
execFileSync('git', [
|
|
667
|
+
'-c', 'user.name=Amalgm Test',
|
|
668
|
+
'-c', 'user.email=amalgm-test@example.invalid',
|
|
669
|
+
'commit', '-qm', 'initial history',
|
|
670
|
+
], { cwd: repo });
|
|
671
|
+
|
|
672
|
+
const { record } = await service.register(repo);
|
|
673
|
+
const state = service.registration.repoState(record.uuid);
|
|
674
|
+
assert.equal(record.type, 'repo.git');
|
|
675
|
+
assert.ok(state?.transportVersion, 'the saved state names one immutable transport object');
|
|
676
|
+
assert.equal(state.card.bundle, null,
|
|
677
|
+
'identity.db holds only bundle-free state; the worker left history in immutable content');
|
|
678
|
+
assert.ok(service.entityContent.bytes({ contentHash: state.transportVersion }).length > 0,
|
|
679
|
+
'the worker finished the transport before Register committed the repo head');
|
|
680
|
+
});
|
|
681
|
+
|
|
660
682
|
test('an edge-reached repository gets the same complete registration as a directly added repo', async () => {
|
|
661
683
|
const service = makeService('edge-repo-card');
|
|
662
684
|
const holder = makeGround('edge-repo-holder');
|
|
@@ -724,6 +746,45 @@ test('a nested repository has its own baseline and watcher, while its tree remai
|
|
|
724
746
|
assert.equal(tree.registry.activeAt(src.uuid, 'live.js').type, 'file.text');
|
|
725
747
|
});
|
|
726
748
|
|
|
749
|
+
test('a repo discovered inside watched workspace ground stays unregistered until explicit register gives it one complete repo root', async () => {
|
|
750
|
+
const service = makeService('workspace-repo-admission');
|
|
751
|
+
const workspace = makeGround('workspace-repo-admission');
|
|
752
|
+
const { record: workspaceRecord } = await service.register(workspace);
|
|
753
|
+
const repo = path.join(workspace, 't3code');
|
|
754
|
+
fs.mkdirSync(repo);
|
|
755
|
+
execFileSync('git', ['init', '-q'], { cwd: repo });
|
|
756
|
+
fs.writeFileSync(path.join(repo, 'readme.md'), '# T3\n');
|
|
757
|
+
|
|
758
|
+
// This is the production ordering that convicted the live Fly run: the
|
|
759
|
+
// outer workspace watcher discovers a newly cloned repository first.
|
|
760
|
+
const workspaceRoot = service.observer.listRoots().find((root) => root.path === workspace);
|
|
761
|
+
service.observer.scanNow(workspaceRoot.rootId);
|
|
762
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
763
|
+
service.registration.flushIfDirty();
|
|
764
|
+
|
|
765
|
+
const territory = service.observer.listRoots().find((root) => root.path === repo);
|
|
766
|
+
assert.equal(territory?.kind, 'repo', 'Watch knows the local Git territory');
|
|
767
|
+
assert.notEqual(
|
|
768
|
+
service.registration.tree(workspace).registry.activeAt(workspaceRecord.uuid, 't3code')?.type,
|
|
769
|
+
'repo.git',
|
|
770
|
+
'generic filesystem evidence never mints a half-repository with no Card + Checkpoint',
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
const { record } = await service.register(repo);
|
|
774
|
+
assert.equal(record.type, 'repo.git');
|
|
775
|
+
assert.equal(record.parentUUID, null,
|
|
776
|
+
'selected repo is its own cloud tree even though its local address is inside workspaces');
|
|
777
|
+
assert.equal(service.registration.tree(repo).root().uuid, record.uuid,
|
|
778
|
+
'the nested observer root now witnesses that selected tree');
|
|
779
|
+
assert.ok(service.registration.repoState(record.uuid)?.stateId,
|
|
780
|
+
'its identity and initial Card + Checkpoint were committed together');
|
|
781
|
+
assert.equal(
|
|
782
|
+
service.observer.status().some((row) => row.path === repo && row.watching && !row.degraded),
|
|
783
|
+
true,
|
|
784
|
+
'the selected repo is live after the atomic registration',
|
|
785
|
+
);
|
|
786
|
+
});
|
|
787
|
+
|
|
727
788
|
test('a reopened repo watcher resumes from the saved Card + Checkpoint baseline', async () => {
|
|
728
789
|
const service = makeService('repo-reopen');
|
|
729
790
|
const repo = makeGround('repo-reopen');
|
|
@@ -920,6 +981,25 @@ test('identity survives a real close-and-reopen over the same directory', async
|
|
|
920
981
|
assert.equal(after, before, 'the tree keeps its permanent UUID across processes');
|
|
921
982
|
});
|
|
922
983
|
|
|
984
|
+
test('a restarted runtime reopens the persisted watcher before it reports the tree healthy', async () => {
|
|
985
|
+
const dir = path.join(tempRoot, 'state-reopen-watch');
|
|
986
|
+
const ground = makeGround('reopen-watch');
|
|
987
|
+
fs.writeFileSync(path.join(ground, 'notes.md'), 'persisted ground\n');
|
|
988
|
+
|
|
989
|
+
const first = createRegistrationService({ dir });
|
|
990
|
+
const registered = await first.register(ground);
|
|
991
|
+
first.close();
|
|
992
|
+
|
|
993
|
+
const reopened = createRegistrationService({ dir });
|
|
994
|
+
services.push(reopened);
|
|
995
|
+
const root = reopened.observer.status().find((entry) => entry.path === ground);
|
|
996
|
+
assert.ok(root, 'the original observer root survived the restart');
|
|
997
|
+
assert.equal(root.watching, true, 'the restarted runtime has a live Watch rail');
|
|
998
|
+
assert.equal(root.degraded, false);
|
|
999
|
+
assert.equal(reopened.registration.tree(ground).root().uuid, registered.record.uuid,
|
|
1000
|
+
'the watcher reopened over the same permanent identity');
|
|
1001
|
+
});
|
|
1002
|
+
|
|
923
1003
|
// --- the policies are the real ones ---------------------------------------
|
|
924
1004
|
|
|
925
1005
|
test("amalgm's enrollment policy is in force, and refuses loudly", async () => {
|