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/settle.js
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
/** Turn-end settling of still-held locks (design D2/D3).
|
|
2
|
-
*
|
|
3
|
-
* A finished turn that leaves locks unreleased is not an error, it is unfinished
|
|
4
|
-
* business: the model decides whether each file is really done with. So the
|
|
5
|
-
* session is continued once with a notice that asks for exactly one of two
|
|
6
|
-
* things — release the lock, or ask explicitly to keep it for a bounded period.
|
|
7
|
-
*
|
|
8
|
-
* Only the durable `completed` reason enters this path. A provider error keeps its
|
|
9
|
-
* cleanup-only recovery and a user stop keeps its latch: neither is nudged, and
|
|
10
|
-
* classification still reads nothing but the turn/end reason (AGENTS §3.5).
|
|
11
|
-
*
|
|
12
|
-
* The notice count is bounded and configurable, and the disposition after it runs
|
|
13
|
-
* out is configurable too (`release` or `abnormal`). Both are policy supplied by
|
|
14
|
-
* the caller, so this module holds no configuration and no clock.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/** @param {{reason?: {kind?: string}}[]} locks @param {number} attempt @param {number} maxAttempts @param {{defaultMinutes: number, singleMaxMinutes: number, cumulativeMaxMinutes: number, remainingMinutes: number, unavailable?: boolean}} budget */
|
|
18
|
-
export function settlementPrompt(locks, attempt, maxAttempts, budget) {
|
|
19
|
-
const lines = locks.map(lock => `- ${lock.resourceId}`)
|
|
20
|
-
// With the batch budget gone there is only one honest instruction left, so the
|
|
21
|
-
// hold option is not mentioned at all rather than offered and then refused.
|
|
22
|
-
const option = budget.unavailable
|
|
23
|
-
? 'Retention is unavailable because its settings conflict, so no reservation can be made: release each file (edit_lock_release).'
|
|
24
|
-
: budget.remainingMinutes <= 0
|
|
25
|
-
? `This batch has used its whole retention budget (${budget.cumulativeMaxMinutes} minutes), so no new reservation is available: release each file (edit_lock_release).`
|
|
26
|
-
: `For each file, either release it because you are done with it (edit_lock_release), or ask to keep ownership for a bounded time (edit_lock_hold, at most ${budget.singleMaxMinutes} minutes per request and ${budget.remainingMinutes} minutes left in this batch).`
|
|
27
|
-
return [
|
|
28
|
-
`Edit Lock: the turn ended while this session still held ${locks.length} file(s):`,
|
|
29
|
-
...lines,
|
|
30
|
-
option,
|
|
31
|
-
`Do not edit other files to work around this. A file you leave unresolved after ${maxAttempts} notice(s) is handled for you.`,
|
|
32
|
-
`This is notice ${attempt} of ${maxAttempts}.`,
|
|
33
|
-
].join('\n')
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** @param {{
|
|
37
|
-
* domainFor: (agent: object) => Promise<any> | undefined,
|
|
38
|
-
* followup: (agent: object, text: string) => void,
|
|
39
|
-
* notify: (agent: object, text: string) => void,
|
|
40
|
-
* limits: () => {holdDefaultMinutes: number, holdSingleMaxMinutes: number, holdCumulativeMaxMinutes: number, nudgeAttempts: number, nudgeFallback: 'release'|'abnormal'},
|
|
41
|
-
* now?: () => number,
|
|
42
|
-
* onSettled?: (agent: object) => void,
|
|
43
|
-
* warn?: (message: string) => void,
|
|
44
|
-
* }} options */
|
|
45
|
-
export function createSettlementDriver(options) {
|
|
46
|
-
const now = options.now ?? Date.now
|
|
47
|
-
let closed = false
|
|
48
|
-
/** Notices already sent for the current batch of one agent. In-memory on
|
|
49
|
-
* purpose: a restart restores the session as interrupted and takes the
|
|
50
|
-
* cleanup route instead, so a lost counter cannot loop. @type {WeakMap<object, number>} */
|
|
51
|
-
const notices = new WeakMap()
|
|
52
|
-
/** @type {Set<Promise<unknown>>} */
|
|
53
|
-
const running = new Set()
|
|
54
|
-
/** Agents whose next turn is the settle follow-up itself. @type {WeakSet<object>} */
|
|
55
|
-
const nudged = new WeakSet()
|
|
56
|
-
|
|
57
|
-
/** The notice counter is keyed by the live agent object, so a non-object key can
|
|
58
|
-
* never be remembered. Making that explicit turns a caller mistake into a clear
|
|
59
|
-
* failure instead of a silent "nothing happened". @param {unknown} agent */
|
|
60
|
-
function remember(agent) {
|
|
61
|
-
if (!agent || (typeof agent !== 'object' && typeof agent !== 'function')) {
|
|
62
|
-
throw new Error('settlement requires a live agent object')
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
/** @param {object} agent */
|
|
66
|
-
function forget(agent) {
|
|
67
|
-
if (!agent || typeof agent !== 'object') return
|
|
68
|
-
notices.delete(agent)
|
|
69
|
-
nudged.delete(agent)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** @param {object} agent @param {any} domain @param {any[]} locks */
|
|
73
|
-
async function dispose(agent, domain, locks) {
|
|
74
|
-
const limits = options.limits()
|
|
75
|
-
if (limits.nudgeFallback === 'abnormal') {
|
|
76
|
-
// The team asked for human handling: keep the locks, never silently free them.
|
|
77
|
-
const marked = await domain.classifyAbnormal(agent, 'unsettled-after-notices')
|
|
78
|
-
options.notify(agent, `Edit Lock: ${marked.length} file(s) stayed locked and are now flagged for the user to sort out in the Edit Lock panel.`)
|
|
79
|
-
} else {
|
|
80
|
-
const { released } = await domain.releaseHeld(agent)
|
|
81
|
-
options.notify(agent, released.length
|
|
82
|
-
? `Edit Lock: ${released.length} file(s) stayed locked after ${limits.nudgeAttempts} notice(s), so they were released for other sessions.`
|
|
83
|
-
: 'Edit Lock: no file is held any more.')
|
|
84
|
-
}
|
|
85
|
-
forget(agent)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** @param {object} agent @param {any} domain */
|
|
89
|
-
async function settle(agent, domain) {
|
|
90
|
-
remember(agent)
|
|
91
|
-
const status = await domain.status(agent)
|
|
92
|
-
// Only ordinary ownership counts. Abnormal and interrupted locks have their
|
|
93
|
-
// own paths, and a pending confirmation already requires a human.
|
|
94
|
-
const held = status.locks.filter(lock => lock.status === 'active')
|
|
95
|
-
if (held.length === 0) { forget(agent); return }
|
|
96
|
-
const limits = options.limits()
|
|
97
|
-
const retention = status.retention ?? { held: false, remainingMs: 0, holdCumulativeMs: 0 }
|
|
98
|
-
// Already reserved and still inside its period: nothing to ask, and the
|
|
99
|
-
// expiry timer owns what happens next.
|
|
100
|
-
if (retention.held && retention.remainingMs > 0) return
|
|
101
|
-
const attempts = notices.get(agent) ?? 0
|
|
102
|
-
if (attempts >= limits.nudgeAttempts) { await dispose(agent, domain, held); return }
|
|
103
|
-
notices.set(agent, attempts + 1)
|
|
104
|
-
// The follow-up starts a turn of its own; that turn must not count as the
|
|
105
|
-
// holder getting back to work, or the count would reset forever.
|
|
106
|
-
nudged.add(agent)
|
|
107
|
-
const remainingMinutes = Math.max(0, Math.floor((limits.holdCumulativeMaxMinutes * 60_000 - retention.holdCumulativeMs) / 60_000))
|
|
108
|
-
options.followup(agent, settlementPrompt(held, attempts + 1, limits.nudgeAttempts, {
|
|
109
|
-
defaultMinutes: limits.holdDefaultMinutes,
|
|
110
|
-
singleMaxMinutes: limits.holdSingleMaxMinutes,
|
|
111
|
-
cumulativeMaxMinutes: limits.holdCumulativeMaxMinutes,
|
|
112
|
-
remainingMinutes,
|
|
113
|
-
unavailable: limits.holdUnavailable === true,
|
|
114
|
-
}))
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return Object.freeze({
|
|
118
|
-
/** Durable turn/end feed for one agent. @param {object} agent @param {any} reason */
|
|
119
|
-
onTurnEnd(agent, reason) {
|
|
120
|
-
if (closed || reason?.kind !== 'completed') return
|
|
121
|
-
const domain = options.domainFor(agent)
|
|
122
|
-
if (!domain) return
|
|
123
|
-
const job = Promise.resolve(domain)
|
|
124
|
-
.then(resolved => (resolved ? settle(agent, resolved) : undefined))
|
|
125
|
-
.catch(error => options.warn?.(`edit lock settlement failed: ${error?.message ?? error}`))
|
|
126
|
-
.finally(() => running.delete(job))
|
|
127
|
-
running.add(job)
|
|
128
|
-
},
|
|
129
|
-
/** The holder started a new turn, so the batch is in use again: the notice
|
|
130
|
-
* budget restarts. Without this a long session would run out of notices over
|
|
131
|
-
* unrelated turns. @param {object} agent */
|
|
132
|
-
turnStarted(agent) {
|
|
133
|
-
if (agent && typeof agent === 'object' && nudged.delete(agent)) return
|
|
134
|
-
forget(agent)
|
|
135
|
-
},
|
|
136
|
-
/** The expiry timer released the batch: nothing left to settle. @param {object} agent */
|
|
137
|
-
settled(agent) { forget(agent) },
|
|
138
|
-
/** @param {object} agent */
|
|
139
|
-
noticeCount(agent) { return notices.get(agent) ?? 0 },
|
|
140
|
-
/** Host shutdown waits for in-flight settlement instead of dropping it. */
|
|
141
|
-
async drain() { await Promise.allSettled([...running]) },
|
|
142
|
-
close() { closed = true },
|
|
143
|
-
})
|
|
144
|
-
}
|
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
// Pure durable-image validation shared by the authority store and the
|
|
2
|
-
// read-only maintenance inspector. Everything here is a function of its
|
|
3
|
-
// arguments: no filesystem access, no clock, no ctx. `parseSnapshot` performs
|
|
4
|
-
// EXACTLY the checks the store's recover path always performed (same order,
|
|
5
|
-
// same messages) so an inspection verdict can never diverge from what a real
|
|
6
|
-
// recover would accept — corruption is reported, never repaired.
|
|
7
|
-
import { createHash } from 'node:crypto'
|
|
8
|
-
import { validateOperations } from './operation-history.js'
|
|
9
|
-
import { validateAdminLedger } from './admin-ledger.js'
|
|
10
|
-
import { validateIncarnations } from './incarnations.js'
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* @typedef {import('./store.js').AuthorityImage} AuthorityImage
|
|
14
|
-
* @typedef {{ revision: number, state: AuthorityImage }} Snapshot
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/** @param {unknown} condition @param {string} message @returns {asserts condition} */
|
|
18
|
-
function valid(condition, message) {
|
|
19
|
-
if (!condition) throw new Error(`invalid ${message}`)
|
|
20
|
-
}
|
|
21
|
-
/** @param {unknown} value @returns {value is string} */
|
|
22
|
-
function id(value) { return typeof value === 'string' && value.length > 0 }
|
|
23
|
-
/** @param {unknown} value @param {number} [minimum] @returns {value is number} */
|
|
24
|
-
function integer(value, minimum = 0) { return typeof value === 'number' && Number.isSafeInteger(value) && !Object.is(value, -0) && value >= minimum }
|
|
25
|
-
/** @param {unknown} value @param {string[]} keys @returns {asserts value is Record<string, unknown>} */
|
|
26
|
-
function shape(value, keys) {
|
|
27
|
-
valid(value !== null && typeof value === 'object' && !Array.isArray(value), 'object')
|
|
28
|
-
valid(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null, 'object prototype')
|
|
29
|
-
valid(Reflect.ownKeys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)), 'schema keys')
|
|
30
|
-
for (const key of keys) {
|
|
31
|
-
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
|
32
|
-
valid(descriptor && 'value' in descriptor && descriptor.enumerable, 'data property')
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Deterministic JSON: UTF-16 key order, JSON scalar spelling, no whitespace.
|
|
37
|
-
* Array order is retained (including the historical image's ordered collections).
|
|
38
|
-
* @param {unknown} value @returns {string} */
|
|
39
|
-
export function canonical(value) {
|
|
40
|
-
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
|
|
41
|
-
if (value !== null && typeof value === 'object') {
|
|
42
|
-
const object = /** @type {Record<string, unknown>} */ (value)
|
|
43
|
-
return `{${Object.keys(object).sort().map(key => `${JSON.stringify(key)}:${canonical(object[key])}`).join(',')}}`
|
|
44
|
-
}
|
|
45
|
-
return JSON.stringify(value)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Lossless v2 -> v3 upgrade: add one empty retention row per known session.
|
|
49
|
-
* @param {any} state @returns {any} */
|
|
50
|
-
export function upgradeFromV2(state) {
|
|
51
|
-
valid(state && typeof state === 'object' && !Array.isArray(state) && !Object.hasOwn(state, 'holds') && state.version === 2, 'v2 image')
|
|
52
|
-
valid(Array.isArray(state.sessions), 'v2 sessions')
|
|
53
|
-
const holds = state.sessions.map((/** @type {any} */ session) => ({ sessionId: session?.sessionId, holding: false, holdUntil: null, holdCumulativeMs: 0 }))
|
|
54
|
-
const { version: _v, operations, ...rest } = state
|
|
55
|
-
return { ...rest, version: 3, holds, operations }
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Lossless v3 -> v4: historical assertions remain inert, never upgraded to proof.
|
|
59
|
-
* @param {any} state @returns {AuthorityImage} */
|
|
60
|
-
export function upgradeFromV3(state) {
|
|
61
|
-
valid(state?.version === 3 && Array.isArray(state.operations), 'v3 image')
|
|
62
|
-
for (const operation of state.operations) {
|
|
63
|
-
valid(Array.isArray(operation.closeouts), 'v3 closeouts')
|
|
64
|
-
for (const closeout of operation.closeouts) shape(closeout, ['kind', 'assertionId'])
|
|
65
|
-
}
|
|
66
|
-
return { ...state, version: 4 }
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** @param {AuthorityImage} state */
|
|
70
|
-
export function validateImage(state) {
|
|
71
|
-
shape(state, ['version', 'managerIncarnation', 'sessions', 'generations', 'locks', 'issuedRequests', 'recovery', 'holds', 'operations', ...(state.version === 5 ? ['adminRecoveries'] : []), ...(state.version === 6 ? ['adminRecoveries', 'incarnations'] : [])])
|
|
72
|
-
valid(state.version === 4 || state.version === 5 || state.version === 6, `image version ${state.version}; expected 4, 5 or 6`)
|
|
73
|
-
valid(state.managerIncarnation === null || id(state.managerIncarnation), 'incarnation')
|
|
74
|
-
for (const collection of [state.sessions, state.generations, state.locks, state.issuedRequests, state.recovery, state.holds]) {
|
|
75
|
-
valid(Array.isArray(collection) && Object.getPrototypeOf(collection) === Array.prototype, 'collection')
|
|
76
|
-
valid(Reflect.ownKeys(collection).length === collection.length + 1, 'dense collection keys')
|
|
77
|
-
for (let i = 0; i < collection.length; i++) {
|
|
78
|
-
const descriptor = Object.getOwnPropertyDescriptor(collection, i)
|
|
79
|
-
valid(descriptor && 'value' in descriptor && descriptor.enumerable, 'collection data property')
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
valid(state.managerIncarnation !== null || [state.sessions, state.generations, state.locks, state.issuedRequests, state.recovery, state.holds, state.operations].every(list => list.length === 0), 'null incarnation with history')
|
|
83
|
-
const sessions = new Map(state.sessions.map(session => {
|
|
84
|
-
shape(session, ['sessionId', 'executionEpoch', 'interrupted'])
|
|
85
|
-
valid(id(session.sessionId) && integer(session.executionEpoch, 1) && typeof session.interrupted === 'boolean', 'session')
|
|
86
|
-
return [session.sessionId, session]
|
|
87
|
-
}))
|
|
88
|
-
valid(sessions.size === state.sessions.length, 'duplicate session')
|
|
89
|
-
// Retention is a session-level budget (design D1): one row per session, only for
|
|
90
|
-
// sessions the image knows, and the cumulative allowance is a lifetime charge
|
|
91
|
-
// that is never refunded.
|
|
92
|
-
const holds = new Map()
|
|
93
|
-
for (const held of state.holds) {
|
|
94
|
-
shape(held, ['sessionId', 'holding', 'holdUntil', 'holdCumulativeMs'])
|
|
95
|
-
valid(sessions.has(held.sessionId) && !holds.has(held.sessionId), 'hold reference')
|
|
96
|
-
valid(typeof held.holding === 'boolean' && integer(held.holdCumulativeMs), 'hold flags')
|
|
97
|
-
valid(held.holdUntil === null || integer(held.holdUntil, 1), 'hold expiry')
|
|
98
|
-
valid(held.holding ? held.holdUntil !== null : held.holdUntil === null, 'hold expiry coherence')
|
|
99
|
-
holds.set(held.sessionId, held)
|
|
100
|
-
}
|
|
101
|
-
for (const session of state.sessions) valid(holds.has(session.sessionId), 'missing hold row')
|
|
102
|
-
const generations = new Map(state.generations.map(entry => {
|
|
103
|
-
shape(entry, ['resourceId', 'generation'])
|
|
104
|
-
valid(id(entry.resourceId) && integer(entry.generation, 1), 'generation')
|
|
105
|
-
return [entry.resourceId, entry.generation]
|
|
106
|
-
}))
|
|
107
|
-
valid(generations.size === state.generations.length, 'duplicate generation')
|
|
108
|
-
// A session whose last lock is gone has no batch left: the row stays (one row per
|
|
109
|
-
// session) but must be back at zero, so a stale allowance cannot survive a batch.
|
|
110
|
-
for (const held of state.holds) {
|
|
111
|
-
if (state.locks.some(lock => lock.owner === held.sessionId)) continue
|
|
112
|
-
valid(!held.holding && held.holdUntil === null && held.holdCumulativeMs === 0, 'hold without a lock')
|
|
113
|
-
}
|
|
114
|
-
const resources = new Set()
|
|
115
|
-
for (const lock of state.locks) {
|
|
116
|
-
shape(lock, lock.status === 'abnormal' ? ['resourceId', 'owner', 'generation', 'status', 'reason'] : ['resourceId', 'owner', 'generation', 'status'])
|
|
117
|
-
valid(id(lock.resourceId) && id(lock.owner) && integer(lock.generation, 1), 'lock')
|
|
118
|
-
const session = sessions.get(lock.owner)
|
|
119
|
-
valid(session && generations.get(lock.resourceId) === lock.generation && !resources.has(lock.resourceId), 'lock reference')
|
|
120
|
-
resources.add(lock.resourceId)
|
|
121
|
-
valid(['active', 'user-interrupted', 'pending-confirmation', 'abnormal'].includes(lock.status), 'lock status')
|
|
122
|
-
if (lock.status === 'abnormal') valid(id(lock.reason), 'abnormal reason')
|
|
123
|
-
else valid(session.interrupted === (lock.status === 'user-interrupted'), 'lock interruption')
|
|
124
|
-
}
|
|
125
|
-
const requests = new Set()
|
|
126
|
-
for (const request of state.issuedRequests) {
|
|
127
|
-
shape(request, ['sessionId', 'requestId'])
|
|
128
|
-
valid(sessions.has(request.sessionId) && id(request.requestId), 'issued request')
|
|
129
|
-
const key = canonical([request.sessionId, request.requestId])
|
|
130
|
-
valid(!requests.has(key), 'duplicate request')
|
|
131
|
-
requests.add(key)
|
|
132
|
-
}
|
|
133
|
-
const recoveries = new Set()
|
|
134
|
-
for (const recovery of state.recovery) {
|
|
135
|
-
shape(recovery, ['sessionId', 'attempts', 'elapsedMs', 'pauseMs'])
|
|
136
|
-
valid(sessions.has(recovery.sessionId) && !recoveries.has(recovery.sessionId), 'recovery reference')
|
|
137
|
-
valid(integer(recovery.attempts) && integer(recovery.elapsedMs) && integer(recovery.pauseMs), 'recovery counters')
|
|
138
|
-
recoveries.add(recovery.sessionId)
|
|
139
|
-
}
|
|
140
|
-
validateAdminLedger(state)
|
|
141
|
-
validateOperations(state)
|
|
142
|
-
if (state.version === 6) validateIncarnations(state)
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Parse and validate one committed snapshot's raw bytes for `domainId`.
|
|
147
|
-
* Byte-for-byte the store recover path's validation, in the same order with
|
|
148
|
-
* the same refusal messages; the caller supplies the bytes, so a read-only
|
|
149
|
-
* inspection runs no store, no recovery and no reservation. Throws on any
|
|
150
|
-
* deviation — the caller decides how corruption is surfaced; nothing here
|
|
151
|
-
* repairs, rewrites or upgrades the file on disk.
|
|
152
|
-
* @param {Buffer} bytes @param {string} domainId @returns {Snapshot}
|
|
153
|
-
*/
|
|
154
|
-
export function parseSnapshot(bytes, domainId) {
|
|
155
|
-
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
156
|
-
const envelope = JSON.parse(text)
|
|
157
|
-
// Exact byte equality rejects duplicate keys at every depth as well as
|
|
158
|
-
// alternate number/escape spellings, whitespace and reordered keys.
|
|
159
|
-
valid(Buffer.from(canonical(envelope)).equals(bytes), 'noncanonical snapshot')
|
|
160
|
-
shape(envelope, ['payload', 'checksum'])
|
|
161
|
-
const payload = envelope.payload
|
|
162
|
-
shape(payload, ['version', 'domainId', 'revision', 'state'])
|
|
163
|
-
valid(typeof payload.version === 'number' && [2, 3, 4, 5, 6].includes(payload.version), `snapshot version ${payload.version}; supported: 2, 3, 4, 5, 6; recover with a newer build or restore a pre-upgrade snapshot`)
|
|
164
|
-
valid(payload.domainId === domainId, 'snapshot domain')
|
|
165
|
-
valid(payload.state !== null && typeof payload.state === 'object' && 'version' in payload.state && payload.state.version === payload.version, 'snapshot/image version mismatch')
|
|
166
|
-
valid(integer(payload.revision), 'snapshot revision')
|
|
167
|
-
valid(typeof envelope.checksum === 'string' && envelope.checksum === createHash('sha256').update(canonical(payload)).digest('hex'), 'snapshot checksum')
|
|
168
|
-
// Verify legacy closeout shapes before upgrading: new semantics must never
|
|
169
|
-
// be smuggled into an old image. Integrity above is against the original bytes.
|
|
170
|
-
const legacy = payload.version === 2 ? upgradeFromV2(payload.state) : payload.state
|
|
171
|
-
const state = /** @type {AuthorityImage} */ (payload.version < 4 ? upgradeFromV3(legacy) : legacy)
|
|
172
|
-
validateImage(state)
|
|
173
|
-
for (const row of state.adminRecoveries ?? []) valid(row.root === domainId && row.committedRevision <= payload.revision, 'administrative domain/revision')
|
|
174
|
-
return { revision: payload.revision, state }
|
|
175
|
-
}
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
// Message-triggered stale-lock sweep: the missing-target classifier and the
|
|
2
|
-
// per-domain detached scheduler. Plain ESM, no ctx, no @deepseek-ai imports;
|
|
3
|
-
// the scheduler's job, clock and timer are injected so the composition and
|
|
4
|
-
// the unit tests share one core.
|
|
5
|
-
import { lstatSync } from 'node:fs'
|
|
6
|
-
import { dirname } from 'node:path'
|
|
7
|
-
|
|
8
|
-
/** Missing-target classification for one stored canonical resource id. ONLY
|
|
9
|
-
* ENOENT counts: a successful lstat — including of a dangling symlink — is
|
|
10
|
-
* not missing, and any other error (EACCES, ENOTDIR, symlink loops) skips the
|
|
11
|
-
* row. Never follows a moved file, never resolves aliases into a replacement
|
|
12
|
-
* identity, never fabricates a resource key for a missing path.
|
|
13
|
-
* @param {string} resourceId */
|
|
14
|
-
export function isMissingTarget(resourceId) {
|
|
15
|
-
if (typeof resourceId !== 'string' || resourceId.length === 0) return false
|
|
16
|
-
try {
|
|
17
|
-
lstatSync(resourceId)
|
|
18
|
-
return false
|
|
19
|
-
} catch (error) {
|
|
20
|
-
const code = /** @type {any} */ (error)?.code
|
|
21
|
-
if (code !== 'ENOENT') return false
|
|
22
|
-
// Windows reports a path through a regular-file component as ENOENT as
|
|
23
|
-
// well (there is no ENOTDIR): preserve the conservative POSIX
|
|
24
|
-
// classification by walking up — a non-directory ancestor means the path
|
|
25
|
-
// is BLOCKED (skip the row), not missing.
|
|
26
|
-
if (process.platform === 'win32') {
|
|
27
|
-
for (let parent = dirname(resourceId); parent !== dirname(parent); parent = dirname(parent)) {
|
|
28
|
-
let stat
|
|
29
|
-
try { stat = lstatSync(parent) } catch (ancestorError) {
|
|
30
|
-
if (/** @type {any} */ (ancestorError)?.code === 'ENOENT') continue
|
|
31
|
-
return false
|
|
32
|
-
}
|
|
33
|
-
return stat.isDirectory()
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
return true
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Cooldown between two sweeps of one management domain. */
|
|
41
|
-
export const STALE_SWEEP_COOLDOWN_MS = 60_000
|
|
42
|
-
|
|
43
|
-
/** Per-key detached sweep scheduler. At most one in-flight sweep and one
|
|
44
|
-
* cooldown window per key (the management-domain ROOT, not a session), so
|
|
45
|
-
* message volume across sessions never scales the aggregate scan cost. Idle
|
|
46
|
-
* entries whose cooldown window expired are RECLAIMED — on each trigger pass
|
|
47
|
-
* and when a job completes — and close() empties the registry, so the map
|
|
48
|
-
* never grows unboundedly with the number of domains a process has touched.
|
|
49
|
-
*
|
|
50
|
-
* Dispatch happens on a zero-delay timer and is NEVER awaited by the caller:
|
|
51
|
-
* the turn proceeds immediately (unlike auto-resume, which must precede the
|
|
52
|
-
* first step). close() cancels undispatched work; an already-running job
|
|
53
|
-
* always settles on its own — an already-submitted authority transaction is
|
|
54
|
-
* never cancelled or reinterpreted. A failed job warns once through the
|
|
55
|
-
* injected sink and still opens the cooldown window.
|
|
56
|
-
* @param {{cooldownMs?: number, now?: () => number,
|
|
57
|
-
* setTimer?: (fn: () => void) => unknown, clearTimer?: (handle: unknown) => void,
|
|
58
|
-
* warn?: (message: string) => void}} [options] */
|
|
59
|
-
export function createStaleSweepScheduler({ cooldownMs = STALE_SWEEP_COOLDOWN_MS, now = () => Date.now(), setTimer, clearTimer, warn = () => {} } = {}) {
|
|
60
|
-
const arm = setTimer ?? ((fn) => globalThis.setTimeout(fn, 0))
|
|
61
|
-
const disarm = clearTimer ?? ((handle) => globalThis.clearTimeout(handle))
|
|
62
|
-
/** @type {Map<string, {inFlight: Promise<unknown> | null, coolingUntil: number, handle: unknown}>} */
|
|
63
|
-
const entries = new Map()
|
|
64
|
-
let closed = false
|
|
65
|
-
/** Reclaim entries that are neither dispatched nor in flight and whose
|
|
66
|
-
* cooldown window expired. @param {unknown} [except] */
|
|
67
|
-
function prune(except = undefined) {
|
|
68
|
-
const at = now()
|
|
69
|
-
for (const [key, entry] of entries) {
|
|
70
|
-
if (entry === except) continue
|
|
71
|
-
if (entry.inFlight === null && entry.handle === null && at >= entry.coolingUntil) entries.delete(key)
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return Object.freeze({
|
|
75
|
-
/** Arm one sweep for key. Returns false when the key is closed, one is
|
|
76
|
-
* already dispatched or in flight, or the cooldown window is still open.
|
|
77
|
-
* @param {string} key @param {() => unknown} job */
|
|
78
|
-
trigger(key, job) {
|
|
79
|
-
if (closed || typeof key !== 'string' || key.length === 0 || typeof job !== 'function') return false
|
|
80
|
-
prune()
|
|
81
|
-
let entry = entries.get(key)
|
|
82
|
-
if (!entry) {
|
|
83
|
-
entry = { inFlight: null, coolingUntil: 0, handle: null }
|
|
84
|
-
entries.set(key, entry)
|
|
85
|
-
}
|
|
86
|
-
if (entry.inFlight !== null || entry.handle !== null || now() < entry.coolingUntil) return false
|
|
87
|
-
entry.handle = arm(() => {
|
|
88
|
-
entry.handle = null
|
|
89
|
-
if (closed) return
|
|
90
|
-
const pending = Promise.resolve()
|
|
91
|
-
.then(job)
|
|
92
|
-
.then(
|
|
93
|
-
() => {},
|
|
94
|
-
(error) => warn(`edit lock stale sweep failed: ${/** @type {any} */ (error)?.message ?? error}`),
|
|
95
|
-
)
|
|
96
|
-
.then(() => {
|
|
97
|
-
entry.inFlight = null
|
|
98
|
-
entry.coolingUntil = now() + cooldownMs
|
|
99
|
-
// Completion is the other reclamation point: peers whose windows
|
|
100
|
-
// lapsed while this job ran are dropped here.
|
|
101
|
-
prune(entry)
|
|
102
|
-
})
|
|
103
|
-
entry.inFlight = pending
|
|
104
|
-
})
|
|
105
|
-
return true
|
|
106
|
-
},
|
|
107
|
-
/** Entry count, for diagnostics and tests. */
|
|
108
|
-
get size() { return entries.size },
|
|
109
|
-
/** Cancel every undispatched sweep and forget every key. In-flight jobs
|
|
110
|
-
* settle untouched. */
|
|
111
|
-
close() {
|
|
112
|
-
closed = true
|
|
113
|
-
for (const entry of entries.values()) {
|
|
114
|
-
if (entry.handle !== null) disarm(entry.handle)
|
|
115
|
-
entry.handle = null
|
|
116
|
-
}
|
|
117
|
-
entries.clear()
|
|
118
|
-
},
|
|
119
|
-
})
|
|
120
|
-
}
|