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,1031 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The operation model — one of the oracle's two independent truths
|
|
5
|
+
* (docs/entity-model.md; the other is the disk referee in referee.js).
|
|
6
|
+
*
|
|
7
|
+
* The test performs a real filesystem operation and records it here. The
|
|
8
|
+
* model keeps its own simulated disk (paths → content hash + a physical
|
|
9
|
+
* continuity tag standing in for device:inode, for directories too) and, at
|
|
10
|
+
* each settle point, applies the written identity axioms to the NET state —
|
|
11
|
+
* never per-op, because identity is only ever resolved at proven quiet
|
|
12
|
+
* points:
|
|
13
|
+
*
|
|
14
|
+
* 1. Address and physical continuity are the two identity witnesses, and
|
|
15
|
+
* KIND decides which testifies first. For files, path claims the
|
|
16
|
+
* channel — an atomic save replaces the physical file under a
|
|
17
|
+
* standing address, so the address must absorb that churn — and
|
|
18
|
+
* exactly-one physical continuity rescues path-orphans. For
|
|
19
|
+
* directories the same rules run in the OPPOSITE order: nothing
|
|
20
|
+
* replaces a directory's inode in place, so physical continuity
|
|
21
|
+
* claims first (two directories exchanging names cross by inode,
|
|
22
|
+
* exactly like the files inside them) and the address claims only
|
|
23
|
+
* recreation — a never-seen inode at a known address is the same
|
|
24
|
+
* channel wearing a new tag.
|
|
25
|
+
* 2. Continuity moves a record — exactly one candidate on each side, or
|
|
26
|
+
* nothing is guessed — and only onto UNOWNED ground.
|
|
27
|
+
* Continuity arriving on a parked ACTIVE owner's slot is CONTESTED
|
|
28
|
+
* GROUND: two established identity claims on one address, and the
|
|
29
|
+
* model picks no winner — neither record moves, the ground is
|
|
30
|
+
* reported divergence, carried by ADDRESS continuity within the
|
|
31
|
+
* ground's kind (an atomic replace keeps it, exactly like path
|
|
32
|
+
* claims; a file replaced by a directory has left) until the ground
|
|
33
|
+
* leaves the address or an explicit identity-carrying action
|
|
34
|
+
* resolves it. Reappearance at the claimant's old address resolves
|
|
35
|
+
* nothing — presence proves nothing about identity — and losing
|
|
36
|
+
* evidence never resolves a contest. Folder ground carries a channel
|
|
37
|
+
* now, so the folder half of this rule is LIVE — and an address that
|
|
38
|
+
* runs through contested ground is itself contested for whatever
|
|
39
|
+
* claimant sits there: a moved subtree's children cannot be lawfully
|
|
40
|
+
* addressed while their parent's ground is undecided.
|
|
41
|
+
* 3. Leftover appearances are births. A leftover disappearance is
|
|
42
|
+
* EVIDENCE, never lifecycle: the channel closes, the entity leaves
|
|
43
|
+
* local ground, and its record does not move. Status (active |
|
|
44
|
+
* trashed | deleted) is registry law, changed only by explicit owner
|
|
45
|
+
* actions — trash, restore, empty-trash — which detection may never
|
|
46
|
+
* infer from disk. No explicit action exists in these tests, so every
|
|
47
|
+
* record here stays `active` forever.
|
|
48
|
+
* 4. An ACTIVE entity owns its (parent, name) slot, present or absent —
|
|
49
|
+
* one parent has at most one active child with a given name. Ground
|
|
50
|
+
* reappearing at the slot reconnects to the same uuid — WHATEVER kind
|
|
51
|
+
* it returns as: ownership is identity, not kind, so folder ground at
|
|
52
|
+
* a file-owned slot (or the reverse) is a type transition on the same
|
|
53
|
+
* uuid, even when the swap happens inside a single interval. New bytes
|
|
54
|
+
* are absorbed as the owner's edit (production accepts them or resets
|
|
55
|
+
* from canonical). Only an explicit trash/delete vacates a slot for a
|
|
56
|
+
* new entity — and no explicit action exists in these tests, so
|
|
57
|
+
* reappearance here always reconnects. Restoration from trash stays
|
|
58
|
+
* explicit, never inferred.
|
|
59
|
+
* 5. Git is a LENS, not a lifecycle boundary: a .git appearing flips the
|
|
60
|
+
* folder's type to repo.git on the SAME uuid — including at the
|
|
61
|
+
* enrolled root, where the record flips workspace ↔ repo.git — and
|
|
62
|
+
* every entity under it keeps existing with its identity. Exactly one
|
|
63
|
+
* adapter owns detection at a time: under the lens the repo adapter
|
|
64
|
+
* owns the subtree; lift the lens and file detection returns. Covered
|
|
65
|
+
* ground is therefore UNWITNESSED by leaf detection — no leaf entity
|
|
66
|
+
* is born under the lens, and physical continuity never crosses the
|
|
67
|
+
* boundary. Entities whose ground the lens covers park with their
|
|
68
|
+
* records untouched; the slot law reconnects them when the lens
|
|
69
|
+
* lifts, and ground first written under the lens surfaces as a NEW
|
|
70
|
+
* entity at the lift. (Today's detector re-mints its low-level
|
|
71
|
+
* channels at these boundaries; channels are evidence, and the judge
|
|
72
|
+
* absorbs that at the binder, never in the model.)
|
|
73
|
+
* 6. Only CONTAINERS — workspace, folder, repo.git — hold active
|
|
74
|
+
* children. Identity survives every VALID type transition; a folder
|
|
75
|
+
* that still has stored-active children may not become a file, because
|
|
76
|
+
* accepting that flip would canonicalize the subtree destruction that
|
|
77
|
+
* axiom 3 already refused to infer. File ground landing on such a slot
|
|
78
|
+
* is DIVERGENCE: reported honestly (the channel and its events are
|
|
79
|
+
* real), claimed by no entity, canonical records unmoved, pending
|
|
80
|
+
* explicit resolution — amalgm resets local from canonical, or the
|
|
81
|
+
* owner moves/trashes the children first.
|
|
82
|
+
* 7. A LINK is leaf ground whose whole payload is its target exactly as
|
|
83
|
+
* written — never what it reaches. Its identity follows the FILE
|
|
84
|
+
* rules for the file reason: a symlink cannot be retargeted in place
|
|
85
|
+
* (retarget is unlink + recreate), so the address absorbs that churn
|
|
86
|
+
* and claims first, and exactly-one physical continuity rescues
|
|
87
|
+
* path-orphans (`mv` preserves a symlink's inode). Links are a THIRD
|
|
88
|
+
* ground kind: continuity never crosses kinds, a contest carries by
|
|
89
|
+
* address continuity within link kind, and slot ownership stays
|
|
90
|
+
* identity-not-kind — ground of any kind returning at an owned slot
|
|
91
|
+
* is a type transition on the same uuid, container guard included.
|
|
92
|
+
*
|
|
93
|
+
* Every settled entity is EXACTLY the six-field universal record —
|
|
94
|
+
* { uuid, type, parentUUID, name, version, status } — with folders resolved
|
|
95
|
+
* first so files derive real parent UUIDs. Payloads (content hash,
|
|
96
|
+
* membership, the repo card) are adapter EVIDENCE, returned in a separate
|
|
97
|
+
* map keyed by uuid, never stored inside the record. Versions follow the
|
|
98
|
+
* ratified formula hash(type, parentUUID, name, status, payloadVersion); a
|
|
99
|
+
* container's payload is its sorted STORED-active direct membership as
|
|
100
|
+
* (childUUID, name) pairs. A locally-absent entity is still an active
|
|
101
|
+
* child — its restore is the registry's business — which is why a
|
|
102
|
+
* disappearance changes NO record at all. repo.git payload is null until
|
|
103
|
+
* the card/checkpoint evidence is adapted (a documented gate, not a
|
|
104
|
+
* formula).
|
|
105
|
+
*
|
|
106
|
+
* File type (file.text | file.binary) comes from the INJECTED classify
|
|
107
|
+
* policy, exactly like enrollment comes from the injected shouldEnroll:
|
|
108
|
+
* explicit policy decides, the model never owns a heuristic. A classify
|
|
109
|
+
* flip is a type transition on the same uuid.
|
|
110
|
+
*
|
|
111
|
+
* Truth is tracked per address between settles and absorbed into the owning
|
|
112
|
+
* entity at each settle; the just-closed interval stays queryable per
|
|
113
|
+
* address, so even a channel born and dead between settles is judged at the
|
|
114
|
+
* address it claimed — no global pool.
|
|
115
|
+
*
|
|
116
|
+
* KNOWN GATE: enrollment inside repo ground uses the root-relative policy
|
|
117
|
+
* while the boundary census re-scopes per boundary — settles with the
|
|
118
|
+
* registry.
|
|
119
|
+
*
|
|
120
|
+
* Deliberately shares no code with the detector: this file must not require
|
|
121
|
+
* anything from observer/ or repocard/ (enforced by a meta-test).
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
const crypto = require('crypto');
|
|
125
|
+
|
|
126
|
+
function sha256(bytes) {
|
|
127
|
+
return crypto.createHash('sha256').update(bytes).digest('hex');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function createModel({ shouldEnroll, classify }) {
|
|
131
|
+
if (typeof shouldEnroll !== 'function' || typeof classify !== 'function') {
|
|
132
|
+
throw new Error('createModel requires the injected enrollment and classification policies');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let nextNode = 1;
|
|
136
|
+
let nextTag = 1;
|
|
137
|
+
|
|
138
|
+
// Simulated disk.
|
|
139
|
+
const diskFiles = new Map(); // relPath -> { hash, tag, type }
|
|
140
|
+
const diskLinks = new Map(); // relPath -> { target, tag }
|
|
141
|
+
const diskDirs = new Map(); // relDir -> { tag } ('' is the workspace, never listed)
|
|
142
|
+
const gitDirs = new Set(); // dirs holding a .git ('' = the root itself)
|
|
143
|
+
|
|
144
|
+
// Settled entities. Nodes that leave the live maps stay forever; frozen
|
|
145
|
+
// holds the record fields as of the settle that parked them.
|
|
146
|
+
const workspace = { uuid: `w${nextNode++}` };
|
|
147
|
+
const fileNodes = new Map(); // uuid -> { uuid, relPath, hash, tag, type, status, history:Set, frozen? }
|
|
148
|
+
const fileByPath = new Map(); // relPath -> uuid, on-disk only
|
|
149
|
+
const linkNodes = new Map(); // uuid -> { uuid, relPath, target, tag, type: 'link', status, history:Set, frozen? }
|
|
150
|
+
const linkByPath = new Map(); // relPath -> uuid, on-disk only
|
|
151
|
+
const folderNodes = new Map(); // uuid -> { uuid, relDir, tag, type, status, frozen? }
|
|
152
|
+
const folderByPath = new Map(); // relDir -> uuid, on-disk only
|
|
153
|
+
|
|
154
|
+
// Truth: per-address for the open interval, absorbed per-entity at
|
|
155
|
+
// settle; the just-closed interval stays queryable for channels that
|
|
156
|
+
// never lived long enough to settle. Link ground keeps its own truth
|
|
157
|
+
// space — a pointer's payload is an address, never a content hash.
|
|
158
|
+
let pathTruth = new Map(); // relPath -> Set(hash)
|
|
159
|
+
let lastTruth = new Map();
|
|
160
|
+
let linkTruth = new Map(); // relPath -> Set(target)
|
|
161
|
+
let lastLinkTruth = new Map();
|
|
162
|
+
|
|
163
|
+
// Leaf addresses that held visible same-kind ground as of the last
|
|
164
|
+
// settle — entity ground, divergence, and contested ground alike. The
|
|
165
|
+
// address absorbs replacement at the CHANNEL layer: ground arriving
|
|
166
|
+
// where same-kind ground already stood is absorbed by whatever channel
|
|
167
|
+
// stands there, so the arriving file's own channel dies — physical
|
|
168
|
+
// continuity cannot rescue a node ONTO standing ground.
|
|
169
|
+
let leafGroundAtOpen = { file: new Set(), link: new Set() };
|
|
170
|
+
|
|
171
|
+
// Contested ground: two identity claims on one address (axiom 2 meeting
|
|
172
|
+
// axiom 4). Keyed by address, valued with the claimant, the ground's
|
|
173
|
+
// physical tag — bookkeeping for the claimant's still-open channel,
|
|
174
|
+
// refreshed while the contest stands — and the ground's KIND, because a
|
|
175
|
+
// contest carries by address continuity within its kind only.
|
|
176
|
+
const contestedGround = new Map(); // relPath -> { uuid, tag, kind: 'file' | 'dir' }
|
|
177
|
+
let contestedAtOpen = new Map(); // relPath -> claimant uuid, as the interval opened
|
|
178
|
+
|
|
179
|
+
/** Does this address run through contested ground (itself included)? */
|
|
180
|
+
function underContest(relPath) {
|
|
181
|
+
const parts = relPath.split('/');
|
|
182
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
183
|
+
if (contestedGround.has(parts.slice(0, i).join('/'))) return true;
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Policy prunes at directories: every ancestor must enroll. */
|
|
189
|
+
function enrolledRel(relPath) {
|
|
190
|
+
const parts = relPath.split('/');
|
|
191
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
192
|
+
if (!shouldEnroll(parts.slice(0, i).join('/'))) return false;
|
|
193
|
+
}
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function noteTruth(relPath, hash) {
|
|
198
|
+
if (!enrolledRel(relPath)) return;
|
|
199
|
+
if (!pathTruth.has(relPath)) pathTruth.set(relPath, new Set());
|
|
200
|
+
pathTruth.get(relPath).add(hash);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function noteLinkTruth(relPath, target) {
|
|
204
|
+
if (!enrolledRel(relPath)) return;
|
|
205
|
+
if (!linkTruth.has(relPath)) linkTruth.set(relPath, new Set());
|
|
206
|
+
linkTruth.get(relPath).add(target);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const nameOf = (relPath) => relPath.slice(relPath.lastIndexOf('/') + 1);
|
|
210
|
+
const parentDirOf = (relPath) => {
|
|
211
|
+
const cut = relPath.lastIndexOf('/');
|
|
212
|
+
return cut === -1 ? null : relPath.slice(0, cut);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** mkdir -p semantics: every missing strict ancestor gets a dir entry. */
|
|
216
|
+
function ensureAncestors(relPath) {
|
|
217
|
+
const parts = relPath.split('/');
|
|
218
|
+
for (let i = 1; i < parts.length; i++) {
|
|
219
|
+
const dir = parts.slice(0, i).join('/');
|
|
220
|
+
if (!diskDirs.has(dir)) diskDirs.set(dir, { tag: nextTag++ });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Every repo boundary visible to detection, at any depth. Within each
|
|
226
|
+
* base (the root, or an enclosing boundary), only minimal boundaries are
|
|
227
|
+
* its own; deeper ones belong to the child's census, with the enrollment
|
|
228
|
+
* policy re-applied relative to the child — the root-relative contract
|
|
229
|
+
* each root's census obeys.
|
|
230
|
+
*/
|
|
231
|
+
function territoriesUnder(baseDir) {
|
|
232
|
+
const relTo = (dir) => (baseDir === '' ? dir : dir.slice(baseDir.length + 1));
|
|
233
|
+
const under = [...gitDirs].filter(
|
|
234
|
+
(dir) => dir !== baseDir && (baseDir === '' || dir.startsWith(`${baseDir}/`)),
|
|
235
|
+
);
|
|
236
|
+
const minimal = under
|
|
237
|
+
.filter((dir) => !under.some((other) => other !== dir && dir.startsWith(`${other}/`)))
|
|
238
|
+
.filter((dir) => enrolledScoped(relTo(dir)));
|
|
239
|
+
const all = [];
|
|
240
|
+
for (const dir of minimal) all.push(dir, ...territoriesUnder(dir));
|
|
241
|
+
return all;
|
|
242
|
+
|
|
243
|
+
function enrolledScoped(rel) {
|
|
244
|
+
const parts = rel.split('/');
|
|
245
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
246
|
+
if (!shouldEnroll(parts.slice(0, i).join('/'))) return false;
|
|
247
|
+
}
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function visibleTerritories() {
|
|
253
|
+
return gitDirs.has('')
|
|
254
|
+
? new Set(['', ...territoriesUnder('')])
|
|
255
|
+
: new Set(territoriesUnder(''));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
// --- op recorders: call each one AFTER performing the real fs op ------
|
|
260
|
+
|
|
261
|
+
/** In-place write: the physical file (its tag) survives. */
|
|
262
|
+
write(relPath, bytes) {
|
|
263
|
+
ensureAncestors(relPath);
|
|
264
|
+
const hash = sha256(bytes);
|
|
265
|
+
const type = classify(bytes);
|
|
266
|
+
const existing = diskFiles.get(relPath);
|
|
267
|
+
if (existing) {
|
|
268
|
+
existing.hash = hash;
|
|
269
|
+
existing.type = type;
|
|
270
|
+
} else {
|
|
271
|
+
diskFiles.set(relPath, { hash, tag: nextTag++, type });
|
|
272
|
+
}
|
|
273
|
+
noteTruth(relPath, hash);
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
/** Atomic replacement: a new physical file lands on the address. */
|
|
277
|
+
replace(relPath, bytes) {
|
|
278
|
+
ensureAncestors(relPath);
|
|
279
|
+
const hash = sha256(bytes);
|
|
280
|
+
diskFiles.set(relPath, { hash, tag: nextTag++, type: classify(bytes) });
|
|
281
|
+
noteTruth(relPath, hash);
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
/** mtime moved, bytes did not: nothing to record. */
|
|
285
|
+
touch() {},
|
|
286
|
+
|
|
287
|
+
mkdir(relDir) {
|
|
288
|
+
ensureAncestors(`${relDir}/.`);
|
|
289
|
+
if (!diskDirs.has(relDir)) diskDirs.set(relDir, { tag: nextTag++ });
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
rename(from, to) {
|
|
293
|
+
const entry = diskFiles.get(from);
|
|
294
|
+
if (!entry) throw new Error(`model: rename of unknown path ${from}`);
|
|
295
|
+
ensureAncestors(to);
|
|
296
|
+
diskFiles.delete(from);
|
|
297
|
+
diskFiles.set(to, entry);
|
|
298
|
+
noteTruth(to, entry.hash);
|
|
299
|
+
},
|
|
300
|
+
|
|
301
|
+
renameDir(from, to) {
|
|
302
|
+
if (!diskDirs.has(from)) throw new Error(`model: renameDir of unknown dir ${from}`);
|
|
303
|
+
ensureAncestors(`${to}/.`);
|
|
304
|
+
const moveKey = (key) => `${to}${key.slice(from.length)}`;
|
|
305
|
+
for (const [relDir, entry] of [...diskDirs]) {
|
|
306
|
+
if (relDir !== from && !relDir.startsWith(`${from}/`)) continue;
|
|
307
|
+
diskDirs.delete(relDir);
|
|
308
|
+
diskDirs.set(moveKey(relDir), entry);
|
|
309
|
+
}
|
|
310
|
+
for (const [relPath, entry] of [...diskFiles]) {
|
|
311
|
+
if (!relPath.startsWith(`${from}/`)) continue;
|
|
312
|
+
diskFiles.delete(relPath);
|
|
313
|
+
const movedPath = moveKey(relPath);
|
|
314
|
+
diskFiles.set(movedPath, entry);
|
|
315
|
+
noteTruth(movedPath, entry.hash);
|
|
316
|
+
}
|
|
317
|
+
for (const [relPath, entry] of [...diskLinks]) {
|
|
318
|
+
if (!relPath.startsWith(`${from}/`)) continue;
|
|
319
|
+
diskLinks.delete(relPath);
|
|
320
|
+
const movedPath = moveKey(relPath);
|
|
321
|
+
diskLinks.set(movedPath, entry);
|
|
322
|
+
noteLinkTruth(movedPath, entry.target);
|
|
323
|
+
}
|
|
324
|
+
for (const dir of [...gitDirs]) {
|
|
325
|
+
if (dir !== from && !dir.startsWith(`${from}/`)) continue;
|
|
326
|
+
gitDirs.delete(dir);
|
|
327
|
+
gitDirs.add(moveKey(dir));
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
remove(relPath) {
|
|
332
|
+
if (!diskFiles.delete(relPath)) throw new Error(`model: remove of unknown path ${relPath}`);
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Link ground. A written pointer always lands on a fresh inode —
|
|
337
|
+
* creation and retarget alike (retarget IS unlink + recreate) — so
|
|
338
|
+
* every write wears a new tag; only `mv` preserves one.
|
|
339
|
+
*/
|
|
340
|
+
writeLink(relPath, target) {
|
|
341
|
+
ensureAncestors(relPath);
|
|
342
|
+
diskLinks.set(relPath, { target, tag: nextTag++ });
|
|
343
|
+
noteLinkTruth(relPath, target);
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
renameLink(from, to) {
|
|
347
|
+
const entry = diskLinks.get(from);
|
|
348
|
+
if (!entry) throw new Error(`model: renameLink of unknown path ${from}`);
|
|
349
|
+
ensureAncestors(to);
|
|
350
|
+
diskLinks.delete(from);
|
|
351
|
+
diskLinks.set(to, entry);
|
|
352
|
+
noteLinkTruth(to, entry.target);
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
removeLink(relPath) {
|
|
356
|
+
if (!diskLinks.delete(relPath)) throw new Error(`model: removeLink of unknown path ${relPath}`);
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
removeDir(relDir) {
|
|
360
|
+
for (const relPath of [...diskFiles.keys()]) {
|
|
361
|
+
if (relPath.startsWith(`${relDir}/`)) diskFiles.delete(relPath);
|
|
362
|
+
}
|
|
363
|
+
for (const relPath of [...diskLinks.keys()]) {
|
|
364
|
+
if (relPath.startsWith(`${relDir}/`)) diskLinks.delete(relPath);
|
|
365
|
+
}
|
|
366
|
+
for (const dir of [...diskDirs.keys()]) {
|
|
367
|
+
if (dir === relDir || dir.startsWith(`${relDir}/`)) diskDirs.delete(dir);
|
|
368
|
+
}
|
|
369
|
+
for (const dir of [...gitDirs]) {
|
|
370
|
+
if (dir === relDir || dir.startsWith(`${relDir}/`)) gitDirs.delete(dir);
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
gitInit(relDir) {
|
|
375
|
+
if (relDir !== '' && !diskDirs.has(relDir)) diskDirs.set(relDir, { tag: nextTag++ });
|
|
376
|
+
gitDirs.add(relDir);
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
gitRemove(relDir) {
|
|
380
|
+
gitDirs.delete(relDir);
|
|
381
|
+
},
|
|
382
|
+
|
|
383
|
+
// --- settle: apply the axioms to the net state ------------------------
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Resolve identity for everything that happened since the last settle
|
|
387
|
+
* and return the expected snapshot: six-field records for every entity
|
|
388
|
+
* ever settled, the payload evidence map, the local-presence evidence,
|
|
389
|
+
* the exposed (non-repo-ground) file slice the adapter compares, and
|
|
390
|
+
* the territory census. Git never partitions lifecycle here — every
|
|
391
|
+
* enrolled path resolves under one set of rules, and the lens only
|
|
392
|
+
* decides types and exposure. Mirrors nothing: this is the axioms
|
|
393
|
+
* applied a second time, independently.
|
|
394
|
+
*/
|
|
395
|
+
settle() {
|
|
396
|
+
// The contest attributions as the interval OPENED, kept beside the
|
|
397
|
+
// closing state below: events are judged after the settle, but a
|
|
398
|
+
// contest moves with its ground mid-interval, so an honest event at
|
|
399
|
+
// the old address must answer with the boundary it fired under.
|
|
400
|
+
contestedAtOpen = new Map([...contestedGround].map(([relPath, mark]) => [relPath, mark.uuid]));
|
|
401
|
+
|
|
402
|
+
// Directory addressing as the interval OPENED. A departing record
|
|
403
|
+
// moves not one field, so its frozen parent is the entity that
|
|
404
|
+
// held its parent address at the LAST settle — never the closing
|
|
405
|
+
// state, where that dir may have moved away (or another moved in).
|
|
406
|
+
const dirsAtOpen = new Map(folderByPath);
|
|
407
|
+
|
|
408
|
+
const territories = visibleTerritories();
|
|
409
|
+
|
|
410
|
+
// Covered ground is unwitnessed: leaf detection cannot see under the
|
|
411
|
+
// lens, so covered addresses take no part in any identity phase —
|
|
412
|
+
// their owners park at the drop, reconnect by slot at the lift, and
|
|
413
|
+
// ground first written under the lens is nobody until it surfaces.
|
|
414
|
+
// (A territory's own dir stays visible: it IS the repo's ground.)
|
|
415
|
+
const covered = (relPath) => {
|
|
416
|
+
for (const dir of territories) {
|
|
417
|
+
if (dir === '' || relPath.startsWith(`${dir}/`)) return true;
|
|
418
|
+
}
|
|
419
|
+
return false;
|
|
420
|
+
};
|
|
421
|
+
const visibleFiles = new Map();
|
|
422
|
+
for (const [relPath, entry] of diskFiles) {
|
|
423
|
+
if (enrolledRel(relPath) && !covered(relPath)) visibleFiles.set(relPath, entry);
|
|
424
|
+
}
|
|
425
|
+
const visibleLinks = new Map();
|
|
426
|
+
for (const [relPath, entry] of diskLinks) {
|
|
427
|
+
if (enrolledRel(relPath) && !covered(relPath)) visibleLinks.set(relPath, entry);
|
|
428
|
+
}
|
|
429
|
+
const visibleDirs = new Map();
|
|
430
|
+
for (const [relDir, entry] of diskDirs) {
|
|
431
|
+
if (enrolledRel(relDir) && !covered(relDir)) visibleDirs.set(relDir, entry);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const folderType = (relDir) => (territories.has(relDir) ? 'repo.git' : 'folder');
|
|
435
|
+
|
|
436
|
+
// Contested ground carries by ADDRESS continuity within its KIND,
|
|
437
|
+
// before anything else is judged: ground of the mark's kind still at
|
|
438
|
+
// a marked address stays contested (an atomic replace only refreshes
|
|
439
|
+
// the tag its open channel now wears; ground swapping kind has
|
|
440
|
+
// left). Ground gone from a marked address ends that contest — and
|
|
441
|
+
// the claimant's channel never closed, so its continuity may resume:
|
|
442
|
+
// a still-parked claimant rejoins this settle's rescue pool of its
|
|
443
|
+
// kind, while a re-grounded claimant's SURPLUS channel drags the
|
|
444
|
+
// contest along to wherever its physical tag landed (exactly one,
|
|
445
|
+
// or nothing).
|
|
446
|
+
const rescueEligible = [];
|
|
447
|
+
const linkRescueEligible = [];
|
|
448
|
+
const folderRescueEligible = [];
|
|
449
|
+
const KIND = {
|
|
450
|
+
file: { ground: visibleFiles, nodes: fileNodes, byPath: fileByPath, rescue: rescueEligible },
|
|
451
|
+
link: { ground: visibleLinks, nodes: linkNodes, byPath: linkByPath, rescue: linkRescueEligible },
|
|
452
|
+
dir: { ground: visibleDirs, nodes: folderNodes, byPath: folderByPath, rescue: folderRescueEligible },
|
|
453
|
+
};
|
|
454
|
+
for (const [relPath, mark] of [...contestedGround]) {
|
|
455
|
+
const kind = KIND[mark.kind];
|
|
456
|
+
const entry = kind.ground.get(relPath);
|
|
457
|
+
const node = kind.nodes.get(mark.uuid);
|
|
458
|
+
if (entry) {
|
|
459
|
+
mark.tag = entry.tag;
|
|
460
|
+
if (node?.frozen) node.tag = entry.tag;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
contestedGround.delete(relPath);
|
|
464
|
+
if (!node) continue;
|
|
465
|
+
if (node.frozen) {
|
|
466
|
+
kind.rescue.push(node);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const landed = [...kind.ground]
|
|
470
|
+
.filter(([p, e]) => e.tag === mark.tag && !kind.byPath.has(p)
|
|
471
|
+
&& !(mark.kind !== 'dir' && leafGroundAtOpen[mark.kind].has(p)));
|
|
472
|
+
if (landed.length === 1) {
|
|
473
|
+
contestedGround.set(landed[0][0], { uuid: mark.uuid, tag: mark.tag, kind: mark.kind });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const absorb = (node, relPath) => {
|
|
478
|
+
const truths = pathTruth.get(relPath);
|
|
479
|
+
if (truths) for (const hash of truths) node.history.add(hash);
|
|
480
|
+
};
|
|
481
|
+
const absorbLink = (node, relPath) => {
|
|
482
|
+
const truths = linkTruth.get(relPath);
|
|
483
|
+
if (truths) for (const target of truths) node.history.add(target);
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
// The occupant law, consulted by every rescue guard (phase 1) and
|
|
487
|
+
// the slot phase (phase 3), kind-blind: who owns this (parent, name)
|
|
488
|
+
// SLOT? An active record owns its slot wherever its ground stands —
|
|
489
|
+
// still on it, gone this very interval (the record has not moved),
|
|
490
|
+
// or parked settles ago. A node's slot is its RECORD's slot: the
|
|
491
|
+
// frozen fields once parked; the settled folder graph once this
|
|
492
|
+
// settle resolved it; the OPENING state while it is still pending —
|
|
493
|
+
// because records move only when continuity moves them, never by an
|
|
494
|
+
// address string drifting underneath. The asked address is spelled
|
|
495
|
+
// through the settled folder graph, so a parent renamed this very
|
|
496
|
+
// interval still spells the same slot on both sides.
|
|
497
|
+
const settledNodes = new Set();
|
|
498
|
+
const slotOfNode = (node) => {
|
|
499
|
+
if (node.frozen) return [node.frozen.parentUUID, node.frozen.name];
|
|
500
|
+
const rel = node.relDir ?? node.relPath;
|
|
501
|
+
const parent = parentDirOf(rel);
|
|
502
|
+
if (parent === null) return [workspace.uuid, nameOf(rel)];
|
|
503
|
+
return [(settledNodes.has(node) ? folderByPath : dirsAtOpen).get(parent), nameOf(rel)];
|
|
504
|
+
};
|
|
505
|
+
const occupantAt = (relPath) => {
|
|
506
|
+
const parent = parentDirOf(relPath);
|
|
507
|
+
const destParent = parent === null ? workspace.uuid : folderByPath.get(parent);
|
|
508
|
+
if (destParent === undefined) return undefined;
|
|
509
|
+
const destName = nameOf(relPath);
|
|
510
|
+
for (const node of [...folderNodes.values(), ...fileNodes.values(), ...linkNodes.values()]) {
|
|
511
|
+
const [slotParent, slotName] = slotOfNode(node);
|
|
512
|
+
if (slotParent === destParent && slotName === destName) return node;
|
|
513
|
+
}
|
|
514
|
+
return undefined;
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
// --- phase 1: match what continued. For FILES, path claims first —
|
|
518
|
+
// an atomic save replaces the physical file under a standing address,
|
|
519
|
+
// so the address absorbs that churn — then exactly-one tag rescues
|
|
520
|
+
// path-orphans, never across kinds (ground kind is a disk fact).
|
|
521
|
+
// DIRECTORIES apply the same two rules in the OPPOSITE order, for the
|
|
522
|
+
// mirror reason: nothing replaces a directory's inode in place, so
|
|
523
|
+
// physical continuity claims first — a known tag at a new address IS
|
|
524
|
+
// the directory, moved, even when its old address was reoccupied in
|
|
525
|
+
// the same interval (two directories exchanging names cross by tag,
|
|
526
|
+
// exactly like the files inside them) — and the address claims only
|
|
527
|
+
// what no tag claims: recreation under an old name, the same channel
|
|
528
|
+
// wearing a new tag. What neither rule matched falls through:
|
|
529
|
+
// unmatched ground to the slot phase, unmatched nodes to departure.
|
|
530
|
+
// No move lands on another's slot: a parked ACTIVE owner there —
|
|
531
|
+
// parked in an earlier settle or departing in this very one — is a
|
|
532
|
+
// CONTEST, the ground divergence, the claimant parked, nobody
|
|
533
|
+
// winning by address. Directory movers may hold each other's
|
|
534
|
+
// addresses, so they resolve in rounds: whoever's ground is free
|
|
535
|
+
// moves, and when nobody's is, the waiters cross — vacate all, land
|
|
536
|
+
// all.
|
|
537
|
+
const folderAppearances = []; // parents first, so children derive live ground
|
|
538
|
+
const departingFolders = [];
|
|
539
|
+
{
|
|
540
|
+
const liveNodes = [...folderByPath.values()].map((uuid) => folderNodes.get(uuid));
|
|
541
|
+
const nodesByTag = new Map();
|
|
542
|
+
// A territory's dir carries NO dir channel — its physical tag is
|
|
543
|
+
// unwitnessed, so it cannot rescue onto a new address (it still
|
|
544
|
+
// continues IN PLACE: the address-claim step below owns that).
|
|
545
|
+
for (const node of [...liveNodes, ...folderRescueEligible]) {
|
|
546
|
+
if (node.type === 'repo.git') continue;
|
|
547
|
+
nodesByTag.set(node.tag, nodesByTag.has(node.tag) ? 'many' : node);
|
|
548
|
+
}
|
|
549
|
+
const dirsByTag = new Map();
|
|
550
|
+
// Territory ground is just as unwitnessed on the entry side: a dir
|
|
551
|
+
// that became (or stayed) a boundary offers no dir channel, so its
|
|
552
|
+
// tag can testify for nobody — the address-claim step and the slot
|
|
553
|
+
// phase own whatever stands there.
|
|
554
|
+
for (const [relDir, entry] of visibleDirs) {
|
|
555
|
+
if (territories.has(relDir)) continue;
|
|
556
|
+
dirsByTag.set(entry.tag, dirsByTag.has(entry.tag) ? 'many' : { relDir, entry });
|
|
557
|
+
}
|
|
558
|
+
const consumedNodes = new Set();
|
|
559
|
+
const consumedDirs = new Set();
|
|
560
|
+
const candidates = [];
|
|
561
|
+
for (const [tag, found] of dirsByTag) {
|
|
562
|
+
const node = nodesByTag.get(tag);
|
|
563
|
+
if (found === 'many' || !node || node === 'many') continue;
|
|
564
|
+
consumedNodes.add(node);
|
|
565
|
+
consumedDirs.add(found.relDir);
|
|
566
|
+
if (!node.frozen && node.relDir === found.relDir) {
|
|
567
|
+
node.type = folderType(found.relDir); // continued in place
|
|
568
|
+
settledNodes.add(node);
|
|
569
|
+
} else {
|
|
570
|
+
candidates.push({ node, found });
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// The address claims what no tag claims: recreation, silently.
|
|
574
|
+
for (const [relDir, entry] of visibleDirs) {
|
|
575
|
+
if (consumedDirs.has(relDir)) continue;
|
|
576
|
+
const node = folderNodes.get(folderByPath.get(relDir));
|
|
577
|
+
if (node && !consumedNodes.has(node)) {
|
|
578
|
+
node.tag = entry.tag;
|
|
579
|
+
node.type = folderType(relDir);
|
|
580
|
+
consumedNodes.add(node);
|
|
581
|
+
consumedDirs.add(relDir);
|
|
582
|
+
settledNodes.add(node);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const departing = new Set(liveNodes.filter((node) => !consumedNodes.has(node)));
|
|
586
|
+
departingFolders.push(...departing);
|
|
587
|
+
const land = (batch) => {
|
|
588
|
+
for (const { node } of batch) {
|
|
589
|
+
if (node.frozen) delete node.frozen; // an ex-contest claimant resuming: it left the path maps when it parked
|
|
590
|
+
else folderByPath.delete(node.relDir);
|
|
591
|
+
}
|
|
592
|
+
for (const { node, found } of batch) {
|
|
593
|
+
node.relDir = found.relDir;
|
|
594
|
+
node.type = folderType(found.relDir);
|
|
595
|
+
node.tag = found.entry.tag;
|
|
596
|
+
folderByPath.set(node.relDir, node.uuid);
|
|
597
|
+
settledNodes.add(node);
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
let pending = candidates;
|
|
601
|
+
while (pending.length > 0) {
|
|
602
|
+
const moving = new Set(pending.map((candidate) => candidate.node));
|
|
603
|
+
const wins = [];
|
|
604
|
+
const again = [];
|
|
605
|
+
for (const candidate of pending) {
|
|
606
|
+
const { node, found } = candidate;
|
|
607
|
+
const contest = () => {
|
|
608
|
+
// Two established identity claims on one address (or ground
|
|
609
|
+
// arriving under an undecided one): the model picks no
|
|
610
|
+
// winner — the claimant parks, the entry stays divergence.
|
|
611
|
+
contestedGround.set(found.relDir, { uuid: node.uuid, tag: found.entry.tag, kind: 'dir' });
|
|
612
|
+
consumedDirs.delete(found.relDir);
|
|
613
|
+
departing.add(node);
|
|
614
|
+
if (!node.frozen) departingFolders.push(node);
|
|
615
|
+
};
|
|
616
|
+
if (underContest(found.relDir)) {
|
|
617
|
+
contest();
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
const occupant = occupantAt(found.relDir);
|
|
621
|
+
if (occupant && occupant !== node && moving.has(occupant)) {
|
|
622
|
+
again.push(candidate); // its slot's owner is itself mid-move
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
// An occupant that IS the moving node is no contest: its own
|
|
626
|
+
// channel came home (a parent's move can respell home
|
|
627
|
+
// mid-flight). Anyone else — parked settles ago or departing
|
|
628
|
+
// in this very interval, whatever its kind — owns the slot.
|
|
629
|
+
if (occupant && occupant !== node) {
|
|
630
|
+
contest();
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
wins.push(candidate);
|
|
634
|
+
}
|
|
635
|
+
land(wins);
|
|
636
|
+
if (wins.length === 0 && again.length === pending.length) {
|
|
637
|
+
land(again); // the waiters hold each other's addresses: they cross
|
|
638
|
+
pending = [];
|
|
639
|
+
} else {
|
|
640
|
+
pending = again;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
for (const [relDir, entry] of [...visibleDirs]
|
|
644
|
+
.sort((a, b) => a[0].split('/').length - b[0].split('/').length)) {
|
|
645
|
+
if (!consumedDirs.has(relDir)) folderAppearances.push([relDir, entry]);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
// Leaf ground — files and links — shares one phase-1 shape: the path
|
|
649
|
+
// claims the channel first (the address absorbs replace/retarget
|
|
650
|
+
// churn), then exactly-one physical continuity rescues path-orphans,
|
|
651
|
+
// never across kinds. `land` is the kind's whole difference: which
|
|
652
|
+
// payload the entry carries into the node.
|
|
653
|
+
const settleLeaves = (kindName, land) => {
|
|
654
|
+
const { ground: visible, byPath, rescue } = KIND[kindName];
|
|
655
|
+
const appearances = [];
|
|
656
|
+
const departing = [];
|
|
657
|
+
const claimed = new Set();
|
|
658
|
+
const orphanNodes = [];
|
|
659
|
+
for (const [relPath, uuid] of [...byPath]) {
|
|
660
|
+
const node = KIND[kindName].nodes.get(uuid);
|
|
661
|
+
const entry = visible.get(relPath);
|
|
662
|
+
if (entry) {
|
|
663
|
+
land(node, relPath, entry);
|
|
664
|
+
claimed.add(relPath);
|
|
665
|
+
settledNodes.add(node);
|
|
666
|
+
} else {
|
|
667
|
+
orphanNodes.push(node);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const orphanEntries = [...visible].filter(([relPath]) => !claimed.has(relPath));
|
|
671
|
+
// Ex-contest claimants whose ground just left the marked address
|
|
672
|
+
// rejoin the pool: their channel never closed, so exactly-one
|
|
673
|
+
// physical continuity may still resume for them.
|
|
674
|
+
const nodesByTag = new Map();
|
|
675
|
+
for (const node of [...orphanNodes, ...rescue]) {
|
|
676
|
+
nodesByTag.set(node.tag, nodesByTag.has(node.tag) ? 'many' : node);
|
|
677
|
+
}
|
|
678
|
+
const entriesByTag = new Map();
|
|
679
|
+
for (const [relPath, entry] of orphanEntries) {
|
|
680
|
+
entriesByTag.set(entry.tag, entriesByTag.has(entry.tag) ? 'many' : { relPath, entry });
|
|
681
|
+
}
|
|
682
|
+
const movedNodes = new Set();
|
|
683
|
+
const movedPaths = new Set();
|
|
684
|
+
const landCandidate = ({ node, found }) => {
|
|
685
|
+
if (node.frozen) delete node.frozen; // an ex-contest claimant resuming: it left the path maps when it parked
|
|
686
|
+
else byPath.delete(node.relPath);
|
|
687
|
+
land(node, found.relPath, found.entry);
|
|
688
|
+
byPath.set(node.relPath, node.uuid);
|
|
689
|
+
settledNodes.add(node);
|
|
690
|
+
movedNodes.add(node.uuid);
|
|
691
|
+
movedPaths.add(found.relPath);
|
|
692
|
+
};
|
|
693
|
+
let pending = [];
|
|
694
|
+
for (const [tag, found] of entriesByTag) {
|
|
695
|
+
const node = nodesByTag.get(tag);
|
|
696
|
+
if (found === 'many' || !node || node === 'many') continue;
|
|
697
|
+
pending.push({ node, found });
|
|
698
|
+
}
|
|
699
|
+
while (pending.length > 0) {
|
|
700
|
+
const moving = new Set(pending.map((candidate) => candidate.node));
|
|
701
|
+
const wins = [];
|
|
702
|
+
const again = [];
|
|
703
|
+
for (const candidate of pending) {
|
|
704
|
+
const { node, found } = candidate;
|
|
705
|
+
// Standing same-kind ground absorbed the arrival at the
|
|
706
|
+
// channel layer: the mover's channel died there, no continuity
|
|
707
|
+
// claim was ever witnessed, and its record simply parks — no
|
|
708
|
+
// rescue, no contest.
|
|
709
|
+
if (leafGroundAtOpen[kindName].has(found.relPath)) continue;
|
|
710
|
+
// A parked owner that IS the rescuing node is no contest: its
|
|
711
|
+
// own channel came home (a parent's move can respell home
|
|
712
|
+
// mid-flight). Anyone else standing on the slot — parked in an
|
|
713
|
+
// earlier settle or departing in this very one — is a CONTEST:
|
|
714
|
+
// two established identity claims on one address, and the
|
|
715
|
+
// model picks no winner — the claimant stays (or goes) parked
|
|
716
|
+
// with its record unmoved, the owner stays with its record
|
|
717
|
+
// unmoved, and the ground is divergence.
|
|
718
|
+
const occupant = occupantAt(found.relPath);
|
|
719
|
+
if (occupant && occupant !== node && moving.has(occupant)) {
|
|
720
|
+
again.push(candidate); // its slot's owner is itself mid-move
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
if ((occupant && occupant !== node) || underContest(found.relPath)) {
|
|
724
|
+
contestedGround.set(found.relPath, { uuid: node.uuid, tag: found.entry.tag, kind: kindName });
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
wins.push(candidate);
|
|
728
|
+
}
|
|
729
|
+
for (const candidate of wins) landCandidate(candidate);
|
|
730
|
+
if (wins.length === 0 && again.length === pending.length) {
|
|
731
|
+
for (const candidate of again) landCandidate(candidate); // the waiters hold each other's addresses: they cross
|
|
732
|
+
pending = [];
|
|
733
|
+
} else {
|
|
734
|
+
pending = again;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
for (const pair of orphanEntries) {
|
|
738
|
+
if (!movedPaths.has(pair[0])) appearances.push(pair);
|
|
739
|
+
}
|
|
740
|
+
departing.push(...orphanNodes.filter((node) => !movedNodes.has(node.uuid)));
|
|
741
|
+
return { appearances, departing };
|
|
742
|
+
};
|
|
743
|
+
const { appearances: fileAppearances, departing: departingFiles } = settleLeaves(
|
|
744
|
+
'file', (node, relPath, entry) => {
|
|
745
|
+
node.relPath = relPath;
|
|
746
|
+
node.hash = entry.hash;
|
|
747
|
+
node.tag = entry.tag;
|
|
748
|
+
node.type = entry.type;
|
|
749
|
+
node.history.add(entry.hash);
|
|
750
|
+
absorb(node, relPath);
|
|
751
|
+
});
|
|
752
|
+
const { appearances: linkAppearances, departing: departingLinks } = settleLeaves(
|
|
753
|
+
'link', (node, relPath, entry) => {
|
|
754
|
+
node.relPath = relPath;
|
|
755
|
+
node.target = entry.target;
|
|
756
|
+
node.tag = entry.tag;
|
|
757
|
+
node.history.add(entry.target);
|
|
758
|
+
absorbLink(node, relPath);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
// --- phase 2: park what departed, BOTH kinds, before any appearance
|
|
762
|
+
// resolves. Absence is evidence — not one record field moves — and
|
|
763
|
+
// the owner must already be parked when its slot's returning ground
|
|
764
|
+
// (possibly of the other kind, inside this very interval) is judged.
|
|
765
|
+
const parentAtOpen = (rel) => {
|
|
766
|
+
const parent = parentDirOf(rel);
|
|
767
|
+
return parent === null ? workspace.uuid : dirsAtOpen.get(parent);
|
|
768
|
+
};
|
|
769
|
+
for (const node of departingFolders) {
|
|
770
|
+
folderByPath.delete(node.relDir);
|
|
771
|
+
}
|
|
772
|
+
for (const node of departingFolders) {
|
|
773
|
+
// A lifted lens is a type transition even on a parked record: the
|
|
774
|
+
// registry's lift pass flips every uncovered repo.git to folder,
|
|
775
|
+
// ground present or not.
|
|
776
|
+
node.type = folderType(node.relDir);
|
|
777
|
+
node.frozen = { parentUUID: parentAtOpen(node.relDir), name: nameOf(node.relDir) };
|
|
778
|
+
}
|
|
779
|
+
for (const node of departingFiles) {
|
|
780
|
+
node.frozen = { parentUUID: parentAtOpen(node.relPath), name: nameOf(node.relPath) };
|
|
781
|
+
fileByPath.delete(node.relPath);
|
|
782
|
+
}
|
|
783
|
+
for (const node of departingLinks) {
|
|
784
|
+
node.frozen = { parentUUID: parentAtOpen(node.relPath), name: nameOf(node.relPath) };
|
|
785
|
+
linkByPath.delete(node.relPath);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// --- phase 3: the slot phase (axiom 4). Each remaining appearance
|
|
789
|
+
// belongs to the parked ACTIVE owner of its (parent, name) slot —
|
|
790
|
+
// searched across ALL entities, because ownership is identity, not
|
|
791
|
+
// kind. Only an unowned slot births a new entity. Folders resolve
|
|
792
|
+
// first, parents before children, so children derive live ground.
|
|
793
|
+
// Waking an owner: same node, frozen shed. When the returning ground
|
|
794
|
+
// is the other kind, the node crosses the kind maps — a type
|
|
795
|
+
// transition on the same uuid — shedding only the old kind's ground
|
|
796
|
+
// evidence; uuid, status, and content history persist.
|
|
797
|
+
const wake = (owner, kindNodes) => {
|
|
798
|
+
delete owner.frozen;
|
|
799
|
+
if (!kindNodes.has(owner.uuid)) {
|
|
800
|
+
for (const other of [fileNodes, linkNodes, folderNodes]) {
|
|
801
|
+
if (other !== kindNodes) other.delete(owner.uuid);
|
|
802
|
+
}
|
|
803
|
+
delete owner.relPath;
|
|
804
|
+
delete owner.relDir;
|
|
805
|
+
delete owner.hash;
|
|
806
|
+
delete owner.target;
|
|
807
|
+
kindNodes.set(owner.uuid, owner);
|
|
808
|
+
}
|
|
809
|
+
return owner;
|
|
810
|
+
};
|
|
811
|
+
const divergent = new Map(); // relPath -> hash (null for dir ground), entity-less
|
|
812
|
+
for (const [relDir, entry] of folderAppearances) {
|
|
813
|
+
if (underContest(relDir)) {
|
|
814
|
+
divergent.set(relDir, null);
|
|
815
|
+
continue; // contested ground (or under it): reported, claimed by no entity
|
|
816
|
+
}
|
|
817
|
+
const occupant = occupantAt(relDir);
|
|
818
|
+
const owner = occupant?.frozen ? occupant : undefined;
|
|
819
|
+
const node = owner
|
|
820
|
+
? wake(owner, folderNodes)
|
|
821
|
+
: { uuid: `d${nextNode++}`, status: 'active' };
|
|
822
|
+
node.relDir = relDir;
|
|
823
|
+
node.tag = entry.tag;
|
|
824
|
+
node.type = folderType(relDir);
|
|
825
|
+
folderNodes.set(node.uuid, node);
|
|
826
|
+
folderByPath.set(relDir, node.uuid);
|
|
827
|
+
settledNodes.add(node);
|
|
828
|
+
}
|
|
829
|
+
// Axiom 6's guard: file ground on a slot whose owner is a folder
|
|
830
|
+
// that still HAS stored-active children is a swap the canonical
|
|
831
|
+
// graph refuses — the ground is divergence, not a transition. (A
|
|
832
|
+
// parked folder's subtree parked with it, so its stored-active
|
|
833
|
+
// children are found among the frozen nodes.)
|
|
834
|
+
const hasActiveChildren = (uuid) => [...folderNodes.values(), ...fileNodes.values(), ...linkNodes.values()]
|
|
835
|
+
.some((node) => node.status === 'active' && node.frozen?.parentUUID === uuid);
|
|
836
|
+
for (const [relPath, entry] of fileAppearances) {
|
|
837
|
+
if (underContest(relPath)) {
|
|
838
|
+
divergent.set(relPath, entry.hash);
|
|
839
|
+
continue; // contested ground (or under it): reported, claimed by no entity, resolved by nothing here
|
|
840
|
+
}
|
|
841
|
+
const occupant = occupantAt(relPath);
|
|
842
|
+
const owner = occupant?.frozen ? occupant : undefined;
|
|
843
|
+
if (owner && folderNodes.has(owner.uuid) && hasActiveChildren(owner.uuid)) {
|
|
844
|
+
divergent.set(relPath, entry.hash);
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
const node = owner
|
|
848
|
+
? wake(owner, fileNodes)
|
|
849
|
+
: { uuid: `n${nextNode++}`, status: 'active' };
|
|
850
|
+
node.relPath = relPath;
|
|
851
|
+
node.hash = entry.hash;
|
|
852
|
+
node.tag = entry.tag;
|
|
853
|
+
node.type = entry.type;
|
|
854
|
+
(node.history ??= new Set()).add(entry.hash);
|
|
855
|
+
absorb(node, relPath);
|
|
856
|
+
fileNodes.set(node.uuid, node);
|
|
857
|
+
fileByPath.set(relPath, node.uuid);
|
|
858
|
+
settledNodes.add(node);
|
|
859
|
+
}
|
|
860
|
+
// Link arrivals: the same slot law, third kind — ownership is
|
|
861
|
+
// identity, not kind, and the container guard is kind-blind: link
|
|
862
|
+
// ground on an occupied folder's slot is the same refused swap.
|
|
863
|
+
for (const [relPath, entry] of linkAppearances) {
|
|
864
|
+
if (underContest(relPath)) {
|
|
865
|
+
divergent.set(relPath, entry.target);
|
|
866
|
+
continue; // contested ground (or under it): reported, claimed by no entity
|
|
867
|
+
}
|
|
868
|
+
const occupant = occupantAt(relPath);
|
|
869
|
+
const owner = occupant?.frozen ? occupant : undefined;
|
|
870
|
+
if (owner && folderNodes.has(owner.uuid) && hasActiveChildren(owner.uuid)) {
|
|
871
|
+
divergent.set(relPath, entry.target);
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
const node = owner
|
|
875
|
+
? wake(owner, linkNodes)
|
|
876
|
+
: { uuid: `l${nextNode++}`, status: 'active' };
|
|
877
|
+
node.relPath = relPath;
|
|
878
|
+
node.target = entry.target;
|
|
879
|
+
node.tag = entry.tag;
|
|
880
|
+
node.type = 'link';
|
|
881
|
+
(node.history ??= new Set()).add(entry.target);
|
|
882
|
+
absorbLink(node, relPath);
|
|
883
|
+
linkNodes.set(node.uuid, node);
|
|
884
|
+
linkByPath.set(relPath, node.uuid);
|
|
885
|
+
settledNodes.add(node);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
lastTruth = pathTruth;
|
|
889
|
+
pathTruth = new Map();
|
|
890
|
+
lastLinkTruth = linkTruth;
|
|
891
|
+
linkTruth = new Map();
|
|
892
|
+
leafGroundAtOpen = {
|
|
893
|
+
file: new Set(visibleFiles.keys()),
|
|
894
|
+
link: new Set(visibleLinks.keys()),
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
// Six-field records + the payload evidence map. Live parents derive
|
|
898
|
+
// through the settled folder graph; parked nodes carry the fields
|
|
899
|
+
// frozen when they left the maps.
|
|
900
|
+
const liveParent = (relPath) => {
|
|
901
|
+
const parent = parentDirOf(relPath);
|
|
902
|
+
return parent === null ? workspace.uuid : folderByPath.get(parent);
|
|
903
|
+
};
|
|
904
|
+
const records = [];
|
|
905
|
+
const payloads = new Map(); // uuid -> payload evidence (feeds the version hash)
|
|
906
|
+
const children = new Map(); // parentUUID -> [childUUID, name] pairs, STORED-active only
|
|
907
|
+
const note = (record, payload) => {
|
|
908
|
+
records.push(record);
|
|
909
|
+
payloads.set(record.uuid, payload);
|
|
910
|
+
if (record.status !== 'active' || record.parentUUID === null) return;
|
|
911
|
+
if (!children.has(record.parentUUID)) children.set(record.parentUUID, []);
|
|
912
|
+
children.get(record.parentUUID).push([record.uuid, record.name]);
|
|
913
|
+
};
|
|
914
|
+
for (const node of folderNodes.values()) {
|
|
915
|
+
note({
|
|
916
|
+
uuid: node.uuid,
|
|
917
|
+
type: node.type,
|
|
918
|
+
parentUUID: node.frozen ? node.frozen.parentUUID : liveParent(node.relDir),
|
|
919
|
+
name: node.frozen ? node.frozen.name : nameOf(node.relDir),
|
|
920
|
+
version: null,
|
|
921
|
+
status: node.status,
|
|
922
|
+
}, null);
|
|
923
|
+
}
|
|
924
|
+
for (const node of fileNodes.values()) {
|
|
925
|
+
note({
|
|
926
|
+
uuid: node.uuid,
|
|
927
|
+
type: node.type,
|
|
928
|
+
parentUUID: node.frozen ? node.frozen.parentUUID : liveParent(node.relPath),
|
|
929
|
+
name: node.frozen ? node.frozen.name : nameOf(node.relPath),
|
|
930
|
+
version: null,
|
|
931
|
+
status: node.status,
|
|
932
|
+
}, node.hash);
|
|
933
|
+
}
|
|
934
|
+
for (const node of linkNodes.values()) {
|
|
935
|
+
note({
|
|
936
|
+
uuid: node.uuid,
|
|
937
|
+
type: node.type,
|
|
938
|
+
parentUUID: node.frozen ? node.frozen.parentUUID : liveParent(node.relPath),
|
|
939
|
+
name: node.frozen ? node.frozen.name : nameOf(node.relPath),
|
|
940
|
+
version: null,
|
|
941
|
+
status: node.status,
|
|
942
|
+
}, node.target);
|
|
943
|
+
}
|
|
944
|
+
// The root record: the workspace — or, when a .git sits at the
|
|
945
|
+
// enrolled root, the repo the lens makes of it. Same uuid both ways.
|
|
946
|
+
note({
|
|
947
|
+
uuid: workspace.uuid,
|
|
948
|
+
type: territories.has('') ? 'repo.git' : 'workspace',
|
|
949
|
+
parentUUID: null,
|
|
950
|
+
name: '', version: null, status: 'active',
|
|
951
|
+
}, null);
|
|
952
|
+
|
|
953
|
+
// The ratified version formula, both halves: containers hash their
|
|
954
|
+
// sorted stored-active membership; repo.git evidence is null-gated
|
|
955
|
+
// until the card/checkpoint payload is adapted; every record's
|
|
956
|
+
// version is the canonical envelope over its fields + payload.
|
|
957
|
+
const membership = (uuid) => sha256(JSON.stringify((children.get(uuid) ?? []).sort()));
|
|
958
|
+
for (const record of records) {
|
|
959
|
+
if (record.type === 'folder' || record.type === 'workspace') {
|
|
960
|
+
payloads.set(record.uuid, membership(record.uuid));
|
|
961
|
+
}
|
|
962
|
+
record.version = sha256(JSON.stringify(
|
|
963
|
+
[record.type, record.parentUUID, record.name, record.status, payloads.get(record.uuid)]));
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// The exposed slice: what the observer is answerable for — exactly
|
|
967
|
+
// the live maps, because covered ground never enters a phase (a
|
|
968
|
+
// territory's own dir is repo ground, never a dir channel).
|
|
969
|
+
const files = new Map();
|
|
970
|
+
for (const [relPath, uuid] of fileByPath) {
|
|
971
|
+
files.set(relPath, { uuid, hash: fileNodes.get(uuid).hash });
|
|
972
|
+
}
|
|
973
|
+
const links = new Map();
|
|
974
|
+
for (const [relPath, uuid] of linkByPath) {
|
|
975
|
+
links.set(relPath, { uuid, target: linkNodes.get(uuid).target });
|
|
976
|
+
}
|
|
977
|
+
const dirs = new Map();
|
|
978
|
+
for (const [relDir, uuid] of folderByPath) {
|
|
979
|
+
if (!territories.has(relDir)) dirs.set(relDir, { uuid });
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// Local presence is EVIDENCE, judged beside the records — never a
|
|
983
|
+
// record field: which entities local ground currently holds.
|
|
984
|
+
const present = new Set([
|
|
985
|
+
workspace.uuid, ...folderByPath.values(), ...fileByPath.values(), ...linkByPath.values(),
|
|
986
|
+
]);
|
|
987
|
+
|
|
988
|
+
return {
|
|
989
|
+
records, payloads, files, links, dirs, territories, present, divergent,
|
|
990
|
+
contested: new Map([...contestedGround].map(([relPath, mark]) => [relPath, mark.uuid])),
|
|
991
|
+
};
|
|
992
|
+
},
|
|
993
|
+
|
|
994
|
+
/** Was this version ever true for THIS entity? (per-entity emitted-truth;
|
|
995
|
+
* one history space per entity, so a leaf that crossed kinds still
|
|
996
|
+
* answers for everything it ever was) */
|
|
997
|
+
nodeEverTrue(uuid, value) {
|
|
998
|
+
return (fileNodes.get(uuid) ?? linkNodes.get(uuid))?.history.has(value) === true;
|
|
999
|
+
},
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Was this version ever true at this address during the just-closed
|
|
1003
|
+
* interval? The floor for a channel born and dead between settles: it
|
|
1004
|
+
* never gained an entity history, but it DID claim an address, and the
|
|
1005
|
+
* address remembers.
|
|
1006
|
+
*/
|
|
1007
|
+
addressEverTrue(relPath, hash) {
|
|
1008
|
+
return lastTruth.get(relPath)?.has(hash) === true;
|
|
1009
|
+
},
|
|
1010
|
+
|
|
1011
|
+
/** The link-kind floor: was this target ever written at this address
|
|
1012
|
+
* during the just-closed interval? */
|
|
1013
|
+
addressLinkEverTrue(relPath, target) {
|
|
1014
|
+
return lastLinkTruth.get(relPath)?.has(target) === true;
|
|
1015
|
+
},
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Was this address contested FOR this claimant at either boundary of
|
|
1019
|
+
* the just-closed interval? A contest moves with its ground
|
|
1020
|
+
* mid-interval, so an honest event at the old address answers with the
|
|
1021
|
+
* opening state, while an event at the new address answers with the
|
|
1022
|
+
* closing one — the same interval scope `addressEverTrue` uses.
|
|
1023
|
+
*/
|
|
1024
|
+
addressContestedFor(relPath, uuid) {
|
|
1025
|
+
return contestedGround.get(relPath)?.uuid === uuid
|
|
1026
|
+
|| contestedAtOpen.get(relPath) === uuid;
|
|
1027
|
+
},
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
module.exports = { createModel, sha256 };
|