weir-harness 1.2.2 → 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/intent-gate/matcher.js +27 -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/admin.js +24 -13
- package/src/lsp/index.js +5 -2
- package/src/lsp/registry.js +83 -17
- package/src/lsp/tools.js +137 -27
- package/src/notify/commands.js +15 -2
- package/src/notify/index.js +48 -10
- package/src/notify/web-channel.js +26 -3
- package/src/process/index.js +11 -0
- package/src/settings/index.js +2 -44
- package/src/settings/sections.js +29 -28
- 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
package/src/edit-lock/manager.js
DELETED
|
@@ -1,875 +0,0 @@
|
|
|
1
|
-
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
-
import { createEditLockState } from './state.js'
|
|
3
|
-
import { bindRequest } from './request-binding.js'
|
|
4
|
-
import { lookupOperation } from './operation-history.js'
|
|
5
|
-
import { canonicalRequestData } from './request-data.js'
|
|
6
|
-
import { admitMutation as admitHistory, publicationCandidate } from './admission.js'
|
|
7
|
-
import { administrativeState, AUTOMATIC_RECOVERY_ACTOR, digest, LATE_WRITER_RISK, ONLINE_RECOVERY_ACTOR } from './admin-ledger.js'
|
|
8
|
-
import { dispositionFor, revokedOwner } from './derived-facts.js'
|
|
9
|
-
import { canonical } from './snapshot.js'
|
|
10
|
-
import { incarnationProcess, withIncarnation } from './incarnations.js'
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Unmounted trusted manager core. Caller exclusively owns the store lifecycle;
|
|
14
|
-
* this factory does not elect a singleton or expose any file publication path.
|
|
15
|
-
* This fresh-store factory is separate from the trusted recovery entrypoint.
|
|
16
|
-
* The optional process identity (shared Liveness adapter, design D2) is
|
|
17
|
-
* registered in the durable image with the first committed write. The
|
|
18
|
-
* optional domain root and backup writer enable the online administrative
|
|
19
|
-
* recovery transaction (design D4); without them that transaction refuses.
|
|
20
|
-
* @param {{store: Awaited<ReturnType<typeof import('./store.js').openEditLockStore>>, managerIncarnation: string, processIdentity?: import('./incarnations.js').ProcessIdentity|null, root?: string|null, writeBackup?: ((file: string, bytes: Buffer) => Promise<void>)|null}} options
|
|
21
|
-
*/
|
|
22
|
-
export function createEditLockManager({ store, managerIncarnation, processIdentity = null, root = null, writeBackup = null }) {
|
|
23
|
-
const confirmed = store.snapshot()
|
|
24
|
-
if (confirmed.revision !== 0 || confirmed.state.managerIncarnation !== null) {
|
|
25
|
-
throw new Error('fresh store required; use trusted recovery entrypoint')
|
|
26
|
-
}
|
|
27
|
-
return managerCore(store, createEditLockState(managerIncarnation), { processIdentity, root, writeBackup })
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Design D3 eligibility for one recovery open: an owner is settled only when
|
|
32
|
-
* it is not already revoked, every one of its unresolved (unknown) operations
|
|
33
|
-
* originates from an incarnation whose recorded process identity differs from
|
|
34
|
-
* the recovering process, AND the shared Liveness adapter proves that process
|
|
35
|
-
* dead. Null identities and same-process remount incarnations never qualify.
|
|
36
|
-
* The records reuse the v5 adminRecoveries shape; the actor marks them
|
|
37
|
-
* automatic. @param {any} previous @param {any} base
|
|
38
|
-
* @param {{liveness: import('../capabilities/store/liveness.js').Liveness, root: string,
|
|
39
|
-
* processIdentity: import('./incarnations.js').ProcessIdentity, now: () => number}} options
|
|
40
|
-
*/
|
|
41
|
-
async function automaticSettlementRecords(previous, base, { liveness, root, processIdentity, now }) {
|
|
42
|
-
/** @type {Map<string, any[]>} */
|
|
43
|
-
const unknownByOwner = new Map()
|
|
44
|
-
for (const operation of base.operations) {
|
|
45
|
-
if (operation.phase !== 'unknown') continue
|
|
46
|
-
// An operation an earlier disposition already covers stays disposed; a
|
|
47
|
-
// re-revocation after repair binds only genuinely new unknowns.
|
|
48
|
-
if (dispositionFor(base, operation)) continue
|
|
49
|
-
const ops = unknownByOwner.get(operation.sessionId) ?? []
|
|
50
|
-
ops.push(operation)
|
|
51
|
-
unknownByOwner.set(operation.sessionId, ops)
|
|
52
|
-
}
|
|
53
|
-
/** @type {any[]} */
|
|
54
|
-
const records = []
|
|
55
|
-
for (const [owner, ops] of unknownByOwner) {
|
|
56
|
-
if (revokedOwner(base, owner)) continue
|
|
57
|
-
const session = base.sessions.find(s => s.sessionId === owner)
|
|
58
|
-
if (!session?.interrupted) continue
|
|
59
|
-
let eligible = true
|
|
60
|
-
for (const operation of ops) {
|
|
61
|
-
const process = incarnationProcess(base, operation.origin.managerIncarnation)
|
|
62
|
-
if (!process) { eligible = false; break }
|
|
63
|
-
if (process.pid === processIdentity.pid && process.bootNonce === processIdentity.bootNonce) { eligible = false; break }
|
|
64
|
-
const state = await liveness.state({ pid: process.pid, host: process.host, startIdentity: { osStart: process.osStart, bootNonce: process.bootNonce } })
|
|
65
|
-
if (state !== 'dead') { eligible = false; break }
|
|
66
|
-
}
|
|
67
|
-
if (!eligible) continue
|
|
68
|
-
const recoveryId = `automatic-${randomUUID()}`
|
|
69
|
-
const operationIds = ops.map(operation => operation.operationId).sort()
|
|
70
|
-
records.push({
|
|
71
|
-
recoveryId, root, owner,
|
|
72
|
-
expectedRevision: previous.revision, committedRevision: previous.revision + 1,
|
|
73
|
-
at: now(), actor: AUTOMATIC_RECOVERY_ACTOR,
|
|
74
|
-
reason: 'Every unresolved publication of this owner originated in a manager incarnation whose process is proven dead (pid and start identity), so that lifetime has no live writer; settled automatically on recovery open.',
|
|
75
|
-
risk: LATE_WRITER_RISK,
|
|
76
|
-
confirmation: digest({ root, owner, expectedRevision: previous.revision, operationIds, risk: LATE_WRITER_RISK }),
|
|
77
|
-
// Filled from the recovered pre-image bytes before the commit.
|
|
78
|
-
backup: { file: `admin-backup-${digest([root, recoveryId])}.json`, sha256: '', bytes: 0 },
|
|
79
|
-
operations: ops.map(operation => ({ operationId: operation.operationId, sha256: digest(operation) })),
|
|
80
|
-
releasedLocks: base.locks.filter((/** @param {any} lock */ lock) => lock.owner === owner),
|
|
81
|
-
revokedEpoch: session.executionEpoch + 1,
|
|
82
|
-
})
|
|
83
|
-
}
|
|
84
|
-
return records
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Trusted lifecycle only: caller must establish exclusive ownership and old
|
|
88
|
-
* publisher quiescence before opening the store. No IPC or automatic election.
|
|
89
|
-
* When the shared Liveness adapter, the domain root and a backup writer are
|
|
90
|
-
* supplied, unknown-phase publications of provably dead incarnation processes
|
|
91
|
-
* are administratively settled in the SAME durable commit as the recovery
|
|
92
|
-
* (design D3): one snapshot commit, one ledger record per settled owner, the
|
|
93
|
-
* unknown outcomes and history preserved.
|
|
94
|
-
* @param {{store: Awaited<ReturnType<typeof import('./store.js').openEditLockStore>>, managerIncarnation: string,
|
|
95
|
-
* processIdentity?: import('./incarnations.js').ProcessIdentity|null,
|
|
96
|
-
* liveness?: import('../capabilities/store/liveness.js').Liveness|null, root?: string|null,
|
|
97
|
-
* writeBackup?: ((file: string, bytes: Buffer) => Promise<void>)|null, now?: () => number,
|
|
98
|
-
* onAutomaticRecovery?: (info: {records: any[], revision: number}) => void}} options */
|
|
99
|
-
export async function recoverEditLockManager({ store, managerIncarnation, processIdentity = null, liveness = null, root = null, writeBackup = null, now = Date.now, onAutomaticRecovery = undefined }) {
|
|
100
|
-
const previous = store.snapshot()
|
|
101
|
-
if (!previous.state.managerIncarnation) throw new Error('historical store required')
|
|
102
|
-
// Phase 1: the ordinary recovery mapping (staging kernel, never installed).
|
|
103
|
-
const staging = createEditLockState(managerIncarnation)
|
|
104
|
-
const staged = staging.authority.beginRecovery({ ...previous.state, managerIncarnation: previous.state.managerIncarnation })
|
|
105
|
-
const mapped = staging.authority.checkpoint(staged)
|
|
106
|
-
staging.authority.discard(staged)
|
|
107
|
-
const operations = previous.state.operations.map(operation => {
|
|
108
|
-
if (operation.phase === 'prepared') return { ...operation, phase: 'not-published',
|
|
109
|
-
outcome: { kind: 'not-published', reason: 'rejected-before-dispatch' } }
|
|
110
|
-
if (operation.phase === 'publishing') return { ...operation, phase: 'unknown', outcome: { kind: 'unknown' } }
|
|
111
|
-
return operation
|
|
112
|
-
})
|
|
113
|
-
let base = withIncarnation({ ...previous.state, ...mapped, operations }, managerIncarnation, processIdentity, previous.state.managerIncarnation)
|
|
114
|
-
if (base.version === 6 && !(base.incarnations ?? []).some(entry => entry.incarnation === managerIncarnation)) {
|
|
115
|
-
// An identity-less recovery of a v6 image still registers the new
|
|
116
|
-
// incarnation — with a null identity, so it can never be auto-settled —
|
|
117
|
-
// because the registry must cover every incarnation the image references.
|
|
118
|
-
base = { ...base, incarnations: [...base.incarnations, { incarnation: managerIncarnation, process: null }] }
|
|
119
|
-
}
|
|
120
|
-
const settle = liveness !== null && root !== null && writeBackup !== null && processIdentity !== null
|
|
121
|
-
const records = settle ? await automaticSettlementRecords(previous, base, { liveness, root, processIdentity, now }) : []
|
|
122
|
-
if (records.length > 0) {
|
|
123
|
-
// Every record backs up the same recovered pre-image; the backup fields
|
|
124
|
-
// are part of the immutable ledger row, so they are filled BEFORE the
|
|
125
|
-
// administrative fold builds the committed state.
|
|
126
|
-
const bytes = store.recoveredBytes()
|
|
127
|
-
if (!bytes) throw new Error('automatic settlement requires the recovered pre-image bytes')
|
|
128
|
-
const sha256 = createHash('sha256').update(bytes).digest('hex')
|
|
129
|
-
for (const record of records) record.backup = { file: record.backup.file, sha256, bytes: bytes.length }
|
|
130
|
-
for (const record of records) await writeBackup(record.backup.file, bytes)
|
|
131
|
-
}
|
|
132
|
-
const nextState = records.reduce((state, record) => administrativeState(state, record), base)
|
|
133
|
-
// Phase 2: the installed kernel hydrates from the FINAL kernel fields
|
|
134
|
-
// (post-settlement), so the in-memory authority matches the durable image.
|
|
135
|
-
const kernel = createEditLockState(managerIncarnation)
|
|
136
|
-
const draft = kernel.authority.beginRecovery({ ...nextState, managerIncarnation: previous.state.managerIncarnation })
|
|
137
|
-
if (records.length === 0) {
|
|
138
|
-
await store.record({ expectedRevision: previous.revision, nextState })
|
|
139
|
-
} else {
|
|
140
|
-
await store.recordAdministrativeRecovery({ expectedRevision: previous.revision, nextState, base })
|
|
141
|
-
}
|
|
142
|
-
kernel.authority.install(draft)
|
|
143
|
-
if (records.length > 0) onAutomaticRecovery?.({ records: structuredClone(records), revision: previous.revision + 1 })
|
|
144
|
-
return managerCore(store, kernel, { processIdentity, root, writeBackup })
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** @param {Awaited<ReturnType<typeof import('./store.js').openEditLockStore>>} store
|
|
148
|
-
* @param {ReturnType<typeof createEditLockState>} kernel
|
|
149
|
-
* @param {{processIdentity?: import('./incarnations.js').ProcessIdentity|null, root?: string|null, writeBackup?: ((file: string, bytes: Buffer) => Promise<void>)|null}} [options] */
|
|
150
|
-
function managerCore(store, kernel, { processIdentity = null, root = null, writeBackup = null } = {}) {
|
|
151
|
-
const { operations, authority } = kernel
|
|
152
|
-
const managerIncarnation = operations.status().managerIncarnation
|
|
153
|
-
// Every durable write of an identity-aware manager carries the incarnation
|
|
154
|
-
// registry (design D2); a no-op once this incarnation is registered.
|
|
155
|
-
/** @param {any} state @returns {any} */
|
|
156
|
-
const withIdentity = processIdentity ? state => withIncarnation(state, managerIncarnation, processIdentity) : state => state
|
|
157
|
-
let confirmed = store.snapshot()
|
|
158
|
-
// Only validated administrative dispositions lift a historical fence. The
|
|
159
|
-
// immutable history still participates in operation-ID lookup before admission.
|
|
160
|
-
function admitMutation(history, candidate, mode = 'normal') {
|
|
161
|
-
return admitHistory(history.filter(op => !dispositionFor(confirmed.state, op)), candidate, mode)
|
|
162
|
-
}
|
|
163
|
-
function checkNotRevoked(sessionId) {
|
|
164
|
-
if (revokedOwner(confirmed.state, sessionId)) throw new Error('owner revoked by administrative recovery')
|
|
165
|
-
}
|
|
166
|
-
let tail = Promise.resolve()
|
|
167
|
-
/** @type {unknown} */
|
|
168
|
-
let poison
|
|
169
|
-
let closed = false
|
|
170
|
-
// Denial only: never a second ownership ledger or a grant source.
|
|
171
|
-
const cancelled = new Map()
|
|
172
|
-
function healthy() {
|
|
173
|
-
if (closed) throw new Error('manager closed')
|
|
174
|
-
if (poison) throw new Error('manager poisoned', { cause: poison })
|
|
175
|
-
}
|
|
176
|
-
/** @typedef {{validate: () => void, publish: () => Promise<{version: string}>, identify: () => string}} Publisher
|
|
177
|
-
* @type {WeakMap<object, {execution: import('./state.js').Execution, request: any, hooks: Publisher}>} */
|
|
178
|
-
const submissions = new WeakMap()
|
|
179
|
-
/** @param {import('./state.js').Execution} execution */
|
|
180
|
-
function checkExecution(execution) {
|
|
181
|
-
healthy()
|
|
182
|
-
checkNotRevoked(execution.sessionId)
|
|
183
|
-
const session = operations.status().sessions.find(s => s.sessionId === execution.sessionId)
|
|
184
|
-
if (!session || execution.managerIncarnation !== managerIncarnation ||
|
|
185
|
-
execution.executionEpoch !== session.executionEpoch || session.interrupted || cancelled.has(execution.sessionId)) {
|
|
186
|
-
throw new Error('execution is not active')
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
/** @param {string} sessionId @returns {import('./admission.js').Candidate} */
|
|
190
|
-
function sessionResources(sessionId) {
|
|
191
|
-
return { kind: 'resources', resourceIds: operations.status().locks.filter(lock => lock.owner === sessionId).map(lock => lock.resourceId) }
|
|
192
|
-
}
|
|
193
|
-
/** @param {(draft: ReturnType<typeof authority.begin>) => any} transition
|
|
194
|
-
* @param {import('./admission.js').Candidate | (() => import('./admission.js').Candidate)} candidate
|
|
195
|
-
* @param {import('./admission.js').Mode} [mode]
|
|
196
|
-
* @param {(state: any) => object} [patch] Extra durable fields (recovery budgets only). */
|
|
197
|
-
function transact(transition, candidate, mode = 'normal', patch = undefined) {
|
|
198
|
-
const pending = tail.then(async () => {
|
|
199
|
-
healthy()
|
|
200
|
-
admitMutation(confirmed.state.operations, typeof candidate === 'function' ? candidate() : candidate, mode)
|
|
201
|
-
const draft = authority.begin()
|
|
202
|
-
let result
|
|
203
|
-
try { result = transition(draft) } catch (error) {
|
|
204
|
-
authority.discard(draft)
|
|
205
|
-
throw error
|
|
206
|
-
}
|
|
207
|
-
try {
|
|
208
|
-
const nextState = { ...confirmed.state, ...authority.checkpoint(draft) }
|
|
209
|
-
if (patch) Object.assign(nextState, patch(nextState))
|
|
210
|
-
const saved = await store.record({ expectedRevision: confirmed.revision, nextState: withIdentity(nextState) })
|
|
211
|
-
authority.install(draft)
|
|
212
|
-
confirmed = saved
|
|
213
|
-
return result
|
|
214
|
-
} catch (error) {
|
|
215
|
-
poison = error
|
|
216
|
-
throw new Error('manager persistence or installation failed; poisoned', { cause: error })
|
|
217
|
-
}
|
|
218
|
-
})
|
|
219
|
-
tail = pending.then(() => {}, () => {})
|
|
220
|
-
return pending
|
|
221
|
-
}
|
|
222
|
-
return Object.freeze({
|
|
223
|
-
/** Historical target lookup for a trusted authenticated session; never acquires.
|
|
224
|
-
* @param {string} sessionId @param {string} operationId */
|
|
225
|
-
history(sessionId, operationId) {
|
|
226
|
-
healthy()
|
|
227
|
-
if (!operations.status().sessions.some(s => s.sessionId === sessionId)) throw new Error('unknown session')
|
|
228
|
-
const operation = confirmed.state.operations.find(o => o.sessionId === sessionId && o.operationId === operationId)
|
|
229
|
-
return operation ? structuredClone(operation) : null
|
|
230
|
-
},
|
|
231
|
-
/** Trusted adapter ingress: binding and observed target are immutable data.
|
|
232
|
-
* validate must synchronously recheck policy/topology/signal at queue position.
|
|
233
|
-
* Existing history is returned before current authority checks, never replayed.
|
|
234
|
-
* @param {import('./state.js').Execution} execution
|
|
235
|
-
* @param {Parameters<typeof bindRequest>[0] & {operationId: string}} request
|
|
236
|
-
* @param {Publisher} publisher */
|
|
237
|
-
prepare(execution, request, publisher) {
|
|
238
|
-
const hooks = Object.freeze({ validate: publisher.validate, publish: publisher.publish, identify: publisher.identify })
|
|
239
|
-
const captured = { ...execution }
|
|
240
|
-
const data = JSON.parse(canonicalRequestData(request))
|
|
241
|
-
const binding = bindRequest(data)
|
|
242
|
-
if (typeof data.operationId !== 'string' || !data.operationId.trim()) throw new Error('invalid operation id')
|
|
243
|
-
const pending = tail.then(async () => {
|
|
244
|
-
healthy()
|
|
245
|
-
if (!operations.status().sessions.some(s => s.sessionId === captured.sessionId)) throw new Error('unknown session')
|
|
246
|
-
const previous = lookupOperation(confirmed.state, { sessionId: captured.sessionId, operationId: data.operationId, binding })
|
|
247
|
-
if (previous) return { kind: 'history', operation: previous }
|
|
248
|
-
checkExecution(captured)
|
|
249
|
-
admitMutation(confirmed.state.operations, publicationCandidate(binding.target))
|
|
250
|
-
if (binding.target.kind === 'update') operations.checkWrite({ ...captured,
|
|
251
|
-
resourceId: binding.target.resourceId, generation: binding.target.generation })
|
|
252
|
-
hooks.validate()
|
|
253
|
-
/** @type {import('./operation-history.js').Operation} */
|
|
254
|
-
const operation = { sessionId: captured.sessionId, operationId: data.operationId,
|
|
255
|
-
origin: { executionEpoch: captured.executionEpoch, managerIncarnation: captured.managerIncarnation },
|
|
256
|
-
binding, phase: 'prepared', fence: null, outcome: null, closeouts: [] }
|
|
257
|
-
try {
|
|
258
|
-
confirmed = await store.record({ expectedRevision: confirmed.revision,
|
|
259
|
-
nextState: withIdentity({ ...confirmed.state, operations: [...confirmed.state.operations, operation] }) })
|
|
260
|
-
} catch (error) { poison = error; throw error }
|
|
261
|
-
// A cancellation during durability must not deliver a ready submission.
|
|
262
|
-
if (cancelled.has(captured.sessionId)) {
|
|
263
|
-
const rejected = { ...operation, phase: /** @type {const} */ ('not-published'),
|
|
264
|
-
outcome: { kind: /** @type {const} */ ('not-published'), reason: /** @type {const} */ ('cancelled-before-dispatch') } }
|
|
265
|
-
try {
|
|
266
|
-
confirmed = await store.record({ expectedRevision: confirmed.revision,
|
|
267
|
-
nextState: withIdentity({ ...confirmed.state, operations: confirmed.state.operations.map(o => o === confirmed.state.operations.at(-1) ? rejected : o) }) })
|
|
268
|
-
} catch (error) { poison = error; throw error }
|
|
269
|
-
throw new Error('session cancelled during preparation')
|
|
270
|
-
}
|
|
271
|
-
const submission = Object.freeze({})
|
|
272
|
-
submissions.set(submission, { execution: captured, request: data, hooks })
|
|
273
|
-
return { kind: 'ready', submission }
|
|
274
|
-
})
|
|
275
|
-
tail = pending.then(() => {}, () => {})
|
|
276
|
-
return pending
|
|
277
|
-
},
|
|
278
|
-
/** Trusted publisher hooks must close over the original fs and frozen call.
|
|
279
|
-
* No caller may dispatch outside this serialized task.
|
|
280
|
-
* @param {object} submission */
|
|
281
|
-
commit(submission) {
|
|
282
|
-
const job = submissions.get(submission)
|
|
283
|
-
if (!job) throw new Error('unknown submission')
|
|
284
|
-
const { execution, request, hooks } = job
|
|
285
|
-
const binding = bindRequest(request)
|
|
286
|
-
const key = { sessionId: execution.sessionId, operationId: request.operationId, binding }
|
|
287
|
-
const pending = tail.then(async () => {
|
|
288
|
-
healthy()
|
|
289
|
-
const original = lookupOperation(confirmed.state, key)
|
|
290
|
-
if (!original) throw new Error('missing prepared history')
|
|
291
|
-
if (original.phase !== 'prepared') return original
|
|
292
|
-
/** @param {import('./operation-history.js').Operation} operation
|
|
293
|
-
* @param {ReturnType<typeof authority.begin>} [draft] */
|
|
294
|
-
const save = async (operation, draft) => {
|
|
295
|
-
try {
|
|
296
|
-
const saved = await store.record({ expectedRevision: confirmed.revision, nextState: withIdentity({
|
|
297
|
-
...confirmed.state, ...(draft ? authority.checkpoint(draft) : {}),
|
|
298
|
-
operations: confirmed.state.operations.map(o => o.sessionId === key.sessionId && o.operationId === key.operationId ? operation : o),
|
|
299
|
-
}) })
|
|
300
|
-
if (draft) authority.install(draft)
|
|
301
|
-
confirmed = saved
|
|
302
|
-
} catch (error) { poison = error; throw error }
|
|
303
|
-
}
|
|
304
|
-
const validate = () => {
|
|
305
|
-
checkExecution(execution)
|
|
306
|
-
if (binding.target.kind === 'update') operations.checkWrite({ ...execution,
|
|
307
|
-
resourceId: binding.target.resourceId, generation: binding.target.generation })
|
|
308
|
-
hooks.validate()
|
|
309
|
-
}
|
|
310
|
-
try {
|
|
311
|
-
admitMutation(confirmed.state.operations, publicationCandidate(binding.target))
|
|
312
|
-
validate()
|
|
313
|
-
} catch (error) {
|
|
314
|
-
await save({ ...original, phase: 'not-published', outcome: { kind: 'not-published',
|
|
315
|
-
reason: cancelled.has(execution.sessionId) ? 'cancelled-before-dispatch' : 'rejected-before-dispatch' } })
|
|
316
|
-
throw error
|
|
317
|
-
}
|
|
318
|
-
/** @type {import('./operation-history.js').Operation} */
|
|
319
|
-
const publishing = { ...original, phase: 'publishing', fence: binding.target.kind === 'update'
|
|
320
|
-
? { kind: 'resource', resourceId: binding.target.resourceId }
|
|
321
|
-
: { kind: 'subtree', ancestor: binding.target.ancestor, basis: 'observed-ancestor' } }
|
|
322
|
-
let attempt
|
|
323
|
-
try {
|
|
324
|
-
attempt = await store.beginPublication({ expectedRevision: confirmed.revision,
|
|
325
|
-
nextState: withIdentity({ ...confirmed.state, operations: confirmed.state.operations.map(o =>
|
|
326
|
-
o.sessionId === key.sessionId && o.operationId === key.operationId ? publishing : o) }) }, key, hooks.publish)
|
|
327
|
-
confirmed = store.snapshot()
|
|
328
|
-
} catch (error) { poison = error; throw error }
|
|
329
|
-
try { validate() } catch (error) {
|
|
330
|
-
try {
|
|
331
|
-
confirmed = await attempt.finishWithoutDispatch(cancelled.has(execution.sessionId)
|
|
332
|
-
? 'cancelled-before-dispatch' : 'rejected-before-dispatch')
|
|
333
|
-
} catch (failure) { poison = failure; throw failure }
|
|
334
|
-
throw error
|
|
335
|
-
}
|
|
336
|
-
let draft
|
|
337
|
-
try {
|
|
338
|
-
const result = await attempt.invoke()
|
|
339
|
-
const resourceId = hooks.identify()
|
|
340
|
-
draft = authority.begin()
|
|
341
|
-
let ownership
|
|
342
|
-
if (binding.target.kind === 'create') ownership = draft.authority.settleCreated(execution, resourceId)
|
|
343
|
-
else {
|
|
344
|
-
if (resourceId !== binding.target.resourceId) throw new Error('published identity changed')
|
|
345
|
-
ownership = { ...execution, resourceId, generation: binding.target.generation }
|
|
346
|
-
draft.operations.checkWrite(ownership)
|
|
347
|
-
}
|
|
348
|
-
if (cancelled.has(execution.sessionId)) draft.authority.cancel(execution)
|
|
349
|
-
const kind = binding.target.kind === 'create' ? 'created' : 'updated'
|
|
350
|
-
const completed = { ...publishing, phase: /** @type {'created'|'updated'} */ (kind),
|
|
351
|
-
outcome: { kind: /** @type {'created'|'updated'} */ (kind), resourceId, generation: ownership.generation, version: result.version } }
|
|
352
|
-
await save(completed, draft)
|
|
353
|
-
draft = undefined
|
|
354
|
-
// Cancellation can arrive while the successful active image is syncing.
|
|
355
|
-
const session = operations.status().sessions.find(s => s.sessionId === execution.sessionId)
|
|
356
|
-
if (cancelled.has(execution.sessionId) && session && !session.interrupted) {
|
|
357
|
-
const corrective = authority.begin()
|
|
358
|
-
corrective.authority.cancel(execution)
|
|
359
|
-
await save(completed, corrective)
|
|
360
|
-
}
|
|
361
|
-
return completed
|
|
362
|
-
} catch (error) {
|
|
363
|
-
if (draft) { try { authority.discard(draft) } catch {} }
|
|
364
|
-
if (!poison) await save({ ...publishing, phase: 'unknown', outcome: { kind: 'unknown' } })
|
|
365
|
-
throw error
|
|
366
|
-
}
|
|
367
|
-
})
|
|
368
|
-
tail = pending.then(() => {}, () => {})
|
|
369
|
-
return pending
|
|
370
|
-
},
|
|
371
|
-
/** Only authenticated human intent may reach this trusted ingress.
|
|
372
|
-
* @param {import('./state.js').Execution} execution @param {string} requestId */
|
|
373
|
-
issueExecutionReceipt(execution, requestId) {
|
|
374
|
-
const captured = { ...execution }
|
|
375
|
-
return transact(draft => {
|
|
376
|
-
if (cancelled.has(captured.sessionId)) throw new Error('session cancelled')
|
|
377
|
-
// A revoked owner is NOT refused here: receipt issuance is half of the
|
|
378
|
-
// trusted repair path; the epoch binding still fences stale authority.
|
|
379
|
-
return draft.authority.issueExecutionReceipt(captured, requestId)
|
|
380
|
-
}, { kind: 'none' }).then(async receipt => {
|
|
381
|
-
const cancellation = cancelled.get(captured.sessionId)
|
|
382
|
-
if (cancellation) {
|
|
383
|
-
await cancellation
|
|
384
|
-
throw new Error('session cancelled during receipt issuance')
|
|
385
|
-
}
|
|
386
|
-
healthy()
|
|
387
|
-
const current = operations.status().sessions.find(s => s.sessionId === captured.sessionId)
|
|
388
|
-
if (current?.executionEpoch !== captured.executionEpoch) throw new Error('stale receipt execution')
|
|
389
|
-
return receipt
|
|
390
|
-
})
|
|
391
|
-
},
|
|
392
|
-
/** @param {import('./state.js').Execution} execution @param {string} requestId @param {object} receipt */
|
|
393
|
-
resume(execution, requestId, receipt) {
|
|
394
|
-
const captured = { ...execution }
|
|
395
|
-
return transact(draft => {
|
|
396
|
-
if (cancelled.has(captured.sessionId)) throw new Error('session cancelled')
|
|
397
|
-
// A revoked owner is NOT refused here: the resume commits the epoch
|
|
398
|
-
// advance that atomically ends the revocation (no ledger change).
|
|
399
|
-
// Admission runs in the resume mode: the epoch advance publishes nothing,
|
|
400
|
-
// so unresolved resource-level fences never block the trusted Continue;
|
|
401
|
-
// the fenced resources stay barred to every other ingress.
|
|
402
|
-
return draft.operations.resume(captured, requestId, receipt)
|
|
403
|
-
}, () => sessionResources(captured.sessionId), 'resume').then(async resumed => {
|
|
404
|
-
const cancellation = cancelled.get(captured.sessionId)
|
|
405
|
-
if (cancellation) {
|
|
406
|
-
await cancellation
|
|
407
|
-
throw new Error('session cancelled during resume')
|
|
408
|
-
}
|
|
409
|
-
healthy()
|
|
410
|
-
const current = operations.status().sessions.find(s => s.sessionId === captured.sessionId)
|
|
411
|
-
if (current?.interrupted || current?.executionEpoch !== resumed.executionEpoch) throw new Error('stale resumed execution')
|
|
412
|
-
return resumed
|
|
413
|
-
})
|
|
414
|
-
},
|
|
415
|
-
/** @param {string} sessionId */
|
|
416
|
-
openSession(sessionId) {
|
|
417
|
-
return transact(draft => {
|
|
418
|
-
if (cancelled.has(sessionId)) throw new Error('session cancelled')
|
|
419
|
-
checkNotRevoked(sessionId)
|
|
420
|
-
return draft.authority.openSession(sessionId)
|
|
421
|
-
}, { kind: 'none' }).then(async execution => {
|
|
422
|
-
const cancellation = cancelled.get(sessionId)
|
|
423
|
-
if (cancellation) {
|
|
424
|
-
await cancellation
|
|
425
|
-
throw new Error('session cancelled during registration')
|
|
426
|
-
}
|
|
427
|
-
healthy()
|
|
428
|
-
return execution
|
|
429
|
-
})
|
|
430
|
-
},
|
|
431
|
-
/** Trusted lifecycle ingress, never a model-facing authority argument.
|
|
432
|
-
* Cancels the session at its queue position, including pending registration.
|
|
433
|
-
* @param {string} sessionId */
|
|
434
|
-
cancelSession(sessionId) {
|
|
435
|
-
healthy()
|
|
436
|
-
if (typeof sessionId !== 'string' || !sessionId.trim()) throw new Error('invalid session')
|
|
437
|
-
const existing = cancelled.get(sessionId)
|
|
438
|
-
if (existing) return existing
|
|
439
|
-
const pending = transact(draft => {
|
|
440
|
-
const session = operations.status().sessions.find(s => s.sessionId === sessionId)
|
|
441
|
-
if (!session) throw new Error('unknown session')
|
|
442
|
-
if (session.interrupted) return { sessionId, executionEpoch: session.executionEpoch, managerIncarnation }
|
|
443
|
-
return draft.authority.cancel({ sessionId, executionEpoch: session.executionEpoch, managerIncarnation })
|
|
444
|
-
}, { kind: 'none' }, 'revocation')
|
|
445
|
-
cancelled.set(sessionId, pending)
|
|
446
|
-
void pending.then(() => cancelled.delete(sessionId), () => {})
|
|
447
|
-
return pending
|
|
448
|
-
},
|
|
449
|
-
/** Trusted canonical identity input only; not a path-based tool interface.
|
|
450
|
-
* @param {import('./state.js').Execution} execution @param {string} resourceId */
|
|
451
|
-
acquire(execution, resourceId) {
|
|
452
|
-
const captured = { ...execution }
|
|
453
|
-
return transact(draft => {
|
|
454
|
-
if (cancelled.has(captured.sessionId)) throw new Error('session cancelled')
|
|
455
|
-
checkNotRevoked(captured.sessionId)
|
|
456
|
-
return draft.operations.acquire(captured, resourceId)
|
|
457
|
-
}, { kind: 'resources', resourceIds: [resourceId] }).then(async token => {
|
|
458
|
-
const cancellation = cancelled.get(captured.sessionId)
|
|
459
|
-
if (cancellation) {
|
|
460
|
-
await cancellation
|
|
461
|
-
throw new Error('session cancelled during acquisition')
|
|
462
|
-
}
|
|
463
|
-
healthy()
|
|
464
|
-
operations.checkWrite(token)
|
|
465
|
-
return token
|
|
466
|
-
})
|
|
467
|
-
},
|
|
468
|
-
/** Trusted per-file confirmation after explicit resume. Never acquires an
|
|
469
|
-
* unowned resource: only this session's pending-confirmation lock activates.
|
|
470
|
-
* @param {import('./state.js').Execution} execution @param {string} resourceId */
|
|
471
|
-
confirm(execution, resourceId) {
|
|
472
|
-
const captured = { ...execution }
|
|
473
|
-
return transact(draft => {
|
|
474
|
-
if (cancelled.has(captured.sessionId)) throw new Error('session cancelled')
|
|
475
|
-
const lock = operations.status().locks.find(item => item.resourceId === resourceId)
|
|
476
|
-
if (!lock || lock.owner !== captured.sessionId || lock.status !== 'pending-confirmation') {
|
|
477
|
-
throw new Error('resource has no pending confirmation for this session')
|
|
478
|
-
}
|
|
479
|
-
return draft.operations.acquire(captured, resourceId)
|
|
480
|
-
}, { kind: 'resources', resourceIds: [resourceId] }).then(async token => {
|
|
481
|
-
const cancellation = cancelled.get(captured.sessionId)
|
|
482
|
-
if (cancellation) {
|
|
483
|
-
await cancellation
|
|
484
|
-
throw new Error('session cancelled during confirmation')
|
|
485
|
-
}
|
|
486
|
-
healthy()
|
|
487
|
-
operations.checkWrite(token)
|
|
488
|
-
return token
|
|
489
|
-
})
|
|
490
|
-
},
|
|
491
|
-
/** Success-only cleanup is atomic; cancellation retains all batch ownership.
|
|
492
|
-
* @param {import('./state.js').Ownership[]} tokens */
|
|
493
|
-
releaseMany(tokens) {
|
|
494
|
-
const captured = tokens.map(token => ({ ...token }))
|
|
495
|
-
return transact(draft => {
|
|
496
|
-
for (const token of captured) {
|
|
497
|
-
if (cancelled.has(token.sessionId)) throw new Error('session cancelled')
|
|
498
|
-
draft.operations.checkWrite(token)
|
|
499
|
-
}
|
|
500
|
-
for (const token of captured) draft.operations.release(token)
|
|
501
|
-
}, { kind: 'resources', resourceIds: captured.map(token => token.resourceId) }, 'release')
|
|
502
|
-
},
|
|
503
|
-
/** Atomic acquisition of existing canonical resources for a trusted batch editor.
|
|
504
|
-
* Pre-existing ownership is retained and explicitly excluded from cleanup.
|
|
505
|
-
* @param {import('./state.js').Execution} execution @param {string[]} resourceIds */
|
|
506
|
-
acquireMany(execution, resourceIds) {
|
|
507
|
-
const captured = { ...execution }
|
|
508
|
-
const ids = [...new Set(resourceIds)].sort()
|
|
509
|
-
return transact(draft => {
|
|
510
|
-
if (cancelled.has(captured.sessionId)) throw new Error('session cancelled')
|
|
511
|
-
const before = operations.status().locks
|
|
512
|
-
if (before.some(lock => ids.includes(lock.resourceId) && lock.status === 'pending-confirmation')) {
|
|
513
|
-
throw new Error('resource requires explicit per-file confirmation (/edit-lock confirm <path>)')
|
|
514
|
-
}
|
|
515
|
-
const tokens = ids.map(resourceId => draft.operations.acquire(captured, resourceId))
|
|
516
|
-
for (const token of tokens) draft.operations.checkWrite(token)
|
|
517
|
-
return { tokens, temporary: tokens.filter(token => !before.some(lock => lock.resourceId === token.resourceId)) }
|
|
518
|
-
}, { kind: 'resources', resourceIds: ids }).then(async result => {
|
|
519
|
-
const cancellation = cancelled.get(captured.sessionId)
|
|
520
|
-
if (cancellation) { await cancellation; throw new Error('session cancelled during batch acquisition') }
|
|
521
|
-
healthy()
|
|
522
|
-
for (const token of result.tokens) operations.checkWrite(token)
|
|
523
|
-
return result
|
|
524
|
-
})
|
|
525
|
-
},
|
|
526
|
-
/** Cleanup authenticates the captured owner/generation through the kernel.
|
|
527
|
-
* @param {import('./state.js').Ownership} token */
|
|
528
|
-
release(token) {
|
|
529
|
-
const captured = { ...token }
|
|
530
|
-
return transact(draft => draft.operations.release(captured), { kind: 'resources', resourceIds: [captured.resourceId] }, 'release')
|
|
531
|
-
},
|
|
532
|
-
/** Give up every active lock of one session: the disposition used when a
|
|
533
|
-
* finished turn's locks were never sorted out. Retention is a policy decision
|
|
534
|
-
* that lives above this layer, so the caller supplies the cap. Refusal is
|
|
535
|
-
* always safe. @param {string} sessionId @param {number} now */
|
|
536
|
-
releaseActive(sessionId, now, requireExpired = false) {
|
|
537
|
-
return transact(draft => {
|
|
538
|
-
if (requireExpired && !operations.settleHold(sessionId, now).expired) return []
|
|
539
|
-
const status = operations.status()
|
|
540
|
-
const session = status.sessions.find(item => item.sessionId === sessionId)
|
|
541
|
-
if (!session) throw new Error('unknown session')
|
|
542
|
-
const released = []
|
|
543
|
-
for (const lock of status.locks) {
|
|
544
|
-
if (lock.owner !== sessionId || lock.status !== 'active') continue
|
|
545
|
-
draft.operations.release({ managerIncarnation, sessionId, executionEpoch: session.executionEpoch, resourceId: lock.resourceId, generation: lock.generation })
|
|
546
|
-
released.push(lock.resourceId)
|
|
547
|
-
}
|
|
548
|
-
return released
|
|
549
|
-
}, () => ({ kind: 'resources', resourceIds: operations.status().locks
|
|
550
|
-
.filter(lock => lock.owner === sessionId && lock.status === 'active').map(lock => lock.resourceId) }), 'release')
|
|
551
|
-
},
|
|
552
|
-
/** Charge and record an explicit retention request (design D2). The caller
|
|
553
|
-
* resolves the configured caps and supplies the instant, so this layer holds
|
|
554
|
-
* no policy and no clock. Ownership is untouched: retention only extends it.
|
|
555
|
-
* @param {string} sessionId @param {number} ms
|
|
556
|
-
* @param {{now: number, singleMaxMs: number, cumulativeMaxMs: number}} caps
|
|
557
|
-
* @returns {Promise<import('./state.js').HoldState>} */
|
|
558
|
-
hold(sessionId, ms, caps) {
|
|
559
|
-
// The candidate is computed on the draft, i.e. at this request's FIFO
|
|
560
|
-
// position: computed earlier on the live kernel it could be based on a hold
|
|
561
|
-
// that a queued turn start is about to end, and would revive it. The draft
|
|
562
|
-
// is only installed after persistence, so a store failure changes nothing.
|
|
563
|
-
return transact(draft => draft.operations.hold(draft.operations.holdCandidate(sessionId, ms, caps)), () => sessionResources(sessionId))
|
|
564
|
-
},
|
|
565
|
-
/** Retention state of one session, settled against the caller's instant so a
|
|
566
|
-
* missed timer cannot leave stale ownership. Pure read; never mutates.
|
|
567
|
-
* @param {string} sessionId @param {number} now */
|
|
568
|
-
settlement(sessionId, now) {
|
|
569
|
-
healthy()
|
|
570
|
-
return operations.settleHold(sessionId, now)
|
|
571
|
-
},
|
|
572
|
-
/** The holder started a new turn: every lock it holds leaves the holding state
|
|
573
|
-
* at once (design D4). Never releases. @param {string} sessionId @param {number} now */
|
|
574
|
-
endHold(sessionId, now) {
|
|
575
|
-
healthy()
|
|
576
|
-
const held = operations.settleHold(sessionId, now)
|
|
577
|
-
if (!held.held) return held
|
|
578
|
-
return transact(draft => draft.operations.endHold(sessionId), { kind: 'none' }, 'revocation').then(() => operations.settleHold(sessionId, now))
|
|
579
|
-
},
|
|
580
|
-
/** Consented transfer: release the holder's exact generation and acquire
|
|
581
|
-
* for the requester in ONE durable transaction. Either side cancelled,
|
|
582
|
-
* stale or interrupted rejects the whole transfer and ownership stays.
|
|
583
|
-
* @param {import('./state.js').Ownership} holder @param {import('./state.js').Execution} requester */
|
|
584
|
-
transfer(holder, requester) {
|
|
585
|
-
const from = { ...holder }, to = { ...requester }
|
|
586
|
-
return transact(draft => {
|
|
587
|
-
if (cancelled.has(from.sessionId) || cancelled.has(to.sessionId)) throw new Error('session cancelled')
|
|
588
|
-
draft.operations.release(from)
|
|
589
|
-
return draft.operations.acquire(to, from.resourceId)
|
|
590
|
-
}, { kind: 'resources', resourceIds: [from.resourceId] }).then(async token => {
|
|
591
|
-
const cancellation = cancelled.get(to.sessionId)
|
|
592
|
-
if (cancellation) { await cancellation; throw new Error('session cancelled during transfer') }
|
|
593
|
-
healthy()
|
|
594
|
-
return token
|
|
595
|
-
})
|
|
596
|
-
},
|
|
597
|
-
/** Controlled human unlock. Runs at its FIFO position, so every earlier
|
|
598
|
-
* commit has settled; refuses removal of unresolved publication ownership
|
|
599
|
-
* or a generation other than the expected current generation. A
|
|
600
|
-
* prepared old-owner write then fails its generation check. There is no
|
|
601
|
-
* unconditional variant. @param {string} resourceId @param {number} generation */
|
|
602
|
-
adminUnlock(resourceId, generation) {
|
|
603
|
-
return transact(draft => {
|
|
604
|
-
const status = operations.status()
|
|
605
|
-
const lock = status.locks.find(item => item.resourceId === resourceId)
|
|
606
|
-
if (!lock) throw new Error('resource is not locked')
|
|
607
|
-
if (lock.generation !== generation) throw new Error(`expected generation ${generation}, current is ${lock.generation}`)
|
|
608
|
-
const session = status.sessions.find(item => item.sessionId === lock.owner)
|
|
609
|
-
if (!session) throw new Error('owner session unknown')
|
|
610
|
-
draft.operations.release({ managerIncarnation, sessionId: lock.owner, executionEpoch: session.executionEpoch, resourceId, generation })
|
|
611
|
-
return { resourceId, generation, owner: lock.owner }
|
|
612
|
-
}, { kind: 'resources', resourceIds: [resourceId] }, 'release')
|
|
613
|
-
},
|
|
614
|
-
/** Trusted online administrative recovery (design D4): the settings-plane
|
|
615
|
-
* operator's one explicit click, executed as ONE serialized transaction of
|
|
616
|
-
* the running manager — locally or forwarded over the peer channel. Every
|
|
617
|
-
* check the offline ADMIN OVERRIDE performs is re-verified HERE at the
|
|
618
|
-
* FIFO execution point against the live confirmed state: owner
|
|
619
|
-
* interrupted, every unresolved operation of the owner unknown-phase, the
|
|
620
|
-
* operation id set exact (sorted compare), the revision unmoved and the
|
|
621
|
-
* confirmation digest matching the SAME scoped algorithm (canonical JSON
|
|
622
|
-
* of {root, owner, expectedRevision, operationIds sorted, risk:
|
|
623
|
-
* LATE_WRITER_RISK}, SHA-256, "ADMIN OVERRIDE <hex>"). The durable commit
|
|
624
|
-
* is the offline commit's exact shape: the adminRecoveries ledger record
|
|
625
|
-
* (actor marks it online), the owner's locks released and its epoch
|
|
626
|
-
* revoked, admission unblocked; unknown outcomes and operation history
|
|
627
|
-
* are byte-identical. A retry with the same recoveryId returns the
|
|
628
|
-
* existing record WITHOUT re-releasing or appending. Refusals carry a
|
|
629
|
-
* `weir-edit-lock/*` code and never persist; a backup-writer failure
|
|
630
|
-
* happens before the commit attempt and changes nothing; only a failed
|
|
631
|
-
* or uncertain durable commit poisons (same discipline as transact).
|
|
632
|
-
* @param {any} rawInput
|
|
633
|
-
* @returns {Promise<{revision: number, idempotent: boolean, record: any}>} */
|
|
634
|
-
adminRecoverOnline(rawInput) {
|
|
635
|
-
healthy()
|
|
636
|
-
const refuse = (/** @type {string} */ code, /** @type {string} */ message) => {
|
|
637
|
-
throw Object.assign(new Error(message), { code: `weir-edit-lock/${code}`, recoveryRefusal: true })
|
|
638
|
-
}
|
|
639
|
-
if (!root || !writeBackup) refuse('unavailable', 'online administrative recovery requires a domain root and backup writer in this composition')
|
|
640
|
-
// Capture before the first await: the operator request cannot change in flight.
|
|
641
|
-
const input = structuredClone(rawInput)
|
|
642
|
-
const keys = ['owner', 'expectedRevision', 'operationIds', 'recoveryId', 'reason', 'acceptLateWriterRisk', 'confirmation']
|
|
643
|
-
if (!input || typeof input !== 'object' || Array.isArray(input) ||
|
|
644
|
-
Object.keys(input).length !== keys.length || !keys.every(key => Object.hasOwn(input, key))) {
|
|
645
|
-
refuse('invalid-recovery', 'Exact recovery fields required')
|
|
646
|
-
}
|
|
647
|
-
for (const key of ['owner', 'recoveryId', 'reason']) {
|
|
648
|
-
if (typeof input[key] !== 'string' || !input[key].trim() ||
|
|
649
|
-
input[key].length > (key === 'reason' ? 2000 : key === 'recoveryId' ? 128 : 512) || input[key].includes('\0')) {
|
|
650
|
-
refuse('invalid-recovery', `Invalid ${key}`)
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) refuse('invalid-recovery', 'Expected revision required')
|
|
654
|
-
if (!Array.isArray(input.operationIds) || input.operationIds.length === 0 || input.operationIds.length > 10000 ||
|
|
655
|
-
!input.operationIds.every((/** @type {any} */ id) => typeof id === 'string' && id.trim() && id.length <= 1024)) {
|
|
656
|
-
refuse('invalid-recovery', 'Operation IDs required')
|
|
657
|
-
}
|
|
658
|
-
if (new Set(input.operationIds).size !== input.operationIds.length) refuse('invalid-recovery', 'Duplicate operation ID')
|
|
659
|
-
if (input.acceptLateWriterRisk !== true) refuse('invalid-recovery', 'Explicit late-writer risk acceptance required')
|
|
660
|
-
const scoped = { root, owner: input.owner, expectedRevision: input.expectedRevision, operationIds: [...input.operationIds].sort(), risk: LATE_WRITER_RISK }
|
|
661
|
-
if (input.confirmation !== `ADMIN OVERRIDE ${digest(scoped)}`) refuse('confirmation-mismatch', 'Explicit scoped ADMIN OVERRIDE confirmation required')
|
|
662
|
-
const pending = tail.then(async () => {
|
|
663
|
-
healthy()
|
|
664
|
-
const state = confirmed.state
|
|
665
|
-
// Idempotent retry: the same recoveryId returns the committed record
|
|
666
|
-
// without re-releasing, re-revoking or appending.
|
|
667
|
-
const existing = state.adminRecoveries?.find((/** @type {any} */ row) => row.recoveryId === input.recoveryId)
|
|
668
|
-
if (existing) {
|
|
669
|
-
if (existing.confirmation !== digest(scoped) || existing.reason !== input.reason) refuse('invalid-recovery', 'Recovery ID reused with different request')
|
|
670
|
-
return { revision: confirmed.revision, idempotent: true, record: structuredClone(existing) }
|
|
671
|
-
}
|
|
672
|
-
if (confirmed.revision !== input.expectedRevision) refuse('revision-conflict', 'Authority revision changed; inspect and confirm again')
|
|
673
|
-
if (revokedOwner(state, input.owner)) refuse('invalid-recovery', 'owner already revoked by administrative recovery')
|
|
674
|
-
const session = state.sessions.find((/** @type {any} */ row) => row.sessionId === input.owner)
|
|
675
|
-
if (!session || !session.interrupted) refuse('invalid-recovery', 'Only an interrupted historical owner can be overridden')
|
|
676
|
-
const unresolved = state.operations.filter((/** @type {any} */ op) => op.sessionId === input.owner && ['prepared', 'publishing', 'unknown'].includes(op.phase) && !dispositionFor(state, op))
|
|
677
|
-
if (!(unresolved.length > 0 && unresolved.every((/** @type {any} */ op) => op.phase === 'unknown'))) refuse('invalid-recovery', 'Recover to stable unknown history before administrative override')
|
|
678
|
-
if (canonical(unresolved.map((/** @type {any} */ op) => op.operationId).sort()) !== canonical(scoped.operationIds)) refuse('invalid-recovery', 'Confirm every unresolved operation for this owner')
|
|
679
|
-
const bytes = store.currentBytes()
|
|
680
|
-
if (!bytes) refuse('unavailable', 'online administrative recovery requires the committed authority bytes')
|
|
681
|
-
const backup = { file: `admin-backup-${digest([root, input.recoveryId])}.json`,
|
|
682
|
-
sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length }
|
|
683
|
-
const record = { recoveryId: input.recoveryId, root, owner: input.owner,
|
|
684
|
-
expectedRevision: confirmed.revision, committedRevision: confirmed.revision + 1, at: Date.now(), actor: ONLINE_RECOVERY_ACTOR,
|
|
685
|
-
reason: input.reason, risk: LATE_WRITER_RISK, confirmation: digest(scoped), backup,
|
|
686
|
-
operations: unresolved.map((/** @type {any} */ op) => ({ operationId: op.operationId, sha256: digest(op) })),
|
|
687
|
-
releasedLocks: state.locks.filter((/** @type {any} */ lock) => lock.owner === input.owner), revokedEpoch: session.executionEpoch + 1 }
|
|
688
|
-
// The kernel fold is exactly administrativeState's ordinary part:
|
|
689
|
-
// revoke the owner's epoch (cancel) and release its exact locks; the
|
|
690
|
-
// batch allowance ends with the last lock. The ledger append and the
|
|
691
|
-
// version discipline are applied on top of the checkpoint, so the
|
|
692
|
-
// committed state is byte-equal to administrativeState(base, record).
|
|
693
|
-
const draft = authority.begin()
|
|
694
|
-
try {
|
|
695
|
-
draft.authority.cancel({ managerIncarnation, sessionId: session.sessionId, executionEpoch: session.executionEpoch })
|
|
696
|
-
for (const lock of record.releasedLocks) {
|
|
697
|
-
draft.operations.release({ managerIncarnation, sessionId: session.sessionId, executionEpoch: record.revokedEpoch, resourceId: lock.resourceId, generation: lock.generation })
|
|
698
|
-
}
|
|
699
|
-
} catch (error) {
|
|
700
|
-
authority.discard(draft)
|
|
701
|
-
throw error
|
|
702
|
-
}
|
|
703
|
-
const base = withIdentity(state)
|
|
704
|
-
const nextState = { ...base, ...authority.checkpoint(draft), version: /** @type {5|6} */ (base.version === 6 ? 6 : 5),
|
|
705
|
-
adminRecoveries: [...(base.adminRecoveries ?? []), structuredClone(record)] }
|
|
706
|
-
// The backup must be durable BEFORE the commit that references it; a
|
|
707
|
-
// failure here attempted no persistence and leaves the manager healthy.
|
|
708
|
-
try { await writeBackup(record.backup.file, bytes) } catch (error) {
|
|
709
|
-
try { authority.discard(draft) } catch { /* draft already closed */ }
|
|
710
|
-
throw error
|
|
711
|
-
}
|
|
712
|
-
try {
|
|
713
|
-
const saved = await store.recordAdministrativeRecovery({ expectedRevision: confirmed.revision, nextState, base })
|
|
714
|
-
authority.install(draft)
|
|
715
|
-
confirmed = saved
|
|
716
|
-
return { revision: saved.revision, idempotent: false, record }
|
|
717
|
-
} catch (error) {
|
|
718
|
-
try { authority.discard(draft) } catch { /* draft already closed */ }
|
|
719
|
-
poison = error
|
|
720
|
-
throw new Error('manager persistence or installation failed; poisoned', { cause: error })
|
|
721
|
-
}
|
|
722
|
-
})
|
|
723
|
-
tail = pending.then(() => {}, () => {})
|
|
724
|
-
return pending
|
|
725
|
-
},
|
|
726
|
-
/** Trusted maintenance ingress for the stale-lock sweep: conditionally
|
|
727
|
-
* release rows a publisher-side scan observed with a missing target. Every
|
|
728
|
-
* row runs in its OWN FIFO transaction that re-verifies the full
|
|
729
|
-
* observation at the execution point — owner, generation, owner execution
|
|
730
|
-
* epoch and lock status — and re-probes through the caller's isMissing
|
|
731
|
-
* that the resource is still missing; only then an ordinary release.
|
|
732
|
-
* Scope is every observed row regardless of owner state (active, holding,
|
|
733
|
-
* pending-confirmation, user-interrupted, abnormal): the row predicate,
|
|
734
|
-
* not the owner class, provides the safety. Any mismatch, an already-gone
|
|
735
|
-
* row, or the ordinary unresolved-publication fence admission refusal is
|
|
736
|
-
* a SKIP — a discarded draft, never a persisted no-op, never an error —
|
|
737
|
-
* and every other row is still processed. Persistence, poison and any
|
|
738
|
-
* unexpected error are NOT skips: the row lands in `failed` so the caller
|
|
739
|
-
* can log it (fail-closed semantics untouched: nothing was persisted).
|
|
740
|
-
* The missing-target probe is injected: this layer performs no filesystem IO.
|
|
741
|
-
* @param {{resourceId: string, owner: string, generation: number, executionEpoch: number, status: string}[]} observed
|
|
742
|
-
* @param {(resourceId: string) => boolean} isMissing
|
|
743
|
-
* @returns {Promise<{released: {resourceId: string, owner: string, generation: number}[], skipped: {row: object, reason: string}[], failed: {row: object, reason: string}[]}>} */
|
|
744
|
-
async releaseStale(observed, isMissing) {
|
|
745
|
-
healthy()
|
|
746
|
-
if (typeof isMissing !== 'function') throw new Error('stale release requires a missing-target probe')
|
|
747
|
-
const released = []
|
|
748
|
-
const skipped = []
|
|
749
|
-
/** @type {{row: object, reason: string}[]} */
|
|
750
|
-
const failed = []
|
|
751
|
-
/** An expected skip: the observation no longer holds at the execution
|
|
752
|
-
* point; a discarded draft, never a persisted no-op, never an error.
|
|
753
|
-
* @param {string} reason */
|
|
754
|
-
const staleSkip = reason => Object.assign(new Error(reason), { staleSkip: true })
|
|
755
|
-
for (const input of Array.isArray(observed) ? observed : []) {
|
|
756
|
-
const row = { resourceId: input?.resourceId, owner: input?.owner, generation: input?.generation, executionEpoch: input?.executionEpoch, status: input?.status }
|
|
757
|
-
try {
|
|
758
|
-
released.push(await transact(draft => {
|
|
759
|
-
const status = operations.status()
|
|
760
|
-
const lock = status.locks.find(item => item.resourceId === row.resourceId)
|
|
761
|
-
if (!lock) throw staleSkip('stale row already released')
|
|
762
|
-
const session = status.sessions.find(item => item.sessionId === lock.owner)
|
|
763
|
-
if (!session || lock.owner !== row.owner || lock.generation !== row.generation ||
|
|
764
|
-
lock.status !== row.status || session.executionEpoch !== row.executionEpoch) {
|
|
765
|
-
throw staleSkip('stale row changed before execution')
|
|
766
|
-
}
|
|
767
|
-
if (!isMissing(row.resourceId)) throw staleSkip('stale row target exists again')
|
|
768
|
-
draft.operations.release({ managerIncarnation, sessionId: lock.owner, executionEpoch: session.executionEpoch, resourceId: lock.resourceId, generation: lock.generation })
|
|
769
|
-
return { resourceId: lock.resourceId, owner: lock.owner, generation: lock.generation }
|
|
770
|
-
}, { kind: 'resources', resourceIds: [row.resourceId] }, 'release'))
|
|
771
|
-
} catch (error) {
|
|
772
|
-
const reason = String(/** @type {any} */ (error)?.message ?? error)
|
|
773
|
-
// Quiet, counted skips: the row was gone, the predicate no longer
|
|
774
|
-
// matched, or ordinary fence admission refused. Anything else —
|
|
775
|
-
// persistence failure, poison, unexpected — is a surfaced failure.
|
|
776
|
-
if (/** @type {any} */ (error)?.staleSkip === true || /** @type {any} */ (error)?.admissionRefusal === true) skipped.push({ row, reason })
|
|
777
|
-
else failed.push({ row, reason })
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
return { released, skipped, failed }
|
|
781
|
-
},
|
|
782
|
-
/** Trusted classifier only (durable turn/end error). Marks every retained,
|
|
783
|
-
* not yet abnormal lock of the session abnormal; never releases. Subtractive,
|
|
784
|
-
* so it may cross an unresolved publication fence.
|
|
785
|
-
* @param {string} sessionId @param {string} reason */
|
|
786
|
-
markAbnormal(sessionId, reason) {
|
|
787
|
-
return transact(draft => {
|
|
788
|
-
const status = operations.status()
|
|
789
|
-
const session = status.sessions.find(item => item.sessionId === sessionId)
|
|
790
|
-
if (!session) throw new Error('unknown session')
|
|
791
|
-
const marked = []
|
|
792
|
-
for (const lock of status.locks) {
|
|
793
|
-
if (lock.owner !== sessionId || lock.status === 'abnormal') continue
|
|
794
|
-
draft.authority.markAbnormal({ managerIncarnation, sessionId, executionEpoch: session.executionEpoch, resourceId: lock.resourceId, generation: lock.generation }, reason)
|
|
795
|
-
marked.push(lock.resourceId)
|
|
796
|
-
}
|
|
797
|
-
return marked
|
|
798
|
-
}, { kind: 'none' }, 'revocation')
|
|
799
|
-
},
|
|
800
|
-
/** Monotonic durable recovery budget charge; restart never refunds it.
|
|
801
|
-
* @param {string} sessionId @param {{attempts?: number, elapsedMs?: number, pauseMs?: number}} delta */
|
|
802
|
-
chargeRecovery(sessionId, delta) {
|
|
803
|
-
for (const value of [delta.attempts ?? 0, delta.elapsedMs ?? 0, delta.pauseMs ?? 0]) {
|
|
804
|
-
if (!Number.isSafeInteger(value) || value < 0) throw new Error('invalid recovery charge')
|
|
805
|
-
}
|
|
806
|
-
/** @type {any} */
|
|
807
|
-
let charged
|
|
808
|
-
return transact(() => {
|
|
809
|
-
if (!operations.status().sessions.some(item => item.sessionId === sessionId)) throw new Error('unknown session')
|
|
810
|
-
}, { kind: 'none' }, 'revocation', state => {
|
|
811
|
-
const recovery = state.recovery.map(/** @param {any} item */ item => ({ ...item }))
|
|
812
|
-
let entry = recovery.find(/** @param {any} item */ item => item.sessionId === sessionId)
|
|
813
|
-
if (!entry) { entry = { sessionId, attempts: 0, elapsedMs: 0, pauseMs: 0 }; recovery.push(entry) }
|
|
814
|
-
entry.attempts += delta.attempts ?? 0
|
|
815
|
-
entry.elapsedMs += delta.elapsedMs ?? 0
|
|
816
|
-
entry.pauseMs += delta.pauseMs ?? 0
|
|
817
|
-
charged = { ...entry }
|
|
818
|
-
return { recovery }
|
|
819
|
-
}).then(() => charged)
|
|
820
|
-
},
|
|
821
|
-
/** Durable recovery usage of one session (zeros when never charged).
|
|
822
|
-
* @param {string} sessionId */
|
|
823
|
-
recoveryUsage(sessionId) {
|
|
824
|
-
healthy()
|
|
825
|
-
const entry = confirmed.state.recovery.find(/** @param {any} item */ item => item.sessionId === sessionId)
|
|
826
|
-
return entry ? { ...entry } : { sessionId, attempts: 0, elapsedMs: 0, pauseMs: 0 }
|
|
827
|
-
},
|
|
828
|
-
/** Synchronous denial overlay must be checked before publication.
|
|
829
|
-
* @param {import('./state.js').Ownership} token */
|
|
830
|
-
checkWrite(token) {
|
|
831
|
-
healthy()
|
|
832
|
-
if (cancelled.has(token.sessionId)) throw new Error('session cancelled')
|
|
833
|
-
return operations.checkWrite(token)
|
|
834
|
-
},
|
|
835
|
-
/** @param {import('./state.js').Execution} execution */
|
|
836
|
-
cancel(execution) {
|
|
837
|
-
healthy()
|
|
838
|
-
execution = { ...execution }
|
|
839
|
-
const session = operations.status().sessions.find(s => s.sessionId === execution.sessionId)
|
|
840
|
-
if (execution.managerIncarnation !== managerIncarnation || !session || session.executionEpoch !== execution.executionEpoch) {
|
|
841
|
-
throw new Error('invalid cancellation execution')
|
|
842
|
-
}
|
|
843
|
-
const existing = cancelled.get(execution.sessionId)
|
|
844
|
-
if (existing) return existing
|
|
845
|
-
const pending = transact(draft => {
|
|
846
|
-
// Admission authenticated the live execution; cancellation also seals an
|
|
847
|
-
// earlier queued resume that installs before this revocation is durable.
|
|
848
|
-
const current = operations.status().sessions.find(s => s.sessionId === execution.sessionId)
|
|
849
|
-
if (!current) throw new Error('unknown session')
|
|
850
|
-
if (current.interrupted) return { ...execution, executionEpoch: current.executionEpoch }
|
|
851
|
-
return draft.authority.cancel({ ...execution, executionEpoch: current.executionEpoch })
|
|
852
|
-
}, { kind: 'none' }, 'revocation')
|
|
853
|
-
cancelled.set(execution.sessionId, pending)
|
|
854
|
-
// Keep denial on failure; poisoned state must never expose rollback authority.
|
|
855
|
-
void pending.then(() => cancelled.delete(execution.sessionId), () => {})
|
|
856
|
-
return pending
|
|
857
|
-
},
|
|
858
|
-
/** Host shutdown waits for every queued authority and publication mutation. */
|
|
859
|
-
async drain() { await tail },
|
|
860
|
-
/** Seal authority after draining; never a permission to enable unlocked edits. */
|
|
861
|
-
async close() { closed = true; await tail },
|
|
862
|
-
status() {
|
|
863
|
-
healthy()
|
|
864
|
-
const snapshot = operations.status()
|
|
865
|
-
for (const session of snapshot.sessions) {
|
|
866
|
-
if (cancelled.has(session.sessionId)) session.interrupted = true
|
|
867
|
-
session.revoked = revokedOwner(confirmed.state, session.sessionId)
|
|
868
|
-
}
|
|
869
|
-
for (const lock of snapshot.locks) {
|
|
870
|
-
if (cancelled.has(lock.owner) && lock.status !== 'abnormal') lock.status = 'user-interrupted'
|
|
871
|
-
}
|
|
872
|
-
return snapshot
|
|
873
|
-
},
|
|
874
|
-
})
|
|
875
|
-
}
|