weir-harness 1.2.3 → 2.0.0
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/cordis.patch.yml +7 -18
- package/lib/client.file-lease-badge.js +307 -0
- package/lib/client.js +20 -30
- package/lib/client.settings-page.js +10 -34
- package/package.json +2 -2
- package/src/capabilities/store/checked-contract.d.ts +2 -2
- package/src/capabilities/store/fs-adapter.js +2 -3
- package/src/context-guard/index.js +2 -2
- package/src/delegate/supervision-mount.js +5 -5
- package/src/delegate/tool.js +2 -10
- package/src/file-lease/client.js +1066 -0
- package/src/file-lease/index.js +30 -0
- package/src/file-lease/lineage.js +207 -0
- package/src/file-lease/node-contracts.d.ts +42 -0
- package/src/file-lease/plugin.js +789 -0
- package/src/file-lease/singleton.js +155 -0
- package/src/file-lease/spawn.js +62 -0
- package/src/file-lease/tools.js +459 -0
- package/src/file-lease/view.js +144 -0
- package/src/hashline-edit/index.js +76 -42
- package/src/hashline-edit/write-tool.js +90 -0
- package/src/lease-broker/entry.js +98 -0
- package/src/lease-broker/node-contracts.d.ts +63 -0
- package/src/lease-broker/protocol.js +236 -0
- package/src/lease-broker/server.js +399 -0
- package/src/lease-broker/state.js +425 -0
- package/src/lsp/tools.js +137 -27
- package/src/settings/index.js +2 -44
- package/src/settings/sections.js +2 -24
- package/src/shared/audit.js +6 -1
- package/src/todo-driver/index.js +1 -6
- package/src/worktree/cards.js +6 -21
- package/src/worktree/lane-core.js +1 -19
- package/src/worktree/lane-workflows.js +11 -32
- package/lib/client.edit-lock-maintenance.js +0 -592
- package/lib/client.edit-lock-panel.js +0 -405
- package/src/edit-lock/adapter.js +0 -57
- package/src/edit-lock/admin-ledger.js +0 -149
- package/src/edit-lock/admin-recovery.js +0 -151
- package/src/edit-lock/admission.js +0 -82
- package/src/edit-lock/binding-attestation.js +0 -29
- package/src/edit-lock/binding-guidance.js +0 -72
- package/src/edit-lock/binding-machine.js +0 -312
- package/src/edit-lock/call-context.js +0 -65
- package/src/edit-lock/derived-facts.js +0 -67
- package/src/edit-lock/domain.js +0 -165
- package/src/edit-lock/domains.js +0 -170
- package/src/edit-lock/host.js +0 -149
- package/src/edit-lock/incarnations.js +0 -150
- package/src/edit-lock/index.js +0 -1303
- package/src/edit-lock/inspect.js +0 -219
- package/src/edit-lock/lifecycle.js +0 -579
- package/src/edit-lock/limits.js +0 -61
- package/src/edit-lock/maintenance.js +0 -310
- package/src/edit-lock/manager.js +0 -875
- package/src/edit-lock/negotiation.js +0 -132
- package/src/edit-lock/operation-history.js +0 -214
- package/src/edit-lock/peer-client.js +0 -86
- package/src/edit-lock/peer-transport.js +0 -76
- package/src/edit-lock/peer.js +0 -87
- package/src/edit-lock/publisher.js +0 -191
- package/src/edit-lock/read-authority.js +0 -101
- package/src/edit-lock/recovery.js +0 -139
- package/src/edit-lock/remote.js +0 -362
- package/src/edit-lock/request-binding.js +0 -42
- package/src/edit-lock/request-data.js +0 -40
- package/src/edit-lock/reservation.js +0 -301
- package/src/edit-lock/reserved-runtime.js +0 -45
- package/src/edit-lock/resource-identity.js +0 -130
- package/src/edit-lock/runtime.js +0 -145
- package/src/edit-lock/settle.js +0 -144
- package/src/edit-lock/snapshot.js +0 -175
- package/src/edit-lock/stale-sweep.js +0 -120
- package/src/edit-lock/state.js +0 -462
- package/src/edit-lock/store.js +0 -327
- package/src/edit-lock/tool-scope.js +0 -29
- package/src/edit-lock/view.js +0 -170
- package/src/edit-lock/write-scopes.js +0 -48
- package/src/edit-lock/write-tool.js +0 -45
- package/src/shared/delegate-edit-lifecycle.js +0 -36
- package/src/worktree/authority.js +0 -56
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
// Trusted offline admin API. Never opens a runtime or invokes a publisher.
|
|
2
|
-
import { constants, lstatSync, realpathSync } from 'node:fs'
|
|
3
|
-
import { open } from 'node:fs/promises'
|
|
4
|
-
import { join, resolve, parse, relative, sep } from 'node:path'
|
|
5
|
-
import { createHash } from 'node:crypto'
|
|
6
|
-
import { reservePublisher } from './reservation.js'
|
|
7
|
-
import { openEditLockStore } from './store.js'
|
|
8
|
-
import { canonical, parseSnapshot, validateImage } from './snapshot.js'
|
|
9
|
-
import { administrativeState, digest, LATE_WRITER_RISK } from './admin-ledger.js'
|
|
10
|
-
import { dispositionFor } from './derived-facts.js'
|
|
11
|
-
|
|
12
|
-
export const MAX_RECOVERY_BYTES = 16 * 1024 * 1024
|
|
13
|
-
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')
|
|
14
|
-
function fail(code, message) { throw Object.assign(new Error(message), { code: `weir-edit-lock/${code}` }) }
|
|
15
|
-
function check(condition, message) { if (!condition) fail('invalid-recovery', message) }
|
|
16
|
-
function scope(input) { return { root: input.root, owner: input.owner, expectedRevision: input.expectedRevision, operationIds: [...input.operationIds].sort(), risk: LATE_WRITER_RISK } }
|
|
17
|
-
export function recoveryConfirmation(input) { return `ADMIN OVERRIDE ${digest(scope(input))}` }
|
|
18
|
-
function validateInput(input) {
|
|
19
|
-
check(input && typeof input === 'object' && !Array.isArray(input), 'Recovery object required')
|
|
20
|
-
const keys = ['root', 'owner', 'expectedRevision', 'operationIds', 'recoveryId', 'reason', 'acceptLateWriterRisk', 'confirmation']
|
|
21
|
-
check(Object.keys(input).length === keys.length && keys.every(k => Object.hasOwn(input, k)), 'Exact recovery fields required')
|
|
22
|
-
for (const key of ['root', 'owner', 'recoveryId', 'reason']) check(typeof input[key] === 'string' && input[key].trim() && input[key].length <= (key === 'root' ? 4096 : key === 'reason' ? 2000 : key === 'recoveryId' ? 128 : 512) && !input[key].includes('\0'), `Invalid ${key}`)
|
|
23
|
-
check(Number.isSafeInteger(input.expectedRevision) && input.expectedRevision >= 0, 'Expected revision required')
|
|
24
|
-
check(Array.isArray(input.operationIds) && input.operationIds.length > 0 && input.operationIds.length <= 10000 && input.operationIds.every(id => typeof id === 'string' && id.trim() && id.length <= 1024), 'Operation IDs required')
|
|
25
|
-
check(new Set(input.operationIds).size === input.operationIds.length, 'Duplicate operation ID')
|
|
26
|
-
check(input.acceptLateWriterRisk === true && input.confirmation === recoveryConfirmation(input), 'Explicit scoped ADMIN OVERRIDE and late-writer risk acceptance required')
|
|
27
|
-
}
|
|
28
|
-
function pinsFor(directory) {
|
|
29
|
-
let current = parse(directory).root
|
|
30
|
-
const pins = []
|
|
31
|
-
for (const part of relative(current, directory).split(sep).filter(Boolean)) {
|
|
32
|
-
current = join(current, part)
|
|
33
|
-
const stat = lstatSync(current)
|
|
34
|
-
check(stat.isDirectory() && !stat.isSymbolicLink(), 'Authority ancestors must be nonsymlink directories')
|
|
35
|
-
pins.push({ path: current, stat })
|
|
36
|
-
}
|
|
37
|
-
return pins
|
|
38
|
-
}
|
|
39
|
-
const same = (a, b) => a.dev === b.dev && a.ino === b.ino && a.mode === b.mode
|
|
40
|
-
function verify(pins) { for (const pin of pins) check(same(pin.stat, lstatSync(pin.path)), 'Authority ancestor changed') }
|
|
41
|
-
async function boundedRead(path, pins) {
|
|
42
|
-
verify(pins)
|
|
43
|
-
const before = lstatSync(path)
|
|
44
|
-
check(before.isFile() && !before.isSymbolicLink() && before.nlink === 1 && before.size <= MAX_RECOVERY_BYTES, 'Bounded single-link regular authority file required')
|
|
45
|
-
// win32: O_NOFOLLOW/O_NONBLOCK are undefined; the lstat above refused a symlink
|
|
46
|
-
// leaf and the post-open identity check below bounds the swap (read-authority.js).
|
|
47
|
-
const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
|
|
48
|
-
try {
|
|
49
|
-
const stat = await file.stat()
|
|
50
|
-
check(same(before, stat) && stat.isFile() && stat.nlink === 1 && stat.size <= MAX_RECOVERY_BYTES, 'Opened file identity or bound changed')
|
|
51
|
-
const bytes = Buffer.alloc(stat.size + 1)
|
|
52
|
-
let length = 0
|
|
53
|
-
while (length < bytes.length) {
|
|
54
|
-
const result = await file.read(bytes, length, bytes.length - length, length)
|
|
55
|
-
if (!result.bytesRead) break
|
|
56
|
-
length += result.bytesRead
|
|
57
|
-
}
|
|
58
|
-
const after = await file.stat()
|
|
59
|
-
verify(pins)
|
|
60
|
-
check(same(stat, lstatSync(path)) && after.size === stat.size && after.mtimeMs === stat.mtimeMs && after.ctimeMs === stat.ctimeMs && length === stat.size, 'Authority changed during read')
|
|
61
|
-
return bytes.subarray(0, length)
|
|
62
|
-
} finally { await file.close() }
|
|
63
|
-
}
|
|
64
|
-
async function syncDirectory(directory) {
|
|
65
|
-
// Directory fsync is POSIX-only (EPERM on win32, reservation.js SYNC_SUPPORTED).
|
|
66
|
-
if (process.platform !== 'darwin' && process.platform !== 'linux') return
|
|
67
|
-
const handle = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
|
|
68
|
-
try { await handle.sync() } finally { await handle.close() }
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Caller must authenticate settings-plane access and exact-match a server-derived
|
|
72
|
-
* root BEFORE calling. Test checkpoints surround real IO, never replace it.
|
|
73
|
-
* @param {any} input
|
|
74
|
-
* @param {{ checkpoint?: (point: string) => void|Promise<void> }} [testing] */
|
|
75
|
-
export async function recoverAuthority(input, testing = {}) {
|
|
76
|
-
validateInput(input)
|
|
77
|
-
// Capture before the first await: the operator request cannot change in flight.
|
|
78
|
-
input = structuredClone(input)
|
|
79
|
-
check(resolve(input.root) === input.root && realpathSync.native(input.root) === input.root, 'Canonical root required')
|
|
80
|
-
const directory = join(input.root, '.weir/edit-lock')
|
|
81
|
-
const pins = pinsFor(directory)
|
|
82
|
-
const reservation = await reservePublisher(directory)
|
|
83
|
-
let store
|
|
84
|
-
try {
|
|
85
|
-
await testing.checkpoint?.('reserved')
|
|
86
|
-
const assertExclusive = async () => { verify(pins); await reservation.assertExclusive(); verify(pins) }
|
|
87
|
-
await assertExclusive()
|
|
88
|
-
const path = join(directory, 'snapshot.json')
|
|
89
|
-
const bytes = await boundedRead(path, pins)
|
|
90
|
-
const before = parseSnapshot(bytes, input.root)
|
|
91
|
-
const existing = before.state.adminRecoveries?.find(row => row.recoveryId === input.recoveryId)
|
|
92
|
-
if (existing) {
|
|
93
|
-
check(existing.confirmation === digest(scope(input)) && existing.reason === input.reason, 'Recovery ID reused with different request')
|
|
94
|
-
const backup = await boundedRead(join(directory, existing.backup.file), pins)
|
|
95
|
-
check(sha256(backup) === existing.backup.sha256 && backup.length === existing.backup.bytes, 'Recovery backup no longer validates')
|
|
96
|
-
const original = parseSnapshot(backup, input.root)
|
|
97
|
-
check(original.revision === existing.expectedRevision, 'Backup revision mismatch')
|
|
98
|
-
await syncDirectory(directory)
|
|
99
|
-
await assertExclusive()
|
|
100
|
-
return { revision: before.revision, idempotent: true, record: existing }
|
|
101
|
-
}
|
|
102
|
-
if (before.revision !== input.expectedRevision) fail('revision-conflict', 'Authority revision changed; inspect and confirm again')
|
|
103
|
-
const owner = before.state.sessions.find(s => s.sessionId === input.owner)
|
|
104
|
-
if (!owner || !owner.interrupted) throw new Error('Only an interrupted historical owner can be overridden')
|
|
105
|
-
// Operations an earlier disposition of the same owner already covers stay
|
|
106
|
-
// disposed: a re-revocation after repair names only genuinely new unknowns.
|
|
107
|
-
const unresolved = before.state.operations.filter(op => op.sessionId === input.owner && ['prepared', 'publishing', 'unknown'].includes(op.phase) && !dispositionFor(before.state, op))
|
|
108
|
-
check(unresolved.length > 0 && unresolved.every(op => op.phase === 'unknown'), 'Recover to stable unknown history before administrative override')
|
|
109
|
-
check(canonical(unresolved.map(op => op.operationId).sort()) === canonical([...input.operationIds].sort()), 'Confirm every unresolved operation for this owner')
|
|
110
|
-
const backup = { file: `admin-backup-${digest([input.root, input.recoveryId])}.json`, sha256: sha256(bytes), bytes: bytes.length }
|
|
111
|
-
const record = { recoveryId: input.recoveryId, root: input.root, owner: input.owner,
|
|
112
|
-
expectedRevision: before.revision, committedRevision: before.revision + 1, at: Date.now(), actor: 'authenticated-settings-administrator',
|
|
113
|
-
reason: input.reason, risk: LATE_WRITER_RISK, confirmation: digest(scope(input)), backup,
|
|
114
|
-
operations: unresolved.map(op => ({ operationId: op.operationId, sha256: digest(op) })),
|
|
115
|
-
releasedLocks: before.state.locks.filter(lock => lock.owner === input.owner), revokedEpoch: owner.executionEpoch + 1 }
|
|
116
|
-
const nextState = administrativeState(before.state, record)
|
|
117
|
-
validateImage(nextState)
|
|
118
|
-
check(Buffer.byteLength(canonical(nextState)) + 1024 <= MAX_RECOVERY_BYTES, 'Resulting authority exceeds recovery byte limit')
|
|
119
|
-
await testing.checkpoint?.('before-backup')
|
|
120
|
-
await assertExclusive()
|
|
121
|
-
let file
|
|
122
|
-
try { file = await open(join(directory, backup.file), 'wx', 0o600) }
|
|
123
|
-
catch (error) { if (/** @type {{code?: string}} */ (error).code !== 'EEXIST') throw error }
|
|
124
|
-
if (file) {
|
|
125
|
-
try { await file.writeFile(bytes); await testing.checkpoint?.('backup-written'); await file.sync() }
|
|
126
|
-
finally { await file.close() }
|
|
127
|
-
}
|
|
128
|
-
const savedBackup = await boundedRead(join(directory, backup.file), pins)
|
|
129
|
-
check(savedBackup.equals(bytes), 'Existing backup does not match exact authority bytes')
|
|
130
|
-
parseSnapshot(savedBackup, input.root)
|
|
131
|
-
// Also sync an existing backup when retrying a crash before its first fsync.
|
|
132
|
-
// fsync needs a writable handle on Windows (EPERM on O_RDONLY): open the
|
|
133
|
-
// existing backup read-write there so the re-sync stays a real fsync.
|
|
134
|
-
const backupHandle = await open(join(directory, backup.file), process.platform === 'win32' ? 'r+' : constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
|
|
135
|
-
try { await backupHandle.sync() } finally { await backupHandle.close() }
|
|
136
|
-
await syncDirectory(directory)
|
|
137
|
-
await testing.checkpoint?.('backup-durable')
|
|
138
|
-
await assertExclusive()
|
|
139
|
-
check((await boundedRead(path, pins)).equals(bytes), 'Authority changed after backup')
|
|
140
|
-
store = await openEditLockStore({ directory, domainId: input.root, mode: 'recover', maxSnapshotBytes: MAX_RECOVERY_BYTES }, { checkpoint: async point => { await testing.checkpoint?.(`store:${point}`); await assertExclusive() } })
|
|
141
|
-
check(canonical(store.snapshot()) === canonical(before), 'Authority changed before commit')
|
|
142
|
-
const saved = await store.recordAdministrativeRecovery({ expectedRevision: before.revision, nextState })
|
|
143
|
-
await assertExclusive()
|
|
144
|
-
return { revision: saved.revision, idempotent: false, record }
|
|
145
|
-
} finally {
|
|
146
|
-
await store?.close()
|
|
147
|
-
// This operation dispatched no file publisher; it may release ONLY its own
|
|
148
|
-
// reservation. A pre-existing runtime/stale reservation is never removed.
|
|
149
|
-
await reservation.releaseAfterQuiescence()
|
|
150
|
-
}
|
|
151
|
-
}
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { posix, win32 } from 'node:path'
|
|
2
|
-
|
|
3
|
-
/** Candidates describe effects, not merely explicit API arguments. Session-wide
|
|
4
|
-
* operations must enumerate affected ownership at their FIFO position.
|
|
5
|
-
* Modes: 'normal' covers every mutating ingress; 'release' is pure removal and
|
|
6
|
-
* must retain the ownership unresolved updates require; 'revocation' is strictly
|
|
7
|
-
* subtractive and crosses fences. 'resume' is reserved for session-level
|
|
8
|
-
* recovery transactions (the trusted Continue): it publishes nothing, so an
|
|
9
|
-
* unresolved resource-level fence never denies it, while domain fences and
|
|
10
|
-
* every shape denial bind it exactly as they bind 'normal'. It must never be
|
|
11
|
-
* used for a transaction that publishes, confirms, acquires or releases.
|
|
12
|
-
* @typedef {{kind: 'resources', resourceIds: string[]} | {kind: 'scope', ancestor: string} | {kind: 'none'}} Candidate
|
|
13
|
-
* @typedef {'normal'|'release'|'revocation'|'resume'} Mode
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/** Shape check only, NOT filesystem proof. The trusted manager ingress already
|
|
17
|
-
* requires canonical, single-link resource identity and exclusive topology control.
|
|
18
|
-
* Opaque kernel test IDs remain valid without fences but cannot prove non-overlap.
|
|
19
|
-
* @param {string} resourceId */
|
|
20
|
-
function canonicalResource(resourceId) {
|
|
21
|
-
if (typeof resourceId !== 'string' || resourceId.includes('\0')) return false
|
|
22
|
-
// Shape check only, NOT filesystem proof. The POSIX arm is every POSIX row
|
|
23
|
-
// plus the platform-neutral synthetic ids historical records may carry; the
|
|
24
|
-
// win32 arm is the NTFS-native shape the resource-identity win32 row
|
|
25
|
-
// produces. Real producers emit exactly one shape per platform; relative,
|
|
26
|
-
// root, trailing-slash and non-normalized spellings stay refused.
|
|
27
|
-
const posixCanonical = resourceId.startsWith('/') && resourceId !== '/' &&
|
|
28
|
-
!resourceId.endsWith('/') && posix.normalize(resourceId) === resourceId
|
|
29
|
-
if (process.platform !== 'win32') return posixCanonical
|
|
30
|
-
return posixCanonical ||
|
|
31
|
-
(/^[A-Za-z]:\\/.test(resourceId) && resourceId.length > 3 &&
|
|
32
|
-
!resourceId.endsWith('\\') && !resourceId.includes('/') &&
|
|
33
|
-
win32.normalize(resourceId) === resourceId)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Pure denial check. Historical closeout assertions grant no authority.
|
|
37
|
-
* v3 stores no ancestor continuity witness. Neither relative() nor resolving an
|
|
38
|
-
* ancestor again proves its historical topology: scope relations stay undecidable.
|
|
39
|
-
* @param {import('./operation-history.js').Operation[]} operations
|
|
40
|
-
* @param {Candidate} candidate
|
|
41
|
-
* @param {Mode} [mode]
|
|
42
|
-
*/
|
|
43
|
-
export function admitMutation(operations, candidate, mode = 'normal') {
|
|
44
|
-
if (mode === 'revocation') return
|
|
45
|
-
for (const operation of operations) {
|
|
46
|
-
if (operation.phase !== 'publishing' && operation.phase !== 'unknown') continue
|
|
47
|
-
const fence = operation.fence
|
|
48
|
-
const deny = (/** @type {string} */ reason) => {
|
|
49
|
-
// Tagged so trusted maintenance (the stale-lock sweep) can tell this
|
|
50
|
-
// expected refusal from a persistence/poison/unexpected failure.
|
|
51
|
-
throw Object.assign(new Error(`unresolved publication fence: ${reason}`), { admissionRefusal: true })
|
|
52
|
-
}
|
|
53
|
-
if (!candidate || !fence) deny('scope-unproved')
|
|
54
|
-
// Pure removal cannot publish or grant authority, but MUST retain the exact
|
|
55
|
-
// ownership required by unresolved updates, even under historical fences.
|
|
56
|
-
if (mode === 'release') {
|
|
57
|
-
if (candidate.kind !== 'resources' || !candidate.resourceIds.every(canonicalResource)) deny('identity-unproved')
|
|
58
|
-
const target = operation.binding.target
|
|
59
|
-
if (target.kind === 'update' && candidate.kind === 'resources' && candidate.resourceIds.includes(target.resourceId)) deny('retained-ownership')
|
|
60
|
-
continue
|
|
61
|
-
}
|
|
62
|
-
if (fence?.kind === 'domain') deny('domain')
|
|
63
|
-
if (candidate.kind === 'none') continue
|
|
64
|
-
if (candidate.kind !== 'resources') deny('scope-continuity-unproved')
|
|
65
|
-
if (candidate.kind === 'resources') {
|
|
66
|
-
for (const resourceId of candidate.resourceIds) {
|
|
67
|
-
if (!canonicalResource(resourceId)) deny('identity-unproved')
|
|
68
|
-
if (fence?.kind !== 'resource') deny('scope-continuity-unproved')
|
|
69
|
-
// 'resume' publishes nothing: a resource-level fence never bars the
|
|
70
|
-
// session-level recovery transaction; it keeps barring the resource
|
|
71
|
-
// itself on every other mode until administrative settlement.
|
|
72
|
-
if (mode !== 'resume' && fence?.kind === 'resource' && (!canonicalResource(fence.resourceId) || fence.resourceId === resourceId)) deny('resource-conflict')
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** @param {import('./operation-history.js').Target} target @returns {Candidate} */
|
|
79
|
-
export function publicationCandidate(target) {
|
|
80
|
-
return target.kind === 'update' ? { kind: 'resources', resourceIds: [target.resourceId] }
|
|
81
|
-
: { kind: 'scope', ancestor: target.ancestor }
|
|
82
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/** Same-bundle-instance bridge. Tokens identify channel runtime objects, never
|
|
2
|
-
* confer authority. An absent registration is UNKNOWN, not evidence of death. */
|
|
3
|
-
/** @type {Map<string, {agent: object, sessionId: string}>} */
|
|
4
|
-
const registrations = new Map()
|
|
5
|
-
|
|
6
|
-
/** @param {string} nonce @param {object} agent @param {string} sessionId */
|
|
7
|
-
export function registerBindingRuntime(nonce, agent, sessionId) {
|
|
8
|
-
if (registrations.has(nonce)) throw new Error('duplicate binding nonce')
|
|
9
|
-
const entry = Object.freeze({ agent, sessionId })
|
|
10
|
-
registrations.set(nonce, entry)
|
|
11
|
-
return () => { if (registrations.get(nonce) === entry) registrations.delete(nonce) }
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** @param {string} nonce */
|
|
15
|
-
export function bindingRuntime(nonce) { return registrations.get(nonce) }
|
|
16
|
-
|
|
17
|
-
/** Undefined is a verified absence ONLY after a successful registry lookup.
|
|
18
|
-
* Missing services and lookup failures must throw (and become unknown).
|
|
19
|
-
* @param {(sessionId: string) => object | undefined} servingRuntime */
|
|
20
|
-
export function createBindingAttester(servingRuntime) {
|
|
21
|
-
/** @param {string} sessionId @param {string} nonce
|
|
22
|
-
* @returns {'serving'|'stale'|'unknown'} */
|
|
23
|
-
return (sessionId, nonce) => {
|
|
24
|
-
const holder = bindingRuntime(nonce)
|
|
25
|
-
if (!holder || holder.sessionId !== sessionId) return 'unknown'
|
|
26
|
-
try { return servingRuntime(sessionId) === holder.agent ? 'serving' : 'stale' }
|
|
27
|
-
catch { return 'unknown' }
|
|
28
|
-
}
|
|
29
|
-
}
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/** Publisher-only classification; pid is compared with the publisher, never the client.
|
|
2
|
-
* @param {number | undefined} pid @param {string} attestation */
|
|
3
|
-
export function bindingRefusalReason(pid, attestation) {
|
|
4
|
-
const holder = attestation === 'serving' ? 'serving'
|
|
5
|
-
: attestation === 'serving-idle' ? 'serving-idle'
|
|
6
|
-
: pid === process.pid ? 'same-process-unattestable'
|
|
7
|
-
: pid !== undefined ? 'cross-process-pid' : 'unknown'
|
|
8
|
-
return `session already bound: holder=${holder}${pid !== undefined ? `; pid=${pid}` : ''}`
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/** The wire/minted holder classes. */
|
|
12
|
-
const HOLDER_KINDS = Object.freeze(['revocation-failed', 'serving', 'transferable', 'cross-process', 'unknown'])
|
|
13
|
-
|
|
14
|
-
/** Validate a structured binding refusal — exactly the minted four-field
|
|
15
|
-
* shape crosses the wire or reaches a classifier; anything else degrades to
|
|
16
|
-
* the text channel (display only, never a decision input).
|
|
17
|
-
* @param {unknown} value @returns {{code: 'binding-duplicate', retryable: true, holderKind: string, holderPid: number | null} | undefined} */
|
|
18
|
-
export function asBindingFailure(value) {
|
|
19
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
20
|
-
if (Object.keys(value).sort().join(',') !== 'code,holderKind,holderPid,retryable') return undefined
|
|
21
|
-
const { code, retryable, holderKind, holderPid } = /** @type {any} */ (value)
|
|
22
|
-
if (code !== 'binding-duplicate' || retryable !== true || !HOLDER_KINDS.includes(holderKind)) return undefined
|
|
23
|
-
if (holderPid !== null && !(Number.isSafeInteger(holderPid) && holderPid > 0)) return undefined
|
|
24
|
-
return { code, retryable, holderKind, holderPid }
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Render only publisher-classified facts — the STRUCTURED bindingFailure,
|
|
28
|
-
* never a re-parse of the display text (spec: presentation must not infer
|
|
29
|
-
* state by matching human-readable text; change edit-lock-deepening D2).
|
|
30
|
-
* Unrecognized or missing structure renders as unknown.
|
|
31
|
-
* @param {string} sessionId @param {any} failure */
|
|
32
|
-
export function bindingGuidance(sessionId, failure) {
|
|
33
|
-
const classification = failure?.code === 'binding-duplicate' ? failure?.holderKind : undefined
|
|
34
|
-
const pid = typeof failure?.holderPid === 'number' ? failure.holderPid : undefined
|
|
35
|
-
let holder
|
|
36
|
-
if (classification === 'transferable') {
|
|
37
|
-
// Transferable (change edit-lock-binding-transfer): an idle same-process
|
|
38
|
-
// holder is taken over by the next driving ingress — calm copy, no pid,
|
|
39
|
-
// no window hunt, and NEVER the publisher-reservation warning.
|
|
40
|
-
holder = `session ${sessionId} is held by an inactive attachment of this same session. Editing attaches here automatically when you next send a message; /edit-lock retry also takes it over now. Retry does not Continue editing.`
|
|
41
|
-
} else if (classification === 'serving') {
|
|
42
|
-
// The holder is provably live and not transferable (mid-turn or a live
|
|
43
|
-
// channel proxy): name what is happening and when to retry. No window
|
|
44
|
-
// hunt (single-instance hosts exist) and no process management.
|
|
45
|
-
holder = `session ${sessionId} is being edited in another attachment right now. Wait for that turn to finish, then use /edit-lock retry in this session; retry does not Continue editing.`
|
|
46
|
-
} else if (classification === 'unknown' && pid !== undefined) {
|
|
47
|
-
holder = `session ${sessionId} has a binding in this same process (pid ${pid}) that cannot be safely reclaimed. A stale runtime incarnation may still hold its channel. Wait for the original holder to disconnect or for confirmed process shutdown.`
|
|
48
|
-
} else if (classification === 'cross-process' && pid !== undefined) {
|
|
49
|
-
holder = `session ${sessionId} has a binding held by process pid ${pid}. Quit that holder process or disconnect the original holder, and wait for confirmation.`
|
|
50
|
-
} else {
|
|
51
|
-
holder = `session ${sessionId} has a binding whose holder cannot be attested. Wait for the original holder to disconnect or for confirmed process shutdown.`
|
|
52
|
-
}
|
|
53
|
-
if (classification === 'transferable' || classification === 'serving') return holder
|
|
54
|
-
return `${holder} Then use /edit-lock retry in the original session when available; retry does not Continue editing. Do NOT remove the publisher reservation (.weir/.edit-lock.publisher-reservation) while its process is alive — removing it would split the edit authority.`
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Structured publisher fact; never classify a retry from prose.
|
|
58
|
-
* @param {number | undefined} pid @param {string} attestation
|
|
59
|
-
* @param {boolean} [revocationFailed] */
|
|
60
|
-
export function bindingFailure(pid, attestation, revocationFailed = false) {
|
|
61
|
-
return { code: 'binding-duplicate', retryable: true,
|
|
62
|
-
holderKind: revocationFailed ? 'revocation-failed' : attestation === 'serving' ? 'serving'
|
|
63
|
-
: attestation === 'serving-idle' ? 'transferable'
|
|
64
|
-
: pid !== undefined && pid !== process.pid ? 'cross-process' : 'unknown',
|
|
65
|
-
holderPid: pid ?? null }
|
|
66
|
-
}
|
|
67
|
-
/** @param {number | undefined} pid @param {string} attestation @param {boolean} [revocationFailed] */
|
|
68
|
-
export function bindingRefusal(pid, attestation, revocationFailed = false) {
|
|
69
|
-
const reason = bindingRefusalReason(pid, attestation)
|
|
70
|
-
return Object.assign(new Error(revocationFailed ? `${reason}; durable revocation was not acknowledged. Inspect authority; no replacement was admitted.` : reason),
|
|
71
|
-
{ bindingFailure: bindingFailure(pid, attestation, revocationFailed) })
|
|
72
|
-
}
|