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
|
@@ -1,579 +0,0 @@
|
|
|
1
|
-
import { bindingGuidance, bindingRefusalReason, bindingFailure } from './binding-guidance.js'
|
|
2
|
-
import { createEditLockHost } from './host.js'
|
|
3
|
-
import { createNegotiation } from './negotiation.js'
|
|
4
|
-
import { RECOVERY_LIMITS } from './recovery.js'
|
|
5
|
-
import { isMissingTarget, STALE_SWEEP_COOLDOWN_MS } from './stale-sweep.js'
|
|
6
|
-
|
|
7
|
-
/** Host-owned lifecycle controller. The authenticated transport must await stop;
|
|
8
|
-
* stock GUI cancel acceptance alone does not constitute this acknowledgement.
|
|
9
|
-
* Resume and confirm are trusted human ingress only; never expose them as tools.
|
|
10
|
-
* @param {Awaited<ReturnType<typeof import('./runtime.js').openEditLockRuntime>>} runtime
|
|
11
|
-
* @param {(agent: object) => string | undefined} sessionForAgent
|
|
12
|
-
* @param {{deliver?: (agent: object, text: string, wake?: boolean) => void, onPending?: (agent: object, pending: number) => void, negotiationTimeoutMs?: number, onStaleRelease?: (row: {resourceId: string, owner: string, generation: number}, triggerSessionId: string | null) => void, onAdminRecovery?: (info: {revision: number, idempotent: boolean, record: any, trigger: string | null}) => void, onRevocationRepair?: (info: {owner: string, fromEpoch: number | undefined, toEpoch: number, trigger: string}) => void, warn?: (message: string) => void, sweepCooldownMs?: number, sweepNow?: () => number, servingRuntime?: (sessionId: string) => object | undefined}} [options] */
|
|
13
|
-
export function createEditLockLifecycle(runtime, sessionForAgent, options = {}) {
|
|
14
|
-
/** @typedef {{sessionId:string, state:'starting'|'active'|'recovering'|'stopped'|'resuming', attempt:number, ready:Promise<'active'|'interrupted'>, stop?:Promise<unknown>}} Entry */
|
|
15
|
-
/** @type {Map<object, Entry>} */
|
|
16
|
-
const entries = new Map()
|
|
17
|
-
const host = createEditLockHost(runtime, agent => {
|
|
18
|
-
const entry = entries.get(agent)
|
|
19
|
-
// Cleanup-only recovery keeps the execution for release/reply; business
|
|
20
|
-
// ingress is gated separately below.
|
|
21
|
-
return !!entry && (entry.state === 'active' || entry.state === 'recovering') && sessionForAgent(agent) === entry.sessionId
|
|
22
|
-
})
|
|
23
|
-
/** A stopped session must say so: the generic authentication error made a
|
|
24
|
-
* deliberate Stop look like a broken registration. @param {Entry | undefined} entry */
|
|
25
|
-
function denyStopped(entry) {
|
|
26
|
-
if (entry?.state === 'stopped' || entry?.state === 'resuming') {
|
|
27
|
-
throw new Error('Edit Lock: editing in this session was stopped by the user. Edits and new ownership stay denied until the user chooses Continue editing (Edit Lock panel or /edit-lock resume); edit_lock_release and edit_lock_status still work. Do not retry the edit.')
|
|
28
|
-
}
|
|
29
|
-
if (entry?.state === 'starting') throw new Error('Edit Lock: this session is still registering; retry shortly.')
|
|
30
|
-
}
|
|
31
|
-
/** Session-owned token at the current epoch for subtractive cleanup only.
|
|
32
|
-
* @param {Entry} entry @param {string} resourceId */
|
|
33
|
-
function cleanupToken(entry, resourceId) {
|
|
34
|
-
const { status, session } = sessionStatus(entry.sessionId)
|
|
35
|
-
const lock = status.locks.find(/** @param {any} item */ item => item.resourceId === resourceId)
|
|
36
|
-
if (!session || !lock || lock.owner !== entry.sessionId) throw new Error('lock not owned by this session')
|
|
37
|
-
return { managerIncarnation: status.managerIncarnation, sessionId: entry.sessionId, executionEpoch: session.executionEpoch, resourceId, generation: lock.generation }
|
|
38
|
-
}
|
|
39
|
-
/** Business edits and new ownership need an active (not recovering) session.
|
|
40
|
-
* @param {any} exec */
|
|
41
|
-
function business(exec) {
|
|
42
|
-
const entry = entries.get(exec?.agent)
|
|
43
|
-
denyStopped(entry)
|
|
44
|
-
if (entry?.state === 'recovering') throw new Error('cleanup-only recovery: business edits and new ownership are denied; release locks, answer requests or pause, then wait for a human /edit-lock resume')
|
|
45
|
-
}
|
|
46
|
-
/** A bare "resource owned" left the model without a next step. Name the owner
|
|
47
|
-
* and the two things that can be done; keep the original words so callers and
|
|
48
|
-
* logs that match on them still do. Never changes the decision.
|
|
49
|
-
* @param {() => Promise<any>} attempt @param {{filePath?: string, cwd?: string}} request */
|
|
50
|
-
async function explainOwned(attempt, request) {
|
|
51
|
-
try { return await attempt() } catch (error) {
|
|
52
|
-
if (!/^resource owned$/.test(String(/** @type {any} */ (error)?.message))) throw error
|
|
53
|
-
let owner = 'another session'
|
|
54
|
-
try {
|
|
55
|
-
const resourceId = runtime.requests.resource(String(request?.filePath), String(request?.cwd))
|
|
56
|
-
const lock = runtime.control.status().locks.find(/** @param {any} item */ item => item.resourceId === resourceId)
|
|
57
|
-
if (lock) owner = `session ${lock.owner}${lock.status === 'active' ? '' : ` (${lock.status})`}`
|
|
58
|
-
} catch { /* the plain explanation still applies */ }
|
|
59
|
-
throw new Error(`resource owned: this file is being edited by ${owner}. Ask for it with edit_lock_try_steal, or work on other files; do not retry the same edit.`, { cause: error })
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
let closed = false
|
|
63
|
-
/** @type {Promise<void> | undefined} */
|
|
64
|
-
let shutdown
|
|
65
|
-
/** @type {Set<Promise<unknown>>} */
|
|
66
|
-
const disposing = new Set()
|
|
67
|
-
const negotiation = createNegotiation({
|
|
68
|
-
control: runtime.control,
|
|
69
|
-
executionFor: agent => host.executionFor(agent),
|
|
70
|
-
agentFor: sessionId => [...entries].find(([agent, entry]) => entry.sessionId === sessionId && sessionForAgent(agent) === sessionId)?.[0],
|
|
71
|
-
deliver: options.deliver ?? (() => {}),
|
|
72
|
-
onPending: options.onPending,
|
|
73
|
-
timeoutMs: options.negotiationTimeoutMs,
|
|
74
|
-
})
|
|
75
|
-
/** Close admission synchronously, then revoke at the manager queue position.
|
|
76
|
-
* @param {Entry} entry */
|
|
77
|
-
function revoke(entry) {
|
|
78
|
-
entry.state = 'stopped'
|
|
79
|
-
entry.attempt++
|
|
80
|
-
try { entry.stop = Promise.resolve(runtime.control.cancelSession(entry.sessionId)) }
|
|
81
|
-
catch (error) { entry.stop = Promise.reject(error) }
|
|
82
|
-
entry.stop.catch(() => {})
|
|
83
|
-
return entry.stop
|
|
84
|
-
}
|
|
85
|
-
/** @param {object} agent */
|
|
86
|
-
function stop(agent) {
|
|
87
|
-
const entry = entries.get(agent)
|
|
88
|
-
if (!entry) return Promise.reject(new Error('unknown lifecycle agent'))
|
|
89
|
-
if (entry.state === 'stopped' && entry.stop) return entry.stop
|
|
90
|
-
return revoke(entry)
|
|
91
|
-
}
|
|
92
|
-
/** @param {object} agent */
|
|
93
|
-
function entryFor(agent) {
|
|
94
|
-
const entry = entries.get(agent)
|
|
95
|
-
if (!entry || sessionForAgent(agent) !== entry.sessionId) throw new Error('unknown lifecycle agent')
|
|
96
|
-
return entry
|
|
97
|
-
}
|
|
98
|
-
/** @param {string} sessionId */
|
|
99
|
-
function sessionStatus(sessionId) {
|
|
100
|
-
const status = runtime.control.status()
|
|
101
|
-
return { status, session: status.sessions.find(/** @param {any} s */ s => s.sessionId === sessionId) }
|
|
102
|
-
}
|
|
103
|
-
/** Stop, await durable revocation, then forget this agent object. A new
|
|
104
|
-
* agent for the same session starts interrupted. @param {object} agent */
|
|
105
|
-
async function forget(agent) {
|
|
106
|
-
const entry = entries.get(agent)
|
|
107
|
-
if (!entry) return
|
|
108
|
-
// Keep the stopped entry until durable revocation succeeds. A failed
|
|
109
|
-
// disconnect must remain visible and retryable, never admit a replacement.
|
|
110
|
-
const stopping = stop(agent)
|
|
111
|
-
disposing.add(stopping)
|
|
112
|
-
try {
|
|
113
|
-
await stopping
|
|
114
|
-
if (entries.get(agent) === entry) entries.delete(agent)
|
|
115
|
-
} catch (error) {
|
|
116
|
-
if (entry.stop === stopping) entry.stop = undefined
|
|
117
|
-
throw error
|
|
118
|
-
} finally { disposing.delete(stopping) }
|
|
119
|
-
}
|
|
120
|
-
/** @param {object} agent */
|
|
121
|
-
function statusOf(agent) {
|
|
122
|
-
const entry = entryFor(agent)
|
|
123
|
-
const { session, status } = sessionStatus(entry.sessionId)
|
|
124
|
-
// Retention settlement is read-time as well as timer-driven (design D5), so a
|
|
125
|
-
// missed timer can never leave stale ownership behind a status read.
|
|
126
|
-
return {
|
|
127
|
-
sessionId: entry.sessionId,
|
|
128
|
-
state: entry.state,
|
|
129
|
-
interrupted: session?.interrupted ?? null,
|
|
130
|
-
revoked: session?.revoked ?? null,
|
|
131
|
-
executionEpoch: session?.executionEpoch ?? null,
|
|
132
|
-
locks: status.locks.filter(/** @param {any} lock */ lock => lock.owner === entry.sessionId),
|
|
133
|
-
recovery: runtime.control.recoveryUsage(entry.sessionId),
|
|
134
|
-
retention: runtime.control.settlement(entry.sessionId, Date.now()),
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
/** Publisher-authoritative sweep coalescing (one lifecycle serves ONE
|
|
138
|
-
* authority): a trigger arriving while a sweep runs joins it; a trigger
|
|
139
|
-
* inside the cooldown window is answered without scanning. The client-side
|
|
140
|
-
* mount scheduler stays the first-line throttle; this guard is what makes
|
|
141
|
-
* concurrent local and cross-process triggers converge on one scan.
|
|
142
|
-
* @type {Promise<{released: any[], skipped: any[], failed: any[]}> | null} */
|
|
143
|
-
let sweepInFlight = null
|
|
144
|
-
let sweepCompletedAt = Number.NEGATIVE_INFINITY
|
|
145
|
-
const sweepCooldownMs = options.sweepCooldownMs ?? STALE_SWEEP_COOLDOWN_MS
|
|
146
|
-
const sweepNow = options.sweepNow ?? (() => Date.now())
|
|
147
|
-
const warn = options.warn ?? (() => {})
|
|
148
|
-
/** The scan half of sweepStale, always run under the coalescing guard.
|
|
149
|
-
* Releases audit through options.onStaleRelease after durable success;
|
|
150
|
-
* skips stay quiet (counted only); each failure earns exactly one bounded
|
|
151
|
-
* warning. @param {string | null} triggerSessionId */
|
|
152
|
-
async function runStaleSweep(triggerSessionId) {
|
|
153
|
-
const status = runtime.control.status()
|
|
154
|
-
const sessions = new Map(status.sessions.map(/** @param {any} item */ item => [item.sessionId, item]))
|
|
155
|
-
/** @type {{resourceId: string, owner: string, generation: number, executionEpoch: number, status: string}[]} */
|
|
156
|
-
const observed = []
|
|
157
|
-
for (const lock of status.locks) {
|
|
158
|
-
const session = sessions.get(lock.owner)
|
|
159
|
-
if (!session || !isMissingTarget(lock.resourceId)) continue
|
|
160
|
-
observed.push({ resourceId: lock.resourceId, owner: lock.owner, generation: lock.generation, executionEpoch: session.executionEpoch, status: lock.status })
|
|
161
|
-
}
|
|
162
|
-
if (observed.length === 0) return { released: [], skipped: [], failed: [] }
|
|
163
|
-
const result = await runtime.control.releaseStale(observed, isMissingTarget)
|
|
164
|
-
for (const row of result.released) {
|
|
165
|
-
try { options.onStaleRelease?.(row, triggerSessionId) } catch { /* audit is log-only and never breaks the sweep */ }
|
|
166
|
-
}
|
|
167
|
-
for (const failure of result.failed ?? []) {
|
|
168
|
-
try { warn(`edit lock stale sweep: release failed for ${/** @type {any} */ (failure)?.row?.resourceId}: ${/** @type {any} */ (failure)?.reason}`) } catch { /* logging never breaks the sweep */ }
|
|
169
|
-
}
|
|
170
|
-
return result
|
|
171
|
-
}
|
|
172
|
-
/** Holder liveness signal (change edit-lock-binding-transfer, task 1.1): the
|
|
173
|
-
* DSH agent `status` vocabulary is exactly 'idle' | 'running' (idle covers the
|
|
174
|
-
* idle and maintenance phases). Only 'idle' proves the holder is not mid-turn;
|
|
175
|
-
* a missing, non-string or throwing read is undeterminable and never admits.
|
|
176
|
-
* @param {object} holder @returns {string | undefined} */
|
|
177
|
-
function holderStatus(holder) {
|
|
178
|
-
try {
|
|
179
|
-
const status = /** @type {any} */ (holder)?.status
|
|
180
|
-
return typeof status === 'string' ? status : undefined
|
|
181
|
-
} catch { return undefined }
|
|
182
|
-
}
|
|
183
|
-
/** Read-only startup admission. A revoked session is startable: it binds as
|
|
184
|
-
* interrupted and its revocation ends only through the trusted resume path
|
|
185
|
-
* (the epoch advance is the repair; binding alone grants no authority).
|
|
186
|
-
* @param {object} agent */
|
|
187
|
-
function assertStartable(agent) {
|
|
188
|
-
if (closed) throw new Error('edit lifecycle closed')
|
|
189
|
-
const sessionId = sessionForAgent(agent)
|
|
190
|
-
if (!sessionId) throw new Error('unregistered lifecycle agent')
|
|
191
|
-
// Health probe: a poisoned manager refuses the retry here, before any
|
|
192
|
-
// holder is touched. A revoked session passes — it binds as interrupted
|
|
193
|
-
// and only the trusted resume path ends the revocation.
|
|
194
|
-
sessionStatus(sessionId)
|
|
195
|
-
}
|
|
196
|
-
/** Register once before publishing an agent's editing tools. A session the
|
|
197
|
-
* manager already knows (restart, re-created agent) is never re-armed: it
|
|
198
|
-
* starts interrupted and needs trusted resume. Presenting the SAME agent
|
|
199
|
-
* object again adopts the established entry instead of registering twice
|
|
200
|
-
* (change edit-lock-shared-domain-mounts, design D2). @param {object} agent
|
|
201
|
-
* @returns {Promise<'active'|'interrupted'>} */
|
|
202
|
-
function start(agent) {
|
|
203
|
-
if (closed) return Promise.reject(new Error('edit lifecycle closed'))
|
|
204
|
-
const existing = entries.get(agent)
|
|
205
|
-
if (existing) {
|
|
206
|
-
// Same-agent binding adoption: a sibling mount's idempotent re-setup
|
|
207
|
-
// presents the same agent object and takes the established start result
|
|
208
|
-
// verbatim — no openSession, no revocation, no epoch advance, no state
|
|
209
|
-
// change. A stopped/revoked entry keeps its state: adoption returns its
|
|
210
|
-
// established result, and business ingress stays gated by
|
|
211
|
-
// denyStopped/business until a trusted resume. Identity continuity is
|
|
212
|
-
// required — a host that reused the agent object for a different
|
|
213
|
-
// session keeps the duplicate-registration refusal.
|
|
214
|
-
if (sessionForAgent(agent) === existing.sessionId) return existing.ready
|
|
215
|
-
return Promise.reject(new Error('agent already registered'))
|
|
216
|
-
}
|
|
217
|
-
const sessionId = sessionForAgent(agent)
|
|
218
|
-
if (!sessionId) return Promise.reject(new Error('unregistered lifecycle agent'))
|
|
219
|
-
const prior = [...entries].find(([, entry]) => entry.sessionId === sessionId)
|
|
220
|
-
if (prior) {
|
|
221
|
-
let serving
|
|
222
|
-
try { serving = options.servingRuntime?.(sessionId) } catch { /* unknown: refuse */ }
|
|
223
|
-
// A registry-named local holder that is provably not mid-turn is the
|
|
224
|
-
// TRANSFERABLE class (serving-idle): passive paths still refuse, but the
|
|
225
|
-
// refusal announces the automatic takeover instead of a window hunt.
|
|
226
|
-
const attestation = serving === prior[0]
|
|
227
|
-
? (/** @type {any} */ (prior[0]).remote !== true && holderStatus(prior[0]) === 'idle' ? 'serving-idle' : 'serving')
|
|
228
|
-
: 'unknown'
|
|
229
|
-
const refusal = () => {
|
|
230
|
-
const pid = /** @type {any} */ (prior[0]).remote === true ? undefined : process.pid
|
|
231
|
-
const reason = bindingRefusalReason(pid, attestation)
|
|
232
|
-
const failure = bindingFailure(pid, attestation)
|
|
233
|
-
// Remote callers render after their retry budget; local callers have no wire.
|
|
234
|
-
return Object.assign(new Error(/** @type {any} */ (agent).remote === true ? reason : `${reason}: ${bindingGuidance(sessionId, failure)}`),
|
|
235
|
-
{ bindingFailure: failure })
|
|
236
|
-
}
|
|
237
|
-
// Remote entries are channel proxies, NOT runtime identities. Only the
|
|
238
|
-
// endpoint's nonce attestation may reclaim them (including same-process).
|
|
239
|
-
if (/** @type {any} */ (prior[0]).remote === true || serving !== agent || prior[0] === serving) return Promise.reject(refusal())
|
|
240
|
-
// Concurrent claimants re-check after the shared revocation settles;
|
|
241
|
-
// only the serving runtime can win, and no file lock is released.
|
|
242
|
-
return forget(prior[0]).then(() => start(agent), () => { throw refusal() })
|
|
243
|
-
}
|
|
244
|
-
/** @type {Entry} */
|
|
245
|
-
const entry = {sessionId, state:'starting', attempt:0, ready:Promise.resolve(/** @type {'active'} */ ('active'))}
|
|
246
|
-
entries.set(agent, entry)
|
|
247
|
-
entry.ready = (async () => {
|
|
248
|
-
let known
|
|
249
|
-
try { known = sessionStatus(sessionId).session }
|
|
250
|
-
catch (error) { await revoke(entry).catch(() => {}); throw error }
|
|
251
|
-
if (known) {
|
|
252
|
-
await revoke(entry)
|
|
253
|
-
return /** @type {'interrupted'} */ ('interrupted')
|
|
254
|
-
}
|
|
255
|
-
const attempt = entry.attempt
|
|
256
|
-
try {
|
|
257
|
-
const execution = await runtime.control.openSession(sessionId)
|
|
258
|
-
if (closed || entry.state !== 'starting' || entry.attempt !== attempt || sessionForAgent(agent) !== sessionId) {
|
|
259
|
-
throw new Error('agent stopped during registration')
|
|
260
|
-
}
|
|
261
|
-
entry.state = 'active'
|
|
262
|
-
host.attach(agent, execution)
|
|
263
|
-
return /** @type {'active'} */ ('active')
|
|
264
|
-
} catch (error) {
|
|
265
|
-
// Failed registration never leaves an armed manager session behind.
|
|
266
|
-
await stop(agent)
|
|
267
|
-
throw error
|
|
268
|
-
}
|
|
269
|
-
})()
|
|
270
|
-
return entry.ready
|
|
271
|
-
}
|
|
272
|
-
/** Trusted driving-ingress transfer admission (change
|
|
273
|
-
* edit-lock-binding-transfer, design D1/D2): take the binding from a
|
|
274
|
-
* same-process, local (non-remote-proxy) holder that is registry-named and
|
|
275
|
-
* provably not mid-turn, reusing the exact superseded-reclaim semantics —
|
|
276
|
-
* durable revocation and forget, no lock is released, the claimant binds as
|
|
277
|
-
* interrupted. Every other shape (remote proxy, holder not registry-named,
|
|
278
|
-
* mid-turn, undeterminable liveness, unavailable registry) delegates to the
|
|
279
|
-
* ordinary start(), keeping its fail-closed refusal and its classification.
|
|
280
|
-
* Passive entry points (creation setup, catch-up, preset switch) never call
|
|
281
|
-
* this. @param {object} agent @returns {Promise<'active'|'interrupted'>} */
|
|
282
|
-
function transferStart(agent) {
|
|
283
|
-
if (closed) return Promise.reject(new Error('edit lifecycle closed'))
|
|
284
|
-
if (entries.has(agent)) return Promise.reject(new Error('agent already registered'))
|
|
285
|
-
const sessionId = sessionForAgent(agent)
|
|
286
|
-
if (!sessionId) return Promise.reject(new Error('unregistered lifecycle agent'))
|
|
287
|
-
const prior = [...entries].find(([, entry]) => entry.sessionId === sessionId)
|
|
288
|
-
if (!prior) return start(agent)
|
|
289
|
-
let serving
|
|
290
|
-
try { serving = options.servingRuntime?.(sessionId) } catch { /* unattestable: fall through to the ordinary refusal */ }
|
|
291
|
-
if (/** @type {any} */ (prior[0]).remote !== true && serving === prior[0] && holderStatus(prior[0]) === 'idle') {
|
|
292
|
-
// Concurrent claimants re-check after the shared revocation settles;
|
|
293
|
-
// no file lock is released and the claimant starts interrupted. A failed
|
|
294
|
-
// revocation keeps the transferable refusal: the holder entry is retained
|
|
295
|
-
// (start()'s reclaim-failure discipline) and the next driving ingress
|
|
296
|
-
// re-attempts the transfer.
|
|
297
|
-
return forget(prior[0]).then(() => start(agent), () => {
|
|
298
|
-
const reason = bindingRefusalReason(process.pid, 'serving-idle')
|
|
299
|
-
const failure = bindingFailure(process.pid, 'serving-idle')
|
|
300
|
-
throw Object.assign(new Error(/** @type {any} */ (agent).remote === true ? reason : `${reason}: ${bindingGuidance(sessionId, failure)}`),
|
|
301
|
-
{ bindingFailure: failure })
|
|
302
|
-
})
|
|
303
|
-
}
|
|
304
|
-
return start(agent)
|
|
305
|
-
}
|
|
306
|
-
return Object.freeze({
|
|
307
|
-
assertStartable,
|
|
308
|
-
/** Local bindings only; endpoint arbitration reports channel proxies. */
|
|
309
|
-
bindings() {
|
|
310
|
-
return [...entries].filter(([agent]) => /** @type {any} */ (agent).remote !== true).map(([agent, entry]) => {
|
|
311
|
-
let attestation = 'unknown'
|
|
312
|
-
try {
|
|
313
|
-
if (options.servingRuntime) attestation = options.servingRuntime(entry.sessionId) === agent ? 'serving' : 'stale'
|
|
314
|
-
} catch { /* unavailable evidence */ }
|
|
315
|
-
return { sessionId: entry.sessionId, present: true, holderPid: process.pid, attestable: attestation !== 'unknown', attestation }
|
|
316
|
-
})
|
|
317
|
-
},
|
|
318
|
-
// Only this narrowed service belongs in the tool context.
|
|
319
|
-
service: Object.freeze({
|
|
320
|
-
/** @param {any} exec @param {any} request */
|
|
321
|
-
async publish(exec, request) { business(exec); return explainOwned(() => host.publish(exec, request), request) },
|
|
322
|
-
/** @param {any} exec @param {any} request */
|
|
323
|
-
async publishBatch(exec, request) { business(exec); return host.publishBatch(exec, request) },
|
|
324
|
-
/** @param {any} exec @param {any} request */
|
|
325
|
-
async acquire(exec, request) { business(exec); return explainOwned(() => host.acquire(exec, request), request) },
|
|
326
|
-
/** Release stays available while stopped or recovering: it only removes authority.
|
|
327
|
-
* @param {any} exec @param {{filePath: string, cwd: string}} request */
|
|
328
|
-
async release(exec, request) {
|
|
329
|
-
const entry = entries.get(exec?.agent)
|
|
330
|
-
if (!entry || sessionForAgent(exec.agent) !== entry.sessionId) throw new Error('agent has no authenticated edit execution')
|
|
331
|
-
const { resourceId, observation } = runtime.requests.releaseResource(request.filePath, request.cwd)
|
|
332
|
-
await runtime.requests.release(cleanupToken(entry, resourceId), observation)
|
|
333
|
-
return { resourceId, released: true }
|
|
334
|
-
},
|
|
335
|
-
/** Read-only observation; available in every lifecycle state. @param {any} exec */
|
|
336
|
-
locks(exec) {
|
|
337
|
-
const entry = entries.get(exec?.agent)
|
|
338
|
-
if (!entry || sessionForAgent(exec.agent) !== entry.sessionId) throw new Error('agent has no authenticated edit execution')
|
|
339
|
-
return runtime.control.status().locks.map(/** @param {any} lock */ lock => ({ ...lock, mine: lock.owner === entry.sessionId }))
|
|
340
|
-
},
|
|
341
|
-
/** @param {{agent: object}} exec @param {{filePath: string, cwd: string}} request */
|
|
342
|
-
async trySteal(exec, request) {
|
|
343
|
-
business(exec)
|
|
344
|
-
host.executionFor(exec.agent)
|
|
345
|
-
return negotiation.request(exec.agent, runtime.requests.resource(request.filePath, request.cwd))
|
|
346
|
-
},
|
|
347
|
-
/** @param {{agent: object}} exec @param {{requestId: string, decision: 'release'|'keep'}} request */
|
|
348
|
-
async reply(exec, request) { denyStopped(entries.get(exec?.agent)); return negotiation.reply(exec.agent, request.requestId, request.decision) },
|
|
349
|
-
/** @param {{agent: object}} exec */
|
|
350
|
-
pendingRequests(exec) { return negotiation.pending(host.executionFor(exec.agent).sessionId) },
|
|
351
|
-
}),
|
|
352
|
-
start,
|
|
353
|
-
/** Explicit driving-ingress transfer entry; ordinary start() never transfers. */
|
|
354
|
-
transferStart,
|
|
355
|
-
stop,
|
|
356
|
-
/** Normal end of an agent: an agent that is still active (never stopped,
|
|
357
|
-
* not recovering) can never edit again, so its active locks are released
|
|
358
|
-
* first — otherwise every finished subagent would block its files forever.
|
|
359
|
-
* Stopped, abnormal and unresolved state is retained for a human.
|
|
360
|
-
* @param {object} agent */
|
|
361
|
-
async dispose(agent) {
|
|
362
|
-
const entry = entries.get(agent)
|
|
363
|
-
if (entry?.state === 'active') {
|
|
364
|
-
try {
|
|
365
|
-
const tokens = sessionStatus(entry.sessionId).status.locks
|
|
366
|
-
.filter(/** @param {any} lock */ lock => lock.owner === entry.sessionId && lock.status === 'active')
|
|
367
|
-
.map(/** @param {any} lock */ lock => cleanupToken(entry, lock.resourceId))
|
|
368
|
-
if (tokens.length) await runtime.control.releaseMany(tokens)
|
|
369
|
-
} catch { /* an unresolved fence or race keeps the locks; revocation still follows */ }
|
|
370
|
-
}
|
|
371
|
-
return forget(agent)
|
|
372
|
-
},
|
|
373
|
-
/** Connection loss or crash: stop and forget WITHOUT releasing anything.
|
|
374
|
-
* @param {object} agent */
|
|
375
|
-
forget,
|
|
376
|
-
/** Trusted explicit Continue. Consumes a one-use receipt bound to requestId;
|
|
377
|
-
* retained interrupted locks become pending-confirmation, never active.
|
|
378
|
-
* @param {object} agent @param {string} requestId */
|
|
379
|
-
async resume(agent, requestId) {
|
|
380
|
-
if (closed) throw new Error('edit lifecycle closed')
|
|
381
|
-
const entry = entryFor(agent)
|
|
382
|
-
// A recovering session is first durably revoked; abnormal locks stay abnormal.
|
|
383
|
-
if (entry.state === 'recovering') revoke(entry)
|
|
384
|
-
if (entry.state !== 'stopped' || !entry.stop) throw new Error('agent is not interrupted')
|
|
385
|
-
await entry.stop
|
|
386
|
-
if (closed || entry.state !== 'stopped') throw new Error('agent state changed before resume')
|
|
387
|
-
entry.state = 'resuming'
|
|
388
|
-
const attempt = ++entry.attempt
|
|
389
|
-
try {
|
|
390
|
-
const { status, session } = sessionStatus(entry.sessionId)
|
|
391
|
-
// A revoked session resumes exactly like a stopped one: the
|
|
392
|
-
// disposition already released its locks, so resume confirms the empty
|
|
393
|
-
// set and the epoch advance past revokedEpoch ends the revocation.
|
|
394
|
-
const wasRevoked = session?.revoked === true
|
|
395
|
-
const fromEpoch = session?.executionEpoch
|
|
396
|
-
if (!session?.interrupted) throw new Error('session is not durably interrupted')
|
|
397
|
-
if (!session?.interrupted) throw new Error('session is not durably interrupted')
|
|
398
|
-
const execution = {managerIncarnation: status.managerIncarnation, sessionId: entry.sessionId, executionEpoch: session.executionEpoch}
|
|
399
|
-
const receipt = await runtime.control.issueExecutionReceipt(execution, requestId)
|
|
400
|
-
const resumed = await runtime.control.resume(execution, requestId, receipt)
|
|
401
|
-
if (closed || entry.state !== 'resuming' || entry.attempt !== attempt || sessionForAgent(agent) !== entry.sessionId) {
|
|
402
|
-
throw new Error('agent stopped during resume')
|
|
403
|
-
}
|
|
404
|
-
entry.state = 'active'
|
|
405
|
-
host.attach(agent, resumed)
|
|
406
|
-
if (wasRevoked) {
|
|
407
|
-
// Repair of an administrative revocation is audited through the same
|
|
408
|
-
// shared channel as the recovery itself — never the session log.
|
|
409
|
-
const trigger = requestId.startsWith('auto:user-message:') ? 'auto' : 'manual'
|
|
410
|
-
try { options.onRevocationRepair?.({ owner: entry.sessionId, fromEpoch, toEpoch: resumed.executionEpoch, trigger }) } catch { /* audit is log-only */ }
|
|
411
|
-
}
|
|
412
|
-
return statusOf(agent)
|
|
413
|
-
} catch (error) {
|
|
414
|
-
// Anything that may have resumed without attaching is revoked again.
|
|
415
|
-
if (entry.state === 'resuming' && entry.attempt === attempt) await revoke(entry).catch(() => {})
|
|
416
|
-
throw error
|
|
417
|
-
}
|
|
418
|
-
},
|
|
419
|
-
/** Trusted per-file confirmation of a pending-confirmation lock.
|
|
420
|
-
* @param {object} agent @param {string} resourceId */
|
|
421
|
-
async confirm(agent, resourceId) {
|
|
422
|
-
if (closed) throw new Error('edit lifecycle closed')
|
|
423
|
-
entryFor(agent)
|
|
424
|
-
await runtime.control.confirm(host.executionFor(agent), resourceId)
|
|
425
|
-
},
|
|
426
|
-
/** Trusted classifier (durable turn/end error): retained locks become
|
|
427
|
-
* abnormal and the session enters cleanup-only recovery. No locks → no-op.
|
|
428
|
-
* @param {object} agent @param {string} reason @returns {Promise<string[]>} */
|
|
429
|
-
async classifyAbnormal(agent, reason) {
|
|
430
|
-
const entry = entryFor(agent)
|
|
431
|
-
if (entry.state !== 'active') return []
|
|
432
|
-
const marked = await runtime.control.markAbnormal(entry.sessionId, reason)
|
|
433
|
-
const abnormal = statusOf(agent).locks.some(/** @param {any} lock */ lock => lock.status === 'abnormal')
|
|
434
|
-
if (abnormal && entry.state === 'active') entry.state = 'recovering'
|
|
435
|
-
return marked
|
|
436
|
-
},
|
|
437
|
-
/** Explicit bounded retention of every lock the session currently holds. The
|
|
438
|
-
* caller resolves the configured caps; ownership is untouched, so this only
|
|
439
|
-
* extends it. Never available while stopped or recovering: those locks are
|
|
440
|
-
* handled as abnormal locks, and a retention period must not hide them.
|
|
441
|
-
* @param {object} agent @param {number} ms
|
|
442
|
-
* @param {{singleMaxMs: number, cumulativeMaxMs: number}} caps */
|
|
443
|
-
async hold(agent, ms, caps) {
|
|
444
|
-
const entry = entryFor(agent)
|
|
445
|
-
denyStopped(entry)
|
|
446
|
-
if (entry.state === 'recovering') throw new Error('cleanup-only recovery: no retention request is available while abnormal locks are pending')
|
|
447
|
-
const held = await runtime.control.hold(entry.sessionId, ms, { ...caps, now: Date.now() })
|
|
448
|
-
return { ...held, lockCount: statusOf(agent).locks.length }
|
|
449
|
-
},
|
|
450
|
-
/** The holder started a new turn: every lock it holds leaves the holding state
|
|
451
|
-
* at once (spec: Holding state semantics). Safe while stopped; a stopped
|
|
452
|
-
* session has no retention period anyway. @param {object} agent */
|
|
453
|
-
async turnStarted(agent) {
|
|
454
|
-
const entry = entries.get(agent)
|
|
455
|
-
if (!entry) return null
|
|
456
|
-
return runtime.control.endHold(entry.sessionId, Date.now())
|
|
457
|
-
},
|
|
458
|
-
/** Read-time settlement plus its release half (design D5). Releases the
|
|
459
|
-
* session's active locks when the period has genuinely run out, so it is
|
|
460
|
-
* idempotent and safe from a status read or a timer. A host that is mid-turn is
|
|
461
|
-
* never released implicitly: the release is deferred to the turn it ends on,
|
|
462
|
-
* because releasing under a running turn would drop the holder's own locks.
|
|
463
|
-
* `force` is the fallback disposition after the notices run out, which is a
|
|
464
|
-
* deliberate policy decision rather than an expiry.
|
|
465
|
-
* @param {object} agent @param {boolean} [force]
|
|
466
|
-
* @returns {Promise<{released: string[]}>} */
|
|
467
|
-
async settleExpired(agent, force = false, idle = undefined) {
|
|
468
|
-
const entry = entries.get(agent)
|
|
469
|
-
if (!entry) return { released: [] }
|
|
470
|
-
// A channel agent on the publisher side has no status of its own; the host
|
|
471
|
-
// that runs the agent says whether it is idle.
|
|
472
|
-
const isIdle = idle ?? /** @type {any} */ (agent)?.status === 'idle'
|
|
473
|
-
if (!force && !isIdle) return { released: [], deferred: true }
|
|
474
|
-
if (!force && !runtime.control.settlement(entry.sessionId, Date.now()).expired) return { released: [] }
|
|
475
|
-
// Expiry is re-checked inside the transaction, so a hold extended or ended
|
|
476
|
-
// after the check above is never released by this call.
|
|
477
|
-
const released = await runtime.control.releaseActive(entry.sessionId, Date.now(), !force)
|
|
478
|
-
return { released }
|
|
479
|
-
},
|
|
480
|
-
/** Retention state of one session, settled. @param {object} agent */
|
|
481
|
-
retention(agent) { return runtime.control.settlement(entryFor(agent).sessionId, Date.now()) },
|
|
482
|
-
/** @param {object} agent */
|
|
483
|
-
recoveryUsage(agent) { return runtime.control.recoveryUsage(entryFor(agent).sessionId) },
|
|
484
|
-
/** @param {object} agent @param {{attempts?: number, elapsedMs?: number, pauseMs?: number}} delta */
|
|
485
|
-
chargeRecovery(agent, delta) { return runtime.control.chargeRecovery(entryFor(agent).sessionId, delta) },
|
|
486
|
-
/** Bounded pause; charged durably up front, never extends or releases.
|
|
487
|
-
* @param {object} agent @param {number} minutes */
|
|
488
|
-
async pause(agent, minutes) {
|
|
489
|
-
const entry = entryFor(agent)
|
|
490
|
-
if (entry.state !== 'recovering') throw new Error('pause is only available during cleanup-only recovery')
|
|
491
|
-
if (!Number.isFinite(minutes) || minutes <= 0) throw new Error('minutes must be positive')
|
|
492
|
-
const ms = Math.round(minutes * 60_000)
|
|
493
|
-
if (ms > RECOVERY_LIMITS.singlePauseMs) throw new Error(`a single pause is limited to ${RECOVERY_LIMITS.singlePauseMs / 60_000} minutes`)
|
|
494
|
-
const usage = runtime.control.recoveryUsage(entry.sessionId)
|
|
495
|
-
if (usage.pauseMs + ms > RECOVERY_LIMITS.cumulativePauseMs) throw new Error(`cumulative pause budget exhausted (${Math.floor((RECOVERY_LIMITS.cumulativePauseMs - usage.pauseMs) / 60_000)} minutes left)`)
|
|
496
|
-
await runtime.control.chargeRecovery(entry.sessionId, { pauseMs: ms })
|
|
497
|
-
return { pausedMs: ms, cumulativePauseMs: usage.pauseMs + ms }
|
|
498
|
-
},
|
|
499
|
-
/** Trusted maintenance ingress for the stale-lock sweep (message-triggered;
|
|
500
|
-
* local composition and the peer channel both land here). The publisher
|
|
501
|
-
* shares the filesystem with the authority, so the scan ALWAYS runs on this
|
|
502
|
-
* side — a client process only triggers it. Coalescing is authoritative
|
|
503
|
-
* HERE, not only in the client-side mount scheduler: one lifecycle serves
|
|
504
|
-
* one authority, so a single in-flight sweep plus a cooldown window makes
|
|
505
|
-
* concurrent local and cross-process triggers converge on one scan — a
|
|
506
|
-
* trigger arriving while a sweep runs JOINS it (same result, no extra
|
|
507
|
-
* audit), and a trigger inside the cooldown window is answered with a
|
|
508
|
-
* `coalesced: 'cooldown'` result without scanning. A lock row is a candidate ONLY
|
|
509
|
-
* when lstat of its stored canonical resource id fails with ENOENT; the
|
|
510
|
-
* manager's releaseStale re-verifies the full observed row (owner,
|
|
511
|
-
* generation, owner execution epoch, lock status) and re-probes the target
|
|
512
|
-
* at the FIFO execution point, so an owner that resumed, confirmed,
|
|
513
|
-
* re-generated or sits under an unresolved fence between scan and
|
|
514
|
-
* execution is a skip, never an error. Silent by contract: each durable
|
|
515
|
-
* release is reported through options.onStaleRelease (shared audit);
|
|
516
|
-
* skips stay quiet (counted only); each FAILURE (persistence, poison,
|
|
517
|
-
* unexpected) gets exactly one bounded warning through options.warn.
|
|
518
|
-
* Never writes to any conversation.
|
|
519
|
-
* @param {string | null} [triggerSessionId] session whose genuine user
|
|
520
|
-
* message armed the sweep (audit metadata only)
|
|
521
|
-
* @returns {Promise<{released: any[], skipped: any[], failed: any[], coalesced?: string}>} */
|
|
522
|
-
sweepStale(triggerSessionId = null) {
|
|
523
|
-
if (closed) return Promise.reject(new Error('edit lifecycle closed'))
|
|
524
|
-
if (sweepInFlight) return sweepInFlight
|
|
525
|
-
if (sweepNow() - sweepCompletedAt < sweepCooldownMs) {
|
|
526
|
-
return Promise.resolve({ released: [], skipped: [], failed: [], coalesced: 'cooldown' })
|
|
527
|
-
}
|
|
528
|
-
const running = runStaleSweep(triggerSessionId)
|
|
529
|
-
sweepInFlight = running
|
|
530
|
-
// The cooldown window opens at completion (success or failure), matching
|
|
531
|
-
// the client-side scheduler's semantics.
|
|
532
|
-
const settle = () => { if (sweepInFlight === running) { sweepInFlight = null; sweepCompletedAt = sweepNow() } }
|
|
533
|
-
running.then(settle, settle)
|
|
534
|
-
return running
|
|
535
|
-
},
|
|
536
|
-
/** Trusted human unlock by exact generation; never exposed as a tool.
|
|
537
|
-
* Session interruption and pending confirmations are untouched.
|
|
538
|
-
* @param {string} resourceId @param {number} generation */
|
|
539
|
-
unlock(resourceId, generation) {
|
|
540
|
-
if (closed) return Promise.reject(new Error('edit lifecycle closed'))
|
|
541
|
-
return runtime.control.adminUnlock(resourceId, generation)
|
|
542
|
-
},
|
|
543
|
-
/** Trusted online administrative recovery (design D4), executed
|
|
544
|
-
* publisher-side as one serialized manager transaction — the maintenance
|
|
545
|
-
* panel's one-click confirmation reaches here directly (local domain) or
|
|
546
|
-
* through the peer channel (`adminRecover` kind). The `trigger` identity
|
|
547
|
-
* comes from the CALLER (the channel's bound agent or the local settings
|
|
548
|
-
* plane), never from the request payload; it is audit metadata only.
|
|
549
|
-
* Never exposed as a tool.
|
|
550
|
-
* @param {any} request @param {string | null} [trigger] */
|
|
551
|
-
async adminRecoverOnline(request, trigger = null) {
|
|
552
|
-
if (closed) throw new Error('edit lifecycle closed')
|
|
553
|
-
const result = await runtime.control.adminRecoverOnline(request)
|
|
554
|
-
// Shared audit after durable success; it never breaks the recovery.
|
|
555
|
-
try { options.onAdminRecovery?.({ ...result, trigger: trigger ?? null }) } catch { /* log-only */ }
|
|
556
|
-
return result
|
|
557
|
-
},
|
|
558
|
-
/** Domain-wide observation for trusted UI/commands; grants nothing. */
|
|
559
|
-
locks() { return runtime.control.status().locks },
|
|
560
|
-
/** Effective observation for trusted UI/commands; not a write permission. */
|
|
561
|
-
status: statusOf,
|
|
562
|
-
close() {
|
|
563
|
-
if (shutdown) return shutdown
|
|
564
|
-
closed = true
|
|
565
|
-
negotiation.close()
|
|
566
|
-
const stops = [...entries.keys()].map(stop).concat([...disposing])
|
|
567
|
-
shutdown = (async () => {
|
|
568
|
-
const results = await Promise.allSettled(stops)
|
|
569
|
-
await Promise.allSettled([...entries.values()].map(entry => entry.ready))
|
|
570
|
-
await host.close()
|
|
571
|
-
// host.close drains and closes its runtime. The external owner must
|
|
572
|
-
// seal IPC admission before calling this and retain its lease until return.
|
|
573
|
-
const errors = results.filter(result => result.status === 'rejected').map(result => result.reason)
|
|
574
|
-
if (errors.length) throw new AggregateError(errors, 'edit lifecycle revocation failed')
|
|
575
|
-
})()
|
|
576
|
-
return shutdown
|
|
577
|
-
},
|
|
578
|
-
})
|
|
579
|
-
}
|
package/src/edit-lock/limits.js
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
// Edit Lock retention policy: the defaults and the overlay resolver the
|
|
2
|
-
// lifecycle (edit-lock/index.js) consumes. Moved verbatim out of
|
|
3
|
-
// src/settings/sections.js (subsystem-seam-hygiene, D4) — the owner subsystem
|
|
4
|
-
// now holds the policy; settings' field-default parity consumes this module
|
|
5
|
-
// directly (the temporary sections.js re-export was removed by dead-export-cleanup).
|
|
6
|
-
|
|
7
|
-
/** Resolved Edit Lock retention policy. The flat keys are declared in the
|
|
8
|
-
* settings sections table; this is the ONE place that turns them into the
|
|
9
|
-
* values the lifecycle consumes, so defaults and validation cannot drift
|
|
10
|
-
* apart. */
|
|
11
|
-
export const EDIT_LOCK_DEFAULTS = Object.freeze({
|
|
12
|
-
holdDefaultMinutes: 30,
|
|
13
|
-
holdSingleMaxMinutes: 30,
|
|
14
|
-
holdCumulativeMaxMinutes: 120,
|
|
15
|
-
nudgeAttempts: 2,
|
|
16
|
-
nudgeFallback: 'release',
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
/** Overlay one Edit Lock section onto the defaults. A key the user never set
|
|
20
|
-
* falls back to its default; a key that is present but unusable (alone or in
|
|
21
|
-
* combination) throws with the flat settings key named, because silently clamping
|
|
22
|
-
* a retention cap would change how long other sessions stay blocked from those
|
|
23
|
-
* files. The Edit Lock composition refuses retention requests with that error and
|
|
24
|
-
* runs every other path (settling, status, release, stop, unlock) on defaults.
|
|
25
|
-
* @param {any} section */
|
|
26
|
-
export function editLockLimits(section) {
|
|
27
|
-
const value = section && typeof section === 'object' ? section : {}
|
|
28
|
-
/** @type {{holdDefaultMinutes: number, holdSingleMaxMinutes: number, holdCumulativeMaxMinutes: number, nudgeAttempts: number, nudgeFallback: 'release'|'abnormal'}} */
|
|
29
|
-
const limits = { ...EDIT_LOCK_DEFAULTS }
|
|
30
|
-
/** @param {'holdDefaultMinutes'|'holdSingleMaxMinutes'|'holdCumulativeMaxMinutes'} key @param {string} name */
|
|
31
|
-
const take = (key, name) => {
|
|
32
|
-
const input = value[key]
|
|
33
|
-
if (input === undefined) return
|
|
34
|
-
if (typeof input !== 'number' || !Number.isFinite(input) || input <= 0) {
|
|
35
|
-
throw new Error(`weir-settings: ${name} must be a positive number`)
|
|
36
|
-
}
|
|
37
|
-
limits[key] = input
|
|
38
|
-
}
|
|
39
|
-
take('holdDefaultMinutes', 'editLockHoldDefaultMinutes')
|
|
40
|
-
take('holdSingleMaxMinutes', 'editLockHoldSingleMaxMinutes')
|
|
41
|
-
take('holdCumulativeMaxMinutes', 'editLockHoldCumulativeMaxMinutes')
|
|
42
|
-
if (limits.holdDefaultMinutes > limits.holdSingleMaxMinutes) {
|
|
43
|
-
throw new Error('weir-settings: editLockHoldDefaultMinutes must not exceed editLockHoldSingleMaxMinutes')
|
|
44
|
-
}
|
|
45
|
-
if (limits.holdCumulativeMaxMinutes < limits.holdSingleMaxMinutes) {
|
|
46
|
-
throw new Error('weir-settings: editLockHoldCumulativeMaxMinutes must be at least editLockHoldSingleMaxMinutes')
|
|
47
|
-
}
|
|
48
|
-
if (value.nudgeAttempts !== undefined) {
|
|
49
|
-
if (!Number.isSafeInteger(value.nudgeAttempts) || value.nudgeAttempts < 0) {
|
|
50
|
-
throw new Error('weir-settings: editLockNudgeAttempts must be a non-negative integer')
|
|
51
|
-
}
|
|
52
|
-
limits.nudgeAttempts = value.nudgeAttempts
|
|
53
|
-
}
|
|
54
|
-
if (value.nudgeFallback !== undefined) {
|
|
55
|
-
if (value.nudgeFallback !== 'release' && value.nudgeFallback !== 'abnormal') {
|
|
56
|
-
throw new Error('weir-settings: editLockNudgeFallback must be "release" or "abnormal"')
|
|
57
|
-
}
|
|
58
|
-
limits.nudgeFallback = value.nudgeFallback
|
|
59
|
-
}
|
|
60
|
-
return Object.freeze(limits)
|
|
61
|
-
}
|