amalgm 0.1.245 → 0.1.246
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/README.md +12 -0
- package/lib/cli.js +146 -8
- package/lib/layout.js +89 -4
- package/lib/shared-realtime-tunnel.js +384 -43
- package/lib/supervisor.js +0 -3
- package/package.json +2 -2
- package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
- package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
- package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
- package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
- package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
- package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
- package/runtime/scripts/amalgm-mcp/index.js +4 -0
- package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
- package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
- package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
- package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
- package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
- package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
- package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
- package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
- package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
- package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
- package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
- package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
- package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
- package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
- package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
- package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
- package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
- package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
- package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
- package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
- package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
- package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
- package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
- package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
- package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
- package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
- package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
- package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
- package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
- package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
- package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
- package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
- package/runtime/scripts/local-gateway.js +1 -0
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
- package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +0 -3766
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The shared registration operation — the `amalgm register` boundary
|
|
5
|
+
* (docs/realtime-rails.md §3.1). One call turns a path into registered,
|
|
6
|
+
* watched ground: inspect the address (a symlink registers AS the link
|
|
7
|
+
* before anything resolves it), enroll exactly that ground with the
|
|
8
|
+
* observer, register it in the ONE identity registry, then follow every
|
|
9
|
+
* symlink edge to a fixpoint so each reachable real target is registered
|
|
10
|
+
* exactly once. The CLI and SDK both call this; neither reimplements it.
|
|
11
|
+
*
|
|
12
|
+
* The model, in five sentences. ONE identity database holds every
|
|
13
|
+
* registered entity on this installation; a TREE is one active
|
|
14
|
+
* parentless record with its descendants beneath it — a sync boundary
|
|
15
|
+
* (its own future head and rail), never an identity boundary. Every
|
|
16
|
+
* top-level enrolled ground — a workspace, a repository, a single file,
|
|
17
|
+
* or an explicitly added link — is such a tree, with one evidence LENS
|
|
18
|
+
* over its observer root (nested repo territories live INSIDE their
|
|
19
|
+
* tree as repo.git records; they are never trees of their own). A
|
|
20
|
+
* TREE'S IDENTITY IS ITS UUID, PERMANENT; its root path is only the
|
|
21
|
+
* current local address — lenses are keyed by the observer's rootId
|
|
22
|
+
* (the witness), the observer rescues moved ground by physical
|
|
23
|
+
* continuity, and an orphaned tree arriving inside another tree is
|
|
24
|
+
* ADOPTED there: one lawful parent update on its parentless record,
|
|
25
|
+
* after which this boundary retires the orphan's observer roots and
|
|
26
|
+
* evidence scope (the ground is covered by its new tree). "Watched"
|
|
27
|
+
* and "registered" are the same act here: an observer root this
|
|
28
|
+
* boundary enrolls always gets its lens and record, so nothing is ever
|
|
29
|
+
* ground without identity. What a pointer REACHES is derived at this
|
|
30
|
+
* layer and only here — resolution needs eyes on every tree, so
|
|
31
|
+
* `linkTargets` lives on the boundary, not inside any single lens.
|
|
32
|
+
*
|
|
33
|
+
* THE LOSS LAW (evidence.js assertWitnessed): every active parentless record must
|
|
34
|
+
* be claimed by a LIVE observer root's witness binding. A lost or torn
|
|
35
|
+
* observer store leaves registered trees unclaimed, and this boundary
|
|
36
|
+
* convicts that loudly before any lens reconciles — a lost store is
|
|
37
|
+
* never a fresh start, and no replacement identities mint over
|
|
38
|
+
* surviving records. Re-enrollment after loss is an explicit act.
|
|
39
|
+
*
|
|
40
|
+
* Registration is add-only and policy-gated: following edges and adding
|
|
41
|
+
* ground never remove coverage; amalgm's enrollment policy is asked on
|
|
42
|
+
* EVERY route (an edge is refused and reported, an explicit add refuses
|
|
43
|
+
* loudly — the deliberate override is future work); and a target ground
|
|
44
|
+
* that would swallow an enrolled root is refused (`reported()`) — one
|
|
45
|
+
* owner per path. Removing coverage is a future explicit operation
|
|
46
|
+
* (`amalgm remove`), never a side effect. (Retiring an adopted tree's
|
|
47
|
+
* observer roots is not removal: the same ground stays covered, by its
|
|
48
|
+
* new tree.)
|
|
49
|
+
*
|
|
50
|
+
* Storage policy is injected (the Axiom-2 pattern): `openIdentity()` is
|
|
51
|
+
* called ONCE and returns `{ db, sentinelPath }` for the durable
|
|
52
|
+
* installation registry or `{ db, ephemeral: true }` for a real
|
|
53
|
+
* in-memory one. One database, one sentinel, one epoch — identity is
|
|
54
|
+
* never partitioned by tree, which is exactly what makes a cross-tree
|
|
55
|
+
* move one transaction instead of an export between databases.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
const fs = require('fs');
|
|
59
|
+
const path = require('path');
|
|
60
|
+
|
|
61
|
+
const { followEdges } = require('../observer/edges');
|
|
62
|
+
const { createRegistry } = require('../registry');
|
|
63
|
+
const { assertWitnessed, createEvidence, createEvidenceMemory } = require('../registry/evidence');
|
|
64
|
+
const { capture } = require('../repocard');
|
|
65
|
+
const { refusal } = require('./refusal');
|
|
66
|
+
const { createRepoFollowers } = require('./repo-followers');
|
|
67
|
+
const { createRepoStates } = require('./repo-states');
|
|
68
|
+
const { registerTree } = require('./tree');
|
|
69
|
+
|
|
70
|
+
// Events that can change what the recorded edges reach: a new or
|
|
71
|
+
// retargeted pointer, repo ground moving under its card (a worktree
|
|
72
|
+
// change may be a symlink change — the census reads fresh), or a whole
|
|
73
|
+
// root at a new address (its relative out-edges resolve from there now).
|
|
74
|
+
const EDGE_EVENTS = new Set(['link.created', 'link.changed', 'repo.changed', 'repo.discovered', 'root.moved']);
|
|
75
|
+
|
|
76
|
+
function createRegistration(options) {
|
|
77
|
+
const { observer, openIdentity, classify, emit = () => {} } = options;
|
|
78
|
+
if (!observer) throw new Error('createRegistration requires { observer }');
|
|
79
|
+
if (typeof openIdentity !== 'function') {
|
|
80
|
+
throw new Error('createRegistration requires { openIdentity }: () => ({ db, sentinelPath }) or ({ db, ephemeral: true }) — ONE identity database holds every registered tree, opened once');
|
|
81
|
+
}
|
|
82
|
+
if (typeof classify !== 'function') {
|
|
83
|
+
throw new Error('createRegistration requires { classify }: ({ binary }) => file type — an injected policy over the OBSERVED BYTES\' own report, never a built-in rule and never a fresh read of the ground');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const opened = openIdentity();
|
|
87
|
+
const registry = createRegistry({
|
|
88
|
+
db: opened.db,
|
|
89
|
+
sentinelPath: opened.sentinelPath,
|
|
90
|
+
ephemeral: opened.ephemeral,
|
|
91
|
+
emit,
|
|
92
|
+
journal: opened.journal ?? null,
|
|
93
|
+
});
|
|
94
|
+
const memory = createEvidenceMemory(opened.db);
|
|
95
|
+
const repoStates = createRepoStates(opened.db);
|
|
96
|
+
const lenses = new Map(); // rootId (permanent witness) -> evidence lens
|
|
97
|
+
let reported = []; // the latest edge pass's refusals
|
|
98
|
+
let edgesDirty = false;
|
|
99
|
+
let scheduled = false;
|
|
100
|
+
let stopped = false;
|
|
101
|
+
const repoFollowers = createRepoFollowers({
|
|
102
|
+
observer,
|
|
103
|
+
registry,
|
|
104
|
+
repoStates,
|
|
105
|
+
recordForRoot: repoRecordForRoot,
|
|
106
|
+
onSettled: reconcileSettledRepo,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/** Tree roots are the non-enclosed observer roots: a nested repo
|
|
110
|
+
* territory belongs to the tree that encloses it. */
|
|
111
|
+
function treeRoots() {
|
|
112
|
+
const roots = observer.listRoots();
|
|
113
|
+
return roots.filter((root) => !roots.some((other) =>
|
|
114
|
+
other.rootId !== root.rootId && root.path.startsWith(`${other.path}/`)));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function treeRootCovering(absPath) {
|
|
118
|
+
return treeRoots()
|
|
119
|
+
.filter((root) => absPath === root.path || absPath.startsWith(`${root.path}/`))
|
|
120
|
+
.sort((a, b) => b.path.length - a.path.length)[0] ?? null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The tree's top record, found the only honest way: by its witness. */
|
|
124
|
+
function rootRecordOf(rootId) {
|
|
125
|
+
const uuid = memory.boundTo(rootId);
|
|
126
|
+
return uuid === undefined ? null : registry.record(uuid);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Watched implies registered: every tree root gets its lens, born
|
|
130
|
+
* with one reconciliation (events are hints; birth reads store truth).
|
|
131
|
+
* A lens whose witness record gained a parent was ADOPTED into another
|
|
132
|
+
* tree — its ground is covered there now, so its observer family and
|
|
133
|
+
* evidence scope retire. Idempotent; convicts loss first (the loss
|
|
134
|
+
* law, evidence.js `assertWitnessed`). */
|
|
135
|
+
function ensureTrees() {
|
|
136
|
+
assertWitnessed({ observer, registry, memory });
|
|
137
|
+
// Retirement residue: a scope whose witness root no longer lives is a
|
|
138
|
+
// finished retirement's leftovers. The loss law above already vouched
|
|
139
|
+
// for every living tree — a dead-witness binding on a PARENTLESS
|
|
140
|
+
// record convicted there — so dropping the rest can never erase a
|
|
141
|
+
// claim, and a retirement torn at any point completes right here.
|
|
142
|
+
const liveIds = new Set(observer.listRoots().map((row) => row.rootId));
|
|
143
|
+
for (const { scopeId } of memory.allBindings()) {
|
|
144
|
+
if (!liveIds.has(scopeId)) memory.dropScope(scopeId);
|
|
145
|
+
}
|
|
146
|
+
for (const [rootId] of [...lenses]) {
|
|
147
|
+
const row = observer.listRoots().find((member) => member.rootId === rootId);
|
|
148
|
+
if (!row) {
|
|
149
|
+
lenses.delete(rootId);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const record = rootRecordOf(rootId);
|
|
153
|
+
const nestedRepoScope = row.kind === 'repo' && record?.type === 'repo.git'
|
|
154
|
+
&& observer.listRoots().some((other) => other.rootId !== row.rootId
|
|
155
|
+
&& row.path.startsWith(`${other.path}/`));
|
|
156
|
+
if (record && record.parentUUID !== null && !nestedRepoScope) {
|
|
157
|
+
// Retirement is EVIDENCE FIRST, IDENTITY LAST: observer rows go
|
|
158
|
+
// before the witness binding (descendants before the root), so a
|
|
159
|
+
// crash anywhere leaves either a living root whose binding still
|
|
160
|
+
// proves the adoption — retried here — or dead-scope residue the
|
|
161
|
+
// sweep above collects. The reverse order once minted a duplicate
|
|
162
|
+
// tree: an unbound living root looks like fresh ground.
|
|
163
|
+
const family = observer.listRoots()
|
|
164
|
+
.filter((member) => member.rootId === rootId || member.path.startsWith(`${row.path}/`));
|
|
165
|
+
for (const member of family) {
|
|
166
|
+
if (member.rootId !== rootId) observer.removeRoot(member.rootId);
|
|
167
|
+
}
|
|
168
|
+
observer.removeRoot(rootId);
|
|
169
|
+
memory.dropScope(rootId);
|
|
170
|
+
lenses.delete(rootId);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const root of treeRoots()) {
|
|
174
|
+
if (lenses.has(root.rootId)) continue;
|
|
175
|
+
const lens = createEvidence({ observer, registry, memory, rootId: root.rootId, classify });
|
|
176
|
+
lenses.set(root.rootId, lens);
|
|
177
|
+
lens.reconcile();
|
|
178
|
+
}
|
|
179
|
+
// A nested repository is one semantic root inside its enclosing tree.
|
|
180
|
+
// It therefore gets its own evidence scope (for its child channels),
|
|
181
|
+
// while retaining its existing parent UUID. The top-level tree lens
|
|
182
|
+
// never walks through this boundary.
|
|
183
|
+
for (const root of observer.listRoots().filter((row) => row.kind === 'repo')) {
|
|
184
|
+
const record = repoRecordForRoot(root);
|
|
185
|
+
if (!record) continue;
|
|
186
|
+
if (memory.boundTo(root.rootId) !== record.uuid) {
|
|
187
|
+
memory.bind(root.rootId, root.rootId, record.uuid);
|
|
188
|
+
}
|
|
189
|
+
if (lenses.has(root.rootId)) continue;
|
|
190
|
+
const lens = createEvidence({ observer, registry, memory, rootId: root.rootId, classify });
|
|
191
|
+
lenses.set(root.rootId, lens);
|
|
192
|
+
lens.reconcile();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function follow() {
|
|
197
|
+
edgesDirty = false;
|
|
198
|
+
try {
|
|
199
|
+
const result = followEdges(observer);
|
|
200
|
+
registerFollowedTrees();
|
|
201
|
+
// An unsettled edge surface concludes nothing about completeness:
|
|
202
|
+
// the boundary stays dirty, and the observer's own backed-off
|
|
203
|
+
// rescan of that ground rings `repo.changed` when it settles —
|
|
204
|
+
// which re-marks the edges dirty here and retries the follow.
|
|
205
|
+
if (!result.settled) edgesDirty = true;
|
|
206
|
+
reported = result.reported;
|
|
207
|
+
ensureTrees();
|
|
208
|
+
return result;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
edgesDirty = true;
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* `followEdges` is deliberately identity-free: it establishes what real
|
|
217
|
+
* ground is reachable and asks the observer to cover it. Identity must be
|
|
218
|
+
* born here, at the registration boundary, before a lens can reconcile the
|
|
219
|
+
* new root. That makes an edge-reached repository the same thing as a
|
|
220
|
+
* directly added repository: a complete structural tree plus one initial
|
|
221
|
+
* Card + Checkpoint document, all before Watch + Detect can describe it.
|
|
222
|
+
*
|
|
223
|
+
* Only non-enclosed roots are enrolled here. Nested repositories belong to
|
|
224
|
+
* their enclosing structural walk, which creates their `repo.git` records
|
|
225
|
+
* and baselines in the same transaction as their parent tree.
|
|
226
|
+
*/
|
|
227
|
+
function registerFollowedTrees() {
|
|
228
|
+
const unregistered = treeRoots()
|
|
229
|
+
.filter((root) => memory.boundTo(root.rootId) === undefined)
|
|
230
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
231
|
+
|
|
232
|
+
for (const root of unregistered) {
|
|
233
|
+
try {
|
|
234
|
+
assertWitnessed({ observer, registry, memory });
|
|
235
|
+
registerTree({
|
|
236
|
+
root,
|
|
237
|
+
registry,
|
|
238
|
+
memory,
|
|
239
|
+
shouldEnroll: observer.shouldEnroll,
|
|
240
|
+
captureRepo: (repoPath) => capture(repoPath, { withBundle: false }),
|
|
241
|
+
repoStates,
|
|
242
|
+
});
|
|
243
|
+
} catch (error) {
|
|
244
|
+
// An observer root is only coverage until its one structural
|
|
245
|
+
// registration transaction commits. Leave no watched-but-unregistered
|
|
246
|
+
// family behind: the still-present link will attempt this same add on
|
|
247
|
+
// the next edge pass, while a successful sibling stays intact.
|
|
248
|
+
const family = observer.listRoots()
|
|
249
|
+
.filter((member) => member.path === root.path || member.path.startsWith(`${root.path}/`))
|
|
250
|
+
.sort((left, right) => right.path.length - left.path.length);
|
|
251
|
+
for (const member of family) observer.removeRoot(member.rootId);
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function tryFollow() {
|
|
258
|
+
try {
|
|
259
|
+
follow();
|
|
260
|
+
} catch (error) {
|
|
261
|
+
console.warn('[Registration] edge follow failed (stays dirty):', error?.message || error);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The entity that OWNS a derived address inside one tree, walked slot
|
|
267
|
+
* by slot — '' is the tree's root record. Repository boundaries select
|
|
268
|
+
* Card + Checkpoint realtime semantics, but their allowed descendants
|
|
269
|
+
* still have records; this walk therefore always reaches the exact
|
|
270
|
+
* registered entity at the address.
|
|
271
|
+
*/
|
|
272
|
+
function owningRecordAt(rootRecord, relPath) {
|
|
273
|
+
if (!rootRecord) return null;
|
|
274
|
+
if (relPath === '') return rootRecord;
|
|
275
|
+
let cursor = rootRecord.uuid;
|
|
276
|
+
const parts = relPath.split('/');
|
|
277
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
278
|
+
const at = registry.activeAt(cursor, parts[i]);
|
|
279
|
+
if (!at) return null;
|
|
280
|
+
cursor = at.uuid;
|
|
281
|
+
}
|
|
282
|
+
return registry.activeAt(cursor, parts[parts.length - 1]);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The `repo.git` entity an observer repo root represents. A directly
|
|
286
|
+
* registered repo owns itself; a nested repo owns its own boundary
|
|
287
|
+
* record, even though its worktree leaves are structurally registered
|
|
288
|
+
* below that record. */
|
|
289
|
+
function repoRecordForRoot(repoRoot) {
|
|
290
|
+
const home = treeRootCovering(repoRoot.path);
|
|
291
|
+
if (!home) return null;
|
|
292
|
+
const tree = rootRecordOf(home.rootId);
|
|
293
|
+
if (!tree) return null;
|
|
294
|
+
const rel = repoRoot.path === home.path ? '' : repoRoot.path.slice(home.path.length + 1);
|
|
295
|
+
const record = owningRecordAt(tree, rel);
|
|
296
|
+
return record?.type === 'repo.git' ? record : null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Card + Checkpoint is the filter for repo children. This is the only
|
|
301
|
+
* place ordinary worktree channels are refreshed for a repo root: raw
|
|
302
|
+
* filesystem doorbells wake the follower, and a settled follower calls
|
|
303
|
+
* here exactly once for the resulting state.
|
|
304
|
+
*/
|
|
305
|
+
function reconcileSettledRepo({ root }) {
|
|
306
|
+
observer.reconcileRepoWorktree(root.rootId);
|
|
307
|
+
ensureTrees();
|
|
308
|
+
lenses.get(root.rootId)?.flushIfDirty();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Establish every repo's initial child-channel baseline after its
|
|
312
|
+
* structural tree and persisted Card + Checkpoint already exist. Nested
|
|
313
|
+
* repos appear while their parent is scanned, so loop to the fixed point. */
|
|
314
|
+
function reconcileInitialRepoWorktrees(rootPath) {
|
|
315
|
+
const seen = new Set();
|
|
316
|
+
while (true) {
|
|
317
|
+
const pending = observer.listRoots().filter((root) => root.kind === 'repo'
|
|
318
|
+
&& (root.path === rootPath || root.path.startsWith(`${rootPath}/`))
|
|
319
|
+
&& !seen.has(root.rootId));
|
|
320
|
+
if (pending.length === 0) return;
|
|
321
|
+
for (const root of pending) {
|
|
322
|
+
seen.add(root.rootId);
|
|
323
|
+
observer.reconcileRepoWorktree(root.rootId);
|
|
324
|
+
}
|
|
325
|
+
ensureTrees();
|
|
326
|
+
for (const root of pending) lenses.get(root.rootId)?.flushIfDirty();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* One observer event wakes one Record owner. An observer root with its own
|
|
332
|
+
* lens (every repo.git boundary, including a nested one) owns the event it
|
|
333
|
+
* emitted. Only events from ground without a lens fall back to the
|
|
334
|
+
* enclosing tree. Containment and event ownership are different questions:
|
|
335
|
+
* the former finds a parent tree; the latter must preserve the semantic
|
|
336
|
+
* boundary that detected the change.
|
|
337
|
+
*/
|
|
338
|
+
function notifyEventOwner(event) {
|
|
339
|
+
const direct = event.rootId ? lenses.get(event.rootId) : null;
|
|
340
|
+
if (direct) {
|
|
341
|
+
direct.onEvent();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const row = observer.listRoots().find((root) => root.rootId === event.rootId);
|
|
345
|
+
const home = row ? treeRootCovering(row.path)
|
|
346
|
+
: (typeof event.path === 'string' && path.isAbsolute(event.path) ? treeRootCovering(event.path) : null);
|
|
347
|
+
if (home) lenses.get(home.rootId)?.onEvent();
|
|
348
|
+
else for (const lens of lenses.values()) lens.onEvent();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** `add` may only claim success when every watcher it established is
|
|
352
|
+
* genuinely live. A filesystem watcher that could not open is not a
|
|
353
|
+
* warning; it means the requested Register → Watch operation is incomplete
|
|
354
|
+
* and the caller must hear that. */
|
|
355
|
+
function requireLiveWatchers(rootPath) {
|
|
356
|
+
const belongs = (root) => root.path === rootPath || root.path.startsWith(`${rootPath}/`);
|
|
357
|
+
let unavailable = observer.status().filter((root) => belongs(root)
|
|
358
|
+
&& (!root.watching || root.degraded || (root.addressRequired
|
|
359
|
+
&& (!root.addressWatching || root.addressDegraded))));
|
|
360
|
+
// A normal successful start is left alone. Only a failed watcher birth
|
|
361
|
+
// is retried, so registration has one deliberate Watch transition.
|
|
362
|
+
for (const failed of unavailable) observer.start(failed.rootId);
|
|
363
|
+
unavailable = observer.status().filter((root) => (root.path === rootPath
|
|
364
|
+
|| root.path.startsWith(`${rootPath}/`))
|
|
365
|
+
&& (!root.watching || root.degraded || (root.addressRequired
|
|
366
|
+
&& (!root.addressWatching || root.addressDegraded))));
|
|
367
|
+
if (unavailable.length > 0) {
|
|
368
|
+
throw new Error(`registered ${rootPath}, but could not start required watcher(s): ${unavailable.map((root) => root.path).join(', ')}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Real ground -> the UUID of the entity that owns it, across trees. */
|
|
373
|
+
function resolveGround(real) {
|
|
374
|
+
const home = treeRootCovering(real);
|
|
375
|
+
if (!home) return null;
|
|
376
|
+
const rel = real === home.path ? '' : real.slice(home.path.length + 1);
|
|
377
|
+
return owningRecordAt(rootRecordOf(home.rootId), rel)?.uuid ?? null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** The directory an edge's relative target resolves against — a
|
|
381
|
+
* single-ground pointer root's base is its parent directory. */
|
|
382
|
+
function edgeBase(root, relPath) {
|
|
383
|
+
if (root.kind === 'file') return path.dirname(root.path);
|
|
384
|
+
return path.resolve(root.path, path.dirname(relPath));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function treeAt(rootPath) {
|
|
388
|
+
const root = observer.listRoots().find((row) => row.path === rootPath);
|
|
389
|
+
const lens = root && lenses.get(root.rootId);
|
|
390
|
+
if (!lens) return null;
|
|
391
|
+
return {
|
|
392
|
+
registry,
|
|
393
|
+
evidence: lens,
|
|
394
|
+
root: () => rootRecordOf(root.rootId),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** An entity's CURRENT local address, derived from its record chain and
|
|
399
|
+
* its tree's observer address — never stored. null when its tree is not
|
|
400
|
+
* locally bound. */
|
|
401
|
+
function addressOf(uuid) {
|
|
402
|
+
const names = [];
|
|
403
|
+
let record = registry.record(uuid);
|
|
404
|
+
while (record.parentUUID !== null) {
|
|
405
|
+
names.unshift(record.name);
|
|
406
|
+
record = registry.record(record.parentUUID);
|
|
407
|
+
}
|
|
408
|
+
const home = treeRoots().find((row) => memory.boundTo(row.rootId) === record.uuid);
|
|
409
|
+
if (!home) return null;
|
|
410
|
+
return names.length === 0 ? home.path : path.join(home.path, ...names);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* A managed reference is CANONICAL REGISTRY TRUTH with an idempotent
|
|
415
|
+
* local projection, and this is the projector: each reference's
|
|
416
|
+
* rendering must be a symlink at the reference's derived address,
|
|
417
|
+
* pointing at its target's current address. A missing rendering is
|
|
418
|
+
* recreated; a drifted one is rewritten. The rewrite is
|
|
419
|
+
* remove-then-render, deliberately not a temp-and-rename: a crash
|
|
420
|
+
* between the two acts leaves a missing rendering — exactly what the
|
|
421
|
+
* next pass recreates — while a stray temp name inside user ground
|
|
422
|
+
* would be scanned as new ground and mint an entity. Convergence is
|
|
423
|
+
* the atomicity. Real ground occupying the slot is NEVER overwritten
|
|
424
|
+
* (the lens reports that divergence); a target with no local address
|
|
425
|
+
* leaves the rendering as it stands — dangling is honest — repaired
|
|
426
|
+
* the moment the target is rescued or reattached. This is the one
|
|
427
|
+
* place the detection side writes disk, and it writes only Amalgm's
|
|
428
|
+
* own artifact — never user data. A write echoes back as a link
|
|
429
|
+
* channel fact; the entity stays silent because a reference's payload
|
|
430
|
+
* is identity, which no ground can attest.
|
|
431
|
+
*/
|
|
432
|
+
function maintainReferences() {
|
|
433
|
+
for (const { record, targetUUID } of registry.references()) {
|
|
434
|
+
const rendered = addressOf(record.uuid);
|
|
435
|
+
if (rendered === null) continue; // the reference's own tree is not locally bound
|
|
436
|
+
let target;
|
|
437
|
+
try {
|
|
438
|
+
target = addressOf(targetUUID);
|
|
439
|
+
} catch {
|
|
440
|
+
continue; // the target record is gone from this registry: nothing to aim at
|
|
441
|
+
}
|
|
442
|
+
if (target === null) continue; // target unavailable: dangling (or missing) until rescue repairs it
|
|
443
|
+
let current = null; // null = no rendering holds the slot
|
|
444
|
+
try {
|
|
445
|
+
const stat = fs.lstatSync(rendered);
|
|
446
|
+
if (!stat.isSymbolicLink()) continue; // real ground on the slot: divergence (the lens reports it), never overwritten
|
|
447
|
+
current = fs.readlinkSync(rendered);
|
|
448
|
+
} catch {
|
|
449
|
+
current = null; // missing, or raced away mid-look: recreated below
|
|
450
|
+
}
|
|
451
|
+
if (current === target) continue; // the projection already holds
|
|
452
|
+
if (current !== null) fs.unlinkSync(rendered);
|
|
453
|
+
fs.symlinkSync(target, rendered);
|
|
454
|
+
const home = treeRootCovering(rendered);
|
|
455
|
+
if (home) {
|
|
456
|
+
observer.scanNow(home.rootId);
|
|
457
|
+
// The write's echo may fire where no boundary listens yet (the
|
|
458
|
+
// startup pass runs during construction), so the projector hands
|
|
459
|
+
// the fact over itself: the next pass always binds the
|
|
460
|
+
// rendering's channel.
|
|
461
|
+
lenses.get(home.rootId)?.onEvent();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* `amalgm register <path>` has two deliberately ordered acts:
|
|
468
|
+
*
|
|
469
|
+
* 1. Register the complete entity tree from its structural shape.
|
|
470
|
+
* 2. Start Watch and let Detect describe the ground from then on.
|
|
471
|
+
*
|
|
472
|
+
* The address is inspected first: a symlink at it registers as the link
|
|
473
|
+
* entity itself (its target then follows after detection), and only real
|
|
474
|
+
* ground is resolved. Exactly the named ground enrolls — a file or link
|
|
475
|
+
* as a tree of one, a repo only when the named path IS the repo root.
|
|
476
|
+
* Changes while the registration walk runs are outside this operation's
|
|
477
|
+
* contract; no readiness protocol or user-ground mutation is involved.
|
|
478
|
+
*/
|
|
479
|
+
function add(targetPath, options = {}) {
|
|
480
|
+
const identityAt = options.identityAt ?? null;
|
|
481
|
+
if (identityAt !== null && typeof identityAt !== 'function') {
|
|
482
|
+
throw new Error('add identityAt must be a function when supplied');
|
|
483
|
+
}
|
|
484
|
+
let address;
|
|
485
|
+
let ground;
|
|
486
|
+
try {
|
|
487
|
+
const parent = fs.realpathSync(path.dirname(targetPath));
|
|
488
|
+
address = path.join(parent, path.basename(targetPath));
|
|
489
|
+
ground = fs.lstatSync(address).isSymbolicLink() ? address : fs.realpathSync(address);
|
|
490
|
+
} catch (error) {
|
|
491
|
+
// Ground that is not there is a REFUSAL of this request, not a
|
|
492
|
+
// fault: nothing is wrong with the installation, the address is
|
|
493
|
+
// simply not ground. (An address that exists but cannot be read
|
|
494
|
+
// is not this: it falls through as the fault it is.)
|
|
495
|
+
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
|
|
496
|
+
throw refusal(`refusing to add ${targetPath}: there is no ground at that address`);
|
|
497
|
+
}
|
|
498
|
+
const covering = treeRootCovering(ground);
|
|
499
|
+
if (covering) {
|
|
500
|
+
ensureTrees();
|
|
501
|
+
tryFollow();
|
|
502
|
+
requireLiveWatchers(covering.path);
|
|
503
|
+
return { rootPath: covering.path, record: rootRecordOf(covering.rootId) };
|
|
504
|
+
}
|
|
505
|
+
// The policy judges the COMPLETE address — a benign final name
|
|
506
|
+
// inside excluded ground (.ssh/config) is still excluded.
|
|
507
|
+
if (!observer.shouldEnroll(ground)) {
|
|
508
|
+
throw refusal(`refusing to add ${ground}: amalgm's enrollment policy excludes it — the explicit override is a future deliberate operation, never a default`);
|
|
509
|
+
}
|
|
510
|
+
const swallowed = observer.listRoots().filter((root) => root.path.startsWith(`${ground}/`));
|
|
511
|
+
if (swallowed.length > 0) {
|
|
512
|
+
throw refusal(`refusing to add ${ground}: it contains already-registered ground (${swallowed.map((root) => root.path).join(', ')}) — one owner per path; remove the inner registration first, explicitly`);
|
|
513
|
+
}
|
|
514
|
+
const root = observer.enrollRoot(ground, { scan: false, watch: false });
|
|
515
|
+
try {
|
|
516
|
+
assertWitnessed({ observer, registry, memory });
|
|
517
|
+
registerTree({
|
|
518
|
+
root,
|
|
519
|
+
registry,
|
|
520
|
+
memory,
|
|
521
|
+
shouldEnroll: observer.shouldEnroll,
|
|
522
|
+
captureRepo: (repoPath) => capture(repoPath, { withBundle: false }),
|
|
523
|
+
repoStates,
|
|
524
|
+
identityAt,
|
|
525
|
+
});
|
|
526
|
+
} catch (error) {
|
|
527
|
+
// The observer root is only an address until structural registration
|
|
528
|
+
// commits. Do not leave an unregistered address behind after a refused
|
|
529
|
+
// or failed walk; a committed tree, by contrast, is durable truth and
|
|
530
|
+
// is never rolled back by a later Watch/Detect fault.
|
|
531
|
+
observer.removeRoot(root.rootId);
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
534
|
+
// Registration committed the complete structural tree before Watch.
|
|
535
|
+
// Start's first census must run before a parent lens is allowed to
|
|
536
|
+
// conclude that a pre-registered nested repo is absent: unobserved is
|
|
537
|
+
// not absent. Only then do evidence lenses reconcile that census.
|
|
538
|
+
observer.start(root.rootId);
|
|
539
|
+
ensureTrees();
|
|
540
|
+
reconcileInitialRepoWorktrees(root.path);
|
|
541
|
+
flushIfDirty();
|
|
542
|
+
requireLiveWatchers(root.path);
|
|
543
|
+
return { rootPath: root.path, record: rootRecordOf(root.rootId) };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Many explicit paths, one registration primitive. A batch does not invent
|
|
548
|
+
* a second transaction model: every selected root follows the exact same
|
|
549
|
+
* Register → Watch transition as a one-path `add`, in caller order. Input
|
|
550
|
+
* shape is validated before the first registration; thereafter each result
|
|
551
|
+
* is durable before its watcher begins, so a later watcher fault cannot
|
|
552
|
+
* falsely report an all-or-nothing batch after earlier identity committed.
|
|
553
|
+
*
|
|
554
|
+
* The object form is internal-only bootstrap support for remote-first UUID
|
|
555
|
+
* binding. The public CLI/API supplies strings and can never choose UUIDs.
|
|
556
|
+
*/
|
|
557
|
+
function addMany(entries) {
|
|
558
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
559
|
+
throw new Error('addMany requires one or more target paths');
|
|
560
|
+
}
|
|
561
|
+
const normalized = entries.map((entry) => {
|
|
562
|
+
if (typeof entry === 'string') return { targetPath: entry, identityAt: null };
|
|
563
|
+
if (!entry || typeof entry !== 'object' || typeof entry.targetPath !== 'string') {
|
|
564
|
+
throw new Error('addMany entries must be paths or { targetPath, identityAt }');
|
|
565
|
+
}
|
|
566
|
+
if (entry.identityAt !== undefined && entry.identityAt !== null && typeof entry.identityAt !== 'function') {
|
|
567
|
+
throw new Error('addMany identityAt must be a function when supplied');
|
|
568
|
+
}
|
|
569
|
+
return { targetPath: entry.targetPath, identityAt: entry.identityAt ?? null };
|
|
570
|
+
});
|
|
571
|
+
return normalized.map(({ targetPath, identityAt }) => add(targetPath, { identityAt }));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** The boundary's whole convergence pass, one act: loss convicted
|
|
575
|
+
* first (loudly — a conviction is never a retry), edges followed,
|
|
576
|
+
* lenses reconciled, renderings projected. Startup, the explicit
|
|
577
|
+
* call, and the event-scheduled one all run this same code. */
|
|
578
|
+
function flushIfDirty() {
|
|
579
|
+
ensureTrees();
|
|
580
|
+
if (edgesDirty) tryFollow();
|
|
581
|
+
let clean = !edgesDirty;
|
|
582
|
+
for (const lens of lenses.values()) clean = lens.flushIfDirty() && clean;
|
|
583
|
+
maintainReferences(); // renderings follow their targets' current addresses
|
|
584
|
+
// The filesystem lens owns ordinary ground. A repo's detector is its
|
|
585
|
+
// Repocard follower: raw repo doorbells become settled Card + Checkpoint
|
|
586
|
+
// state, then authorize one reconciliation of registered worktree leaves.
|
|
587
|
+
repoFollowers.sync();
|
|
588
|
+
return clean;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Startup is the same act as running — the one convergence pass,
|
|
592
|
+
// nothing less. The projection must not wait for an event: a
|
|
593
|
+
// rendering whose deletion was detected before a crash is equal
|
|
594
|
+
// truth at the catch-up scan — silence — and silence never heals.
|
|
595
|
+
edgesDirty = true;
|
|
596
|
+
flushIfDirty();
|
|
597
|
+
|
|
598
|
+
return {
|
|
599
|
+
// The service-level cloud boundary consumes the logical registry only;
|
|
600
|
+
// observer bindings remain private to this registration composition.
|
|
601
|
+
registry,
|
|
602
|
+
add,
|
|
603
|
+
addMany,
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* `amalgm register <existing-path> --as <address>` — the managed
|
|
607
|
+
* reference, the OTHER link kind (never conflated with a filesystem
|
|
608
|
+
* symlink): created only by this deliberate act, its payload is the
|
|
609
|
+
* TARGET ENTITY'S UUID, and its local rendering as an ordinary
|
|
610
|
+
* symlink at `atPath` is derived state this boundary maintains. The
|
|
611
|
+
* target registers first through the same `add` operation; the
|
|
612
|
+
* reference then lives inside already-registered ground (the Amalgm
|
|
613
|
+
* home), so an observed target move rewrites the rendering while
|
|
614
|
+
* the reference and target UUIDs never change. An unavailable
|
|
615
|
+
* target leaves the rendering dangling — honest — until rescue or
|
|
616
|
+
* reattachment repairs it.
|
|
617
|
+
*/
|
|
618
|
+
addReference(targetPath, atPath) {
|
|
619
|
+
const target = add(targetPath);
|
|
620
|
+
const real = fs.lstatSync(targetPath).isSymbolicLink() ? targetPath : fs.realpathSync(targetPath);
|
|
621
|
+
const targetUuid = resolveGround(real) ?? target.record.uuid;
|
|
622
|
+
const parent = fs.realpathSync(path.dirname(atPath));
|
|
623
|
+
const address = path.join(parent, path.basename(atPath));
|
|
624
|
+
const home = treeRootCovering(parent);
|
|
625
|
+
if (!home || home.kind !== 'files') {
|
|
626
|
+
throw refusal(`a reference lives inside registered workspace ground: ${address} is not covered by one — add its workspace first`);
|
|
627
|
+
}
|
|
628
|
+
const parentRel = parent === home.path ? '' : parent.slice(home.path.length + 1);
|
|
629
|
+
const owner = owningRecordAt(rootRecordOf(home.rootId), parentRel);
|
|
630
|
+
if (!owner) {
|
|
631
|
+
throw new Error(`no registered entity owns ${parent} yet — flush the boundary before placing a reference there`);
|
|
632
|
+
}
|
|
633
|
+
// The deliberate act is IDEMPOTENT: asking for a reference that
|
|
634
|
+
// already exists converges its rendering below instead of refusing
|
|
635
|
+
// — a failed first attempt is retried by repeating it. Any OTHER
|
|
636
|
+
// occupant, record or ground, refuses: one owner per path.
|
|
637
|
+
const existing = registry.activeAt(owner.uuid, path.basename(atPath));
|
|
638
|
+
if (existing && registry.references()
|
|
639
|
+
.find((ref) => ref.record.uuid === existing.uuid)?.targetUUID !== targetUuid) {
|
|
640
|
+
throw refusal(`refusing to render a reference over existing ground at ${address} — one owner per path`);
|
|
641
|
+
}
|
|
642
|
+
if (!existing) {
|
|
643
|
+
let occupied = true;
|
|
644
|
+
try {
|
|
645
|
+
fs.lstatSync(address);
|
|
646
|
+
} catch {
|
|
647
|
+
occupied = false;
|
|
648
|
+
}
|
|
649
|
+
if (occupied) {
|
|
650
|
+
throw refusal(`refusing to render a reference over existing ground at ${address} — one owner per path`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
const record = existing ?? registry.create({
|
|
654
|
+
type: 'reference', parentUUID: owner.uuid, name: path.basename(atPath),
|
|
655
|
+
payloadVersion: targetUuid,
|
|
656
|
+
});
|
|
657
|
+
// The record is canonical; the rendering is the same idempotent
|
|
658
|
+
// projection every flush performs — projected first so the full
|
|
659
|
+
// pass that follows binds the rendering's channel to the record
|
|
660
|
+
// (attesting nothing).
|
|
661
|
+
maintainReferences();
|
|
662
|
+
flushIfDirty();
|
|
663
|
+
return { record, targetUUID: targetUuid };
|
|
664
|
+
},
|
|
665
|
+
|
|
666
|
+
/** INFALLIBLE by construction: route the hint, mark edge news dirty,
|
|
667
|
+
* schedule, return. Wire the observer's emit here. The scheduled
|
|
668
|
+
* pass is the FULL flush — reference maintenance included, so
|
|
669
|
+
* renderings converge after events, never only on an explicit ask —
|
|
670
|
+
* with failures warned and left dirty for the next pass. */
|
|
671
|
+
onEvent(event) {
|
|
672
|
+
if (stopped) return;
|
|
673
|
+
repoFollowers.onObserverEvent(event);
|
|
674
|
+
if (EDGE_EVENTS.has(event.type)) edgesDirty = true;
|
|
675
|
+
notifyEventOwner(event);
|
|
676
|
+
if (scheduled) return;
|
|
677
|
+
scheduled = true;
|
|
678
|
+
const immediate = setImmediate(() => {
|
|
679
|
+
scheduled = false;
|
|
680
|
+
if (stopped) return; // the world closed under an already-queued pass
|
|
681
|
+
try {
|
|
682
|
+
flushIfDirty();
|
|
683
|
+
} catch (error) {
|
|
684
|
+
console.warn('[Registration] scheduled flush failed (stays dirty):', error?.message || error);
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
immediate.unref?.();
|
|
688
|
+
},
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Shut down: schedule no more work, and honor no more hints —
|
|
692
|
+
* EVERY lens included. The boundary owns no handles (its stores
|
|
693
|
+
* belong to whoever opened them) but it and its lenses each own a
|
|
694
|
+
* pending pass, and a pass that lands after those databases close
|
|
695
|
+
* would warn about a shutdown that went exactly right. One stop
|
|
696
|
+
* reaches all of them, because the boundary is what created them.
|
|
697
|
+
* Dirty state stays dirty on purpose: the next boundary over this
|
|
698
|
+
* ground reconciles it at birth, which is the same act.
|
|
699
|
+
*/
|
|
700
|
+
stop() {
|
|
701
|
+
stopped = true;
|
|
702
|
+
repoFollowers.stop();
|
|
703
|
+
for (const lens of lenses.values()) lens.stop();
|
|
704
|
+
},
|
|
705
|
+
|
|
706
|
+
flushIfDirty,
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Derived, never stored: what each link's written address currently
|
|
710
|
+
* reaches in entity space (link uuid → owning uuid, or null), across
|
|
711
|
+
* EVERY registered tree. Resolution is the OS's own — realpath,
|
|
712
|
+
* chains and all — then the real ground's tree answers for the
|
|
713
|
+
* address with the entity that OWNS it: the exact record at the address.
|
|
714
|
+
* A repository boundary changes its realtime semantics, not the child
|
|
715
|
+
* identity map. It dies honestly when
|
|
716
|
+
* the target moves out from under the text: amalgm remembers the
|
|
717
|
+
* pointer, never what it used to reach. null = broken or
|
|
718
|
+
* unregistered ground.
|
|
719
|
+
*/
|
|
720
|
+
linkTargets() {
|
|
721
|
+
const out = new Map();
|
|
722
|
+
for (const root of observer.listRoots()) {
|
|
723
|
+
const home = treeRootCovering(root.path);
|
|
724
|
+
const lens = home ? lenses.get(home.rootId) : null;
|
|
725
|
+
if (!lens) continue;
|
|
726
|
+
for (const row of observer.linksForRoot(root.rootId)) {
|
|
727
|
+
const uuid = lens.boundUuid(row.linkId);
|
|
728
|
+
if (uuid === undefined) continue;
|
|
729
|
+
let real;
|
|
730
|
+
try {
|
|
731
|
+
real = fs.realpathSync(path.resolve(edgeBase(root, row.relPath), row.target));
|
|
732
|
+
} catch {
|
|
733
|
+
out.set(uuid, null); // broken: the derived resolution is dead, the record is not
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
out.set(uuid, resolveGround(real));
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return out;
|
|
740
|
+
},
|
|
741
|
+
|
|
742
|
+
/** The latest edge pass's refusals — visible, never silent. */
|
|
743
|
+
reported: () => [...reported],
|
|
744
|
+
repoState: (uuid) => repoStates.get(uuid),
|
|
745
|
+
tree: treeAt,
|
|
746
|
+
trees: () => new Map(treeRoots()
|
|
747
|
+
.filter((root) => lenses.has(root.rootId))
|
|
748
|
+
.map((root) => [root.path, treeAt(root.path)])),
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
module.exports = { createRegistration };
|