|
| 1 | +import Base from '../core/Base.mjs'; |
| 2 | + |
| 3 | +/** |
| 4 | + * @summary Deterministic topological-lock conflict logic for multi-writer Neural Link coordination. |
| 5 | + * |
| 6 | + * `Neo.ai.LockRegistry` is the pure, side-effect-free *conflict-detection core* an in-heap (App-Worker) |
| 7 | + * authority consults before applying a write-class Neural Link operation when more than one agent shares |
| 8 | + * a live application heap — the co-habitation premise of the agent harness, where agents and humans mutate |
| 9 | + * the same live components. Topological locking is sequenced ahead of any multi-writer slice: a slice either |
| 10 | + * acquires its locks through this logic or explicitly declares itself single-writer. |
| 11 | + * |
| 12 | + * It is deliberately **not** a transport-side lock owner: the coordination epic rejects Bridge-authoritative |
| 13 | + * locking because transport-held locks desync from in-heap reality under reconnects and worker restarts. |
| 14 | + * The heap is the truth; this class is the logic the heap's authority enforces. Accordingly every method |
| 15 | + * is **static and operates on a caller-held `lockTable`** (a plain array the authority owns), returning a |
| 16 | + * new array rather than mutating shared state — so the logic carries no singleton lifecycle and is trivial |
| 17 | + * to test and reason about. |
| 18 | + * |
| 19 | + * A lock is `{agentId, sessionId, subtreePath}`: a writer — identified by the `(agentId, sessionId)` pair — |
| 20 | + * holds a component **subtree**, named by its root→node component-id path (`subtreePath`). Two locks conflict |
| 21 | + * iff their subtrees overlap (equal, ancestor, or descendant paths) **and** they belong to *different* |
| 22 | + * writers; sibling subtrees never conflict and same-writer re-acquisition is re-entrant. `sessionId` is |
| 23 | + * consumed as an **opaque** string here — the canonical-session-id mechanics are a separate concern. |
| 24 | + * |
| 25 | + * Scope boundary: this leaf is the conflict logic only. In-heap enforcement (intercepting the write path), |
| 26 | + * Bridge-side advisory mirroring, lock-lease expiry policy, and the canonical-id binding are named |
| 27 | + * follow-up consumer slices, not this module. |
| 28 | + * @class Neo.ai.LockRegistry |
| 29 | + * @extends Neo.core.Base |
| 30 | + */ |
| 31 | +class LockRegistry extends Base { |
| 32 | + static config = { |
| 33 | + /** |
| 34 | + * @member {String} className='Neo.ai.LockRegistry' |
| 35 | + * @protected |
| 36 | + */ |
| 37 | + className: 'Neo.ai.LockRegistry' |
| 38 | + } |
| 39 | + |
| 40 | + /** |
| 41 | + * @summary Validate a lock descriptor, failing closed. |
| 42 | + * Returns a normalized copy of the lock, or `null` when any field is missing or malformed — callers |
| 43 | + * treat `null` as a denial, never as an empty or wildcard lock. |
| 44 | + * @param {Object} lock |
| 45 | + * @param {String} lock.agentId Non-empty writer (agent) identity. |
| 46 | + * @param {String} lock.sessionId Non-empty, opaque, harness-native session id. |
| 47 | + * @param {String[]} lock.subtreePath Ordered root→node component-id path; non-empty, all non-empty strings. |
| 48 | + * @returns {Object|null} |
| 49 | + */ |
| 50 | + static normalizeLock(lock) { |
| 51 | + if (!lock || typeof lock !== 'object') { |
| 52 | + return null |
| 53 | + } |
| 54 | + |
| 55 | + const {agentId, sessionId, subtreePath} = lock; |
| 56 | + |
| 57 | + if (typeof agentId !== 'string' || agentId === '') {return null} |
| 58 | + if (typeof sessionId !== 'string' || sessionId === '') {return null} |
| 59 | + if (!Array.isArray(subtreePath) || subtreePath.length === 0) {return null} |
| 60 | + if (!subtreePath.every(segment => typeof segment === 'string' && segment !== '')) {return null} |
| 61 | + |
| 62 | + return {agentId, sessionId, subtreePath: [...subtreePath]} |
| 63 | + } |
| 64 | + |
| 65 | + /** |
| 66 | + * @summary Stable string identity of a lock (writer + exact subtree), for dedup and release. |
| 67 | + * Built with `JSON.stringify` over the field tuple, so distinct `(agentId, sessionId, subtreePath)` |
| 68 | + * triples never collide regardless of the characters an id contains. |
| 69 | + * @param {Object} lock A normalized lock. |
| 70 | + * @returns {String} |
| 71 | + */ |
| 72 | + static lockKey({agentId, sessionId, subtreePath}) { |
| 73 | + return JSON.stringify([agentId, sessionId, subtreePath]) |
| 74 | + } |
| 75 | + |
| 76 | + /** |
| 77 | + * @summary Whether two locks belong to the same `(agentId, sessionId)` writer. |
| 78 | + * @param {Object} a A normalized lock. |
| 79 | + * @param {Object} b A normalized lock. |
| 80 | + * @returns {Boolean} |
| 81 | + */ |
| 82 | + static sameWriter(a, b) { |
| 83 | + return a.agentId === b.agentId && a.sessionId === b.sessionId |
| 84 | + } |
| 85 | + |
| 86 | + /** |
| 87 | + * @summary Whether two component subtrees overlap (one equals or contains the other). |
| 88 | + * Overlap ⇔ one path is a prefix of the other (a prefix includes the equal case); sibling paths, which |
| 89 | + * diverge before either ends, never overlap. A lock on the root path therefore overlaps everything. |
| 90 | + * @param {String[]} a Ordered root→node id path. |
| 91 | + * @param {String[]} b Ordered root→node id path. |
| 92 | + * @returns {Boolean} |
| 93 | + */ |
| 94 | + static pathsOverlap(a, b) { |
| 95 | + const min = Math.min(a.length, b.length); |
| 96 | + |
| 97 | + for (let i = 0; i < min; i++) { |
| 98 | + if (a[i] !== b[i]) { |
| 99 | + return false // diverged before either subtree root → siblings, disjoint |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | + return true // one path is a prefix of the other (incl. equal) → subtrees overlap |
| 104 | + } |
| 105 | + |
| 106 | + /** |
| 107 | + * @summary Find an existing lock held by a *different* writer whose subtree overlaps `candidate`. |
| 108 | + * Returns the conflicting held lock (the first match), or `null` when `candidate` can be granted. |
| 109 | + * Both arguments are expected to be normalized locks (see {@link normalizeLock}). |
| 110 | + * @param {Object[]} lockTable Caller-held active locks (normalized). |
| 111 | + * @param {Object} candidate A normalized lock. |
| 112 | + * @returns {Object|null} |
| 113 | + */ |
| 114 | + static findConflict(lockTable, candidate) { |
| 115 | + for (const held of lockTable) { |
| 116 | + if (!LockRegistry.sameWriter(held, candidate) && |
| 117 | + LockRegistry.pathsOverlap(held.subtreePath, candidate.subtreePath)) { |
| 118 | + return held |
| 119 | + } |
| 120 | + } |
| 121 | + |
| 122 | + return null |
| 123 | + } |
| 124 | + |
| 125 | + /** |
| 126 | + * @summary Attempt to acquire a lock, returning a new table and the outcome — never mutates the input. |
| 127 | + * Fail-closed in three layers: a malformed lock is denied with an error and an unchanged table; a |
| 128 | + * cross-writer subtree overlap is denied with the conflicting holder; otherwise the lock is granted. |
| 129 | + * Re-acquiring an identical lock by the same writer is re-entrant (granted, no duplicate appended). |
| 130 | + * @param {Object[]} lockTable The caller-held active locks. |
| 131 | + * @param {Object} lock The lock to acquire. |
| 132 | + * @returns {{lockTable: Object[], granted: Boolean, conflict: Object|null, errors: String[]}} |
| 133 | + */ |
| 134 | + static acquire(lockTable, lock) { |
| 135 | + const table = Array.isArray(lockTable) ? lockTable : [], |
| 136 | + norm = LockRegistry.normalizeLock(lock); |
| 137 | + |
| 138 | + if (!norm) { |
| 139 | + return {lockTable: table, granted: false, conflict: null, errors: ['invalid lock descriptor']} |
| 140 | + } |
| 141 | + |
| 142 | + const conflict = LockRegistry.findConflict(table, norm); |
| 143 | + |
| 144 | + if (conflict) { |
| 145 | + return {lockTable: table, granted: false, conflict, errors: []} |
| 146 | + } |
| 147 | + |
| 148 | + const key = LockRegistry.lockKey(norm); |
| 149 | + |
| 150 | + if (table.some(held => LockRegistry.lockKey(held) === key)) { |
| 151 | + return {lockTable: table, granted: true, conflict: null, errors: []} // re-entrant, idempotent |
| 152 | + } |
| 153 | + |
| 154 | + return {lockTable: [...table, norm], granted: true, conflict: null, errors: []} |
| 155 | + } |
| 156 | + |
| 157 | + /** |
| 158 | + * @summary Release one exact lock (same writer + same subtree), idempotently. |
| 159 | + * Releasing a lock that is not held is a no-op — the table is returned unchanged — so a double-release |
| 160 | + * or a release after a sweep never throws. A lock on the same subtree held by a *different* writer is |
| 161 | + * never released by this call. |
| 162 | + * @param {Object[]} lockTable The caller-held active locks. |
| 163 | + * @param {Object} lock The lock to release. |
| 164 | + * @returns {{lockTable: Object[]}} |
| 165 | + */ |
| 166 | + static release(lockTable, lock) { |
| 167 | + const table = Array.isArray(lockTable) ? lockTable : [], |
| 168 | + norm = LockRegistry.normalizeLock(lock); |
| 169 | + |
| 170 | + if (!norm) { |
| 171 | + return {lockTable: table} |
| 172 | + } |
| 173 | + |
| 174 | + const key = LockRegistry.lockKey(norm); |
| 175 | + |
| 176 | + return {lockTable: table.filter(held => LockRegistry.lockKey(held) !== key)} |
| 177 | + } |
| 178 | + |
| 179 | + /** |
| 180 | + * @summary Release every lock matching a writer selector — the disconnect / worker-restart sweep hook. |
| 181 | + * Pass `agentId` and/or `sessionId`; a lock is swept when it matches *all* provided fields. Passing |
| 182 | + * neither field selects nothing (fail-closed: a selector-less sweep never clears the whole table). |
| 183 | + * @param {Object[]} lockTable The caller-held active locks. |
| 184 | + * @param {Object} [selector={}] |
| 185 | + * @param {String} [selector.agentId] |
| 186 | + * @param {String} [selector.sessionId] |
| 187 | + * @returns {{lockTable: Object[], released: Number}} |
| 188 | + */ |
| 189 | + static releaseAll(lockTable, selector = {}) { |
| 190 | + const table = Array.isArray(lockTable) ? lockTable : [], |
| 191 | + {agentId, sessionId} = selector; |
| 192 | + |
| 193 | + if (agentId === undefined && sessionId === undefined) { |
| 194 | + return {lockTable: table, released: 0} |
| 195 | + } |
| 196 | + |
| 197 | + const kept = table.filter(held => !( |
| 198 | + (agentId === undefined || held.agentId === agentId) && |
| 199 | + (sessionId === undefined || held.sessionId === sessionId) |
| 200 | + )); |
| 201 | + |
| 202 | + return {lockTable: kept, released: table.length - kept.length} |
| 203 | + } |
| 204 | + |
| 205 | + /** |
| 206 | + * @summary Whether an explicit write target is mandatory for the given live-session count (fail-safe). |
| 207 | + * Only a count *known* to be exactly 0 or 1 permits the implicit "most recent session" shortcut |
| 208 | + * (`ConnectionService.call()`, single-writer-safe). Any other value — 2+, or a non-finite / unexpected |
| 209 | + * count such as a consumer's `sessions?.length` resolving to `undefined` — requires an explicit target: |
| 210 | + * if the live-session count cannot be confirmed safe, we must not silently re-enable auto-targeting |
| 211 | + * while co-habitation may be live. The fail direction is the strict one (`true`). |
| 212 | + * @param {Number} liveSessionCount |
| 213 | + * @returns {Boolean} |
| 214 | + */ |
| 215 | + static requiresExplicitTarget(liveSessionCount) { |
| 216 | + return liveSessionCount !== 0 && liveSessionCount !== 1 |
| 217 | + } |
| 218 | +} |
| 219 | + |
| 220 | +export default Neo.setupClass(LockRegistry); |
0 commit comments