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,191 +0,0 @@
|
|
|
1
|
-
import { lstatSync } from 'node:fs'
|
|
2
|
-
import { dirname, isAbsolute, join, relative, resolve as resolvePath } from 'node:path'
|
|
3
|
-
import { createResourceIdentity } from './resource-identity.js'
|
|
4
|
-
import { canonicalRequestData } from './request-data.js'
|
|
5
|
-
|
|
6
|
-
/** Bind the original host filesystem once; never substitute a Node write fallback.
|
|
7
|
-
* The trusted host owns policy provenance, execution authentication, exclusive
|
|
8
|
-
* manager lifetime and exclusion of external topology writers.
|
|
9
|
-
* @param {{manager: ReturnType<typeof import('./manager.js').createEditLockManager>,
|
|
10
|
-
* fs: {resolve: (path: string, options: {cwd: string}) => Promise<any>, writeText: (...args: any[]) => Promise<{version: string}>}, root: string, excluded?: string[], assertExclusive?: () => void}} options */
|
|
11
|
-
export function createPublisher({ manager, fs, root, excluded = [], assertExclusive = () => {} }) {
|
|
12
|
-
if (!isAbsolute(root)) throw new Error('absolute domain root required')
|
|
13
|
-
const resolve = fs.resolve.bind(fs)
|
|
14
|
-
const writeText = fs.writeText.bind(fs)
|
|
15
|
-
const identity = createResourceIdentity()
|
|
16
|
-
/** @type {WeakMap<object, () => void>} */
|
|
17
|
-
const listeners = new WeakMap()
|
|
18
|
-
/** @param {string} path */
|
|
19
|
-
function contained(path) {
|
|
20
|
-
const suffix = relative(root, path)
|
|
21
|
-
if (suffix === '..' || suffix.startsWith('../') || isAbsolute(suffix)) throw new Error('outside management domain')
|
|
22
|
-
for (const protectedPath of excluded) {
|
|
23
|
-
const inner = relative(protectedPath, path)
|
|
24
|
-
if (inner === '' || (!inner.startsWith('..') && !isAbsolute(inner))) throw new Error('Edit Lock authority files are not editable')
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
return Object.freeze({
|
|
28
|
-
/** Canonical identity of one existing in-domain regular file; never a
|
|
29
|
-
* creation key and never a permission. @param {string} path @param {string} cwd */
|
|
30
|
-
resource(path, cwd) {
|
|
31
|
-
assertExclusive()
|
|
32
|
-
const observation = identity.resolve(path, { cwd })
|
|
33
|
-
if (observation.kind !== 'file') throw new Error('lock target must be an existing regular file')
|
|
34
|
-
contained(observation.resourceId)
|
|
35
|
-
return observation.resourceId
|
|
36
|
-
},
|
|
37
|
-
/** Release observations are subtractive only; never publication authority.
|
|
38
|
-
* @param {string} path @param {string} cwd */
|
|
39
|
-
releaseResource(path, cwd) {
|
|
40
|
-
assertExclusive()
|
|
41
|
-
const observation = identity.resolve(path, { cwd })
|
|
42
|
-
const resourceId = observation.kind === 'file' ? observation.resourceId : resolvePath(cwd, path)
|
|
43
|
-
contained(resourceId)
|
|
44
|
-
if (observation.kind === 'missing' &&
|
|
45
|
-
(path.split(/[\\/]/).includes('..') || resourceId !== join(observation.ancestor, observation.suffix) || !missingTarget(resourceId))) {
|
|
46
|
-
throw new Error('Edit Lock release refused: missing target is not proven')
|
|
47
|
-
}
|
|
48
|
-
return { resourceId, observation }
|
|
49
|
-
},
|
|
50
|
-
/** Explicit owner release: preserve ordinary admission, but recheck missing
|
|
51
|
-
* evidence inside the manager FIFO rather than trusting a preflight scan.
|
|
52
|
-
* @param {import('./state.js').Ownership} token
|
|
53
|
-
* @param {ReturnType<typeof identity.resolve>} observation */
|
|
54
|
-
async release(token, observation) {
|
|
55
|
-
assertExclusive()
|
|
56
|
-
token = { ...token }
|
|
57
|
-
if (observation.kind === 'file') return manager.release(token)
|
|
58
|
-
const status = manager.status()
|
|
59
|
-
if (token.managerIncarnation !== status.managerIncarnation) throw new Error('stale incarnation')
|
|
60
|
-
const lock = status.locks.find(item => item.resourceId === token.resourceId)
|
|
61
|
-
if (!lock || lock.owner !== token.sessionId) throw new Error('lock not owned by this session')
|
|
62
|
-
const result = await manager.releaseStale([{
|
|
63
|
-
resourceId: token.resourceId, owner: token.sessionId, generation: token.generation,
|
|
64
|
-
executionEpoch: token.executionEpoch, status: lock.status,
|
|
65
|
-
}], resourceId => {
|
|
66
|
-
assertExclusive()
|
|
67
|
-
identity.revalidate(observation)
|
|
68
|
-
return missingTarget(resourceId)
|
|
69
|
-
})
|
|
70
|
-
const refusal = [...result.skipped, ...result.failed]
|
|
71
|
-
if (refusal.length || result.released.length !== 1) {
|
|
72
|
-
throw new Error(`Edit Lock release refused: ${refusal.map(item => item.reason).join('; ') || 'release not confirmed'}`)
|
|
73
|
-
}
|
|
74
|
-
},
|
|
75
|
-
/** Resolve the entire existing-file set before atomic ownership acquisition.
|
|
76
|
-
* @param {import('./state.js').Execution} execution
|
|
77
|
-
* @param {string[]} paths @param {string} cwd */
|
|
78
|
-
acquireBatch(execution, paths, cwd) {
|
|
79
|
-
assertExclusive()
|
|
80
|
-
const observations = paths.map(path => {
|
|
81
|
-
const observation = identity.resolve(path, { cwd })
|
|
82
|
-
if (observation.kind !== 'file') throw new Error('rename requires existing regular files')
|
|
83
|
-
contained(observation.resourceId)
|
|
84
|
-
return observation
|
|
85
|
-
})
|
|
86
|
-
if (new Set(observations.map(item => item.resourceId)).size !== paths.length) throw new Error('duplicate canonical rename target')
|
|
87
|
-
return manager.acquireMany(execution, observations.map(item => item.resourceId)).then(ownership => ({
|
|
88
|
-
...ownership, ordered: observations.map(item => ownership.tokens.find(/** @param {import('./state.js').Ownership} token */ token => token.resourceId === item.resourceId)),
|
|
89
|
-
}))
|
|
90
|
-
},
|
|
91
|
-
/** Trusted adapter only: content is the fully synthesized original tool payload.
|
|
92
|
-
* @param {import('./state.js').Execution} execution
|
|
93
|
-
* @param {{operationId: string, tool: 'write'|'hash_edit'|'lsp_rename', filePath: string, cwd: string,
|
|
94
|
-
* args: unknown, content: string, effectivePolicy: any,
|
|
95
|
-
* batchOwnership?: import('./state.js').Ownership,
|
|
96
|
-
* expected: {kind: 'createIfAbsent'}|{kind: 'replaceIfVersion', version: string}}} request
|
|
97
|
-
* @param {AbortSignal} signal */
|
|
98
|
-
async prepare(execution, request, signal) {
|
|
99
|
-
const data = JSON.parse(canonicalRequestData(request))
|
|
100
|
-
execution = { ...execution }
|
|
101
|
-
const historical = manager.history(execution.sessionId, data.operationId)
|
|
102
|
-
if (historical) {
|
|
103
|
-
if (canonicalRequestData(data.expected) !== canonicalRequestData(historical.binding.target.policy)) throw new Error('ID_REUSE: original guard differs')
|
|
104
|
-
return manager.prepare(execution, { ...data, target: historical.binding.target }, {
|
|
105
|
-
validate() { throw new Error('historical operation cannot dispatch') },
|
|
106
|
-
publish() { throw new Error('historical operation cannot dispatch') },
|
|
107
|
-
identify() { throw new Error('historical operation cannot dispatch') },
|
|
108
|
-
})
|
|
109
|
-
}
|
|
110
|
-
const abort = () => {
|
|
111
|
-
try { void manager.cancel(execution).catch(() => {}) } catch { /* stale execution cannot revoke a newer one */ }
|
|
112
|
-
}
|
|
113
|
-
signal.addEventListener('abort', abort, { once: true })
|
|
114
|
-
if (signal.aborted) abort()
|
|
115
|
-
const detach = () => signal.removeEventListener('abort', abort)
|
|
116
|
-
try {
|
|
117
|
-
if (signal.aborted) throw new Error('call aborted')
|
|
118
|
-
if (!['workspace-write', 'danger-full-access'].includes(data.effectivePolicy?.mode)) throw new Error('writable effective policy required')
|
|
119
|
-
const observation = identity.resolve(data.filePath, { cwd: data.cwd })
|
|
120
|
-
contained(observation.kind === 'file' ? observation.resourceId : observation.ancestor)
|
|
121
|
-
// The lexical creation target too: a missing protected subtree is not an escape.
|
|
122
|
-
if (observation.kind === 'missing') contained(join(observation.ancestor, observation.suffix))
|
|
123
|
-
const target = await resolve(data.filePath, { cwd: data.cwd })
|
|
124
|
-
let descriptor
|
|
125
|
-
if (observation.kind === 'missing') {
|
|
126
|
-
if (data.tool !== 'write' || data.expected.kind !== 'createIfAbsent') throw new Error('creation requires explicit write intent')
|
|
127
|
-
descriptor = { kind: 'create', ancestor: observation.ancestor, suffix: observation.suffix, policy: data.expected }
|
|
128
|
-
} else {
|
|
129
|
-
if (data.expected.kind !== 'replaceIfVersion') throw new Error('existing resource requires original version guard')
|
|
130
|
-
let ownership = data.batchOwnership
|
|
131
|
-
if (data.tool === 'lsp_rename') {
|
|
132
|
-
if (!ownership || ownership.resourceId !== observation.resourceId || ownership.sessionId !== execution.sessionId ||
|
|
133
|
-
ownership.executionEpoch !== execution.executionEpoch || ownership.managerIncarnation !== execution.managerIncarnation) throw new Error('rename batch ownership changed')
|
|
134
|
-
manager.checkWrite(ownership)
|
|
135
|
-
} else {
|
|
136
|
-
const acquired = await manager.acquireMany(execution, [observation.resourceId])
|
|
137
|
-
ownership = acquired.tokens[0]
|
|
138
|
-
}
|
|
139
|
-
descriptor = { kind: 'update', resourceId: observation.resourceId, generation: ownership.generation, policy: data.expected }
|
|
140
|
-
}
|
|
141
|
-
const validate = () => {
|
|
142
|
-
assertExclusive()
|
|
143
|
-
if (signal.aborted) throw new Error('call aborted')
|
|
144
|
-
if (data.effectivePolicy?.mode === 'read-only') throw new Error('read-only policy')
|
|
145
|
-
identity.revalidate(observation)
|
|
146
|
-
}
|
|
147
|
-
const ready = await manager.prepare(execution, { ...data, target: descriptor }, {
|
|
148
|
-
validate,
|
|
149
|
-
// Admission still observes the turn signal. Once invoked, join the host
|
|
150
|
-
// commit independently: Stop revokes future work, not this dispatched write.
|
|
151
|
-
publish: () => writeText(target, data.content, data.expected, new AbortController().signal, data.effectivePolicy),
|
|
152
|
-
identify: () => {
|
|
153
|
-
const current = identity.resolve(data.filePath, { cwd: data.cwd })
|
|
154
|
-
if (current.kind !== 'file') throw new Error('published resource missing')
|
|
155
|
-
contained(current.resourceId)
|
|
156
|
-
if (observation.kind === 'missing') {
|
|
157
|
-
const suffix = relative(observation.ancestor, current.resourceId)
|
|
158
|
-
if (suffix === '..' || suffix.startsWith('../') || isAbsolute(suffix)) throw new Error('published resource escaped fence')
|
|
159
|
-
}
|
|
160
|
-
return current.resourceId
|
|
161
|
-
},
|
|
162
|
-
})
|
|
163
|
-
if (ready.kind === 'ready' && ready.submission) listeners.set(ready.submission, detach)
|
|
164
|
-
else detach()
|
|
165
|
-
return ready
|
|
166
|
-
} catch (error) { detach(); throw error }
|
|
167
|
-
},
|
|
168
|
-
/** @param {object} submission */
|
|
169
|
-
async commit(submission) {
|
|
170
|
-
try { return await manager.commit(submission) }
|
|
171
|
-
finally { listeners.get(submission)?.(); listeners.delete(submission) }
|
|
172
|
-
},
|
|
173
|
-
})
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** ENOENT alone is insufficient through a symlink or non-directory ancestor.
|
|
177
|
-
* Walk every surviving component without following links; all ambiguity refuses.
|
|
178
|
-
* @param {string} resourceId */
|
|
179
|
-
function missingTarget(resourceId) {
|
|
180
|
-
try { lstatSync(resourceId); return false } catch (error) {
|
|
181
|
-
if (/** @type {any} */ (error)?.code !== 'ENOENT') throw error
|
|
182
|
-
}
|
|
183
|
-
for (let parent = dirname(resourceId); ; parent = dirname(parent)) {
|
|
184
|
-
try {
|
|
185
|
-
if (!lstatSync(parent).isDirectory()) return false
|
|
186
|
-
} catch (error) {
|
|
187
|
-
if (/** @type {any} */ (error)?.code !== 'ENOENT' || parent === dirname(parent)) throw error
|
|
188
|
-
}
|
|
189
|
-
if (parent === dirname(parent)) return true
|
|
190
|
-
}
|
|
191
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
// Read-only authority snapshot bytes: the one bounded, symlink-pinned,
|
|
2
|
-
// identity-checked read shared by the maintenance inspector and the cold
|
|
3
|
-
// status view (design D1). No runtime open, no reservation, no mutation —
|
|
4
|
-
// the file, the reservation and every history are left byte-identical.
|
|
5
|
-
import { lstatSync, openSync, closeSync, fstatSync, readSync, constants } from 'node:fs'
|
|
6
|
-
import { join, resolve, parse, relative, sep } from 'node:path'
|
|
7
|
-
|
|
8
|
-
/** Same ceiling the maintenance inspector enforces (16 MiB). */
|
|
9
|
-
export const MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024
|
|
10
|
-
|
|
11
|
-
// Pin every component, including the trusted root's ancestors. Node exposes no
|
|
12
|
-
// portable openat: compare identities before/after opening and before/after read.
|
|
13
|
-
// NOFOLLOW protects the leaf; NONBLOCK prevents swapped FIFO/device hangs.
|
|
14
|
-
// These checks fail closed on observed races, not an atomic hostile-rename proof.
|
|
15
|
-
export function checkedPath(path) {
|
|
16
|
-
const absolute = resolve(path)
|
|
17
|
-
let current = parse(absolute).root
|
|
18
|
-
const pins = []
|
|
19
|
-
for (const part of relative(current, absolute).split(sep).filter(Boolean)) {
|
|
20
|
-
current = join(current, part)
|
|
21
|
-
const stat = lstatSync(current)
|
|
22
|
-
if (stat.isSymbolicLink()) throw new Error('symlink authority component refused')
|
|
23
|
-
pins.push({ path: current, stat })
|
|
24
|
-
}
|
|
25
|
-
return pins
|
|
26
|
-
}
|
|
27
|
-
/** @param {import('node:fs').Stats} a @param {import('node:fs').Stats} b */
|
|
28
|
-
export function sameNode(a, b) { return a.dev === b.dev && a.ino === b.ino && a.mode === b.mode }
|
|
29
|
-
/** @param {{ path: string, stat: import('node:fs').Stats }[]} pins */
|
|
30
|
-
export function verifyPins(pins) {
|
|
31
|
-
for (const pin of pins) if (!sameNode(pin.stat, lstatSync(pin.path))) throw new Error('authority path changed during inspection')
|
|
32
|
-
}
|
|
33
|
-
/** @param {unknown} error */
|
|
34
|
-
export function missing(error) { return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' }
|
|
35
|
-
/** @param {unknown} error */
|
|
36
|
-
export function errorMessage(error) { return error instanceof Error ? error.message : String(error) }
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* @typedef {{ presence: 'valid', bytes: Buffer }
|
|
40
|
-
* | { presence: 'no-authority' }
|
|
41
|
-
* | { presence: 'no-snapshot' }
|
|
42
|
-
* | { presence: 'not-a-file' }
|
|
43
|
-
* | { presence: 'unreadable', message: string }} SnapshotRead
|
|
44
|
-
*/
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Read `<authorityDir>/snapshot.json` with the full read-only discipline:
|
|
48
|
-
* ancestor pins, O_NOFOLLOW leaf, bounded read, before/after identity checks.
|
|
49
|
-
* The bytes are validated nowhere here — the caller runs `parseSnapshot`.
|
|
50
|
-
*
|
|
51
|
-
* Presence facts, never inferred: 'valid' (bytes returned), 'no-authority'
|
|
52
|
-
* (a path component or the directory itself is absent), 'no-snapshot' (the
|
|
53
|
-
* directory stands but no snapshot leaf exists; history indeterminate),
|
|
54
|
-
* 'not-a-file', 'unreadable' (IO failure or an observed race, message kept).
|
|
55
|
-
* @param {string} authorityDir @returns {SnapshotRead}
|
|
56
|
-
*/
|
|
57
|
-
export function readSnapshotBytes(authorityDir) {
|
|
58
|
-
let parents
|
|
59
|
-
try {
|
|
60
|
-
parents = checkedPath(authorityDir)
|
|
61
|
-
} catch (error) {
|
|
62
|
-
if (missing(error)) return { presence: 'no-authority' }
|
|
63
|
-
return { presence: 'unreadable', message: errorMessage(error) }
|
|
64
|
-
}
|
|
65
|
-
let fd
|
|
66
|
-
try {
|
|
67
|
-
const path = join(authorityDir, 'snapshot.json')
|
|
68
|
-
let leaf
|
|
69
|
-
try { leaf = lstatSync(path) } catch (error) { if (!missing(error)) throw error }
|
|
70
|
-
if (!leaf) {
|
|
71
|
-
verifyPins(parents)
|
|
72
|
-
return { presence: 'no-snapshot' }
|
|
73
|
-
}
|
|
74
|
-
if (!leaf.isFile() || leaf.isSymbolicLink()) return { presence: 'not-a-file' }
|
|
75
|
-
// Windows exposes no O_NOFOLLOW/O_NONBLOCK (constants undefined): the leaf
|
|
76
|
-
// lstat above already refused a symlink and the post-open fstat identity
|
|
77
|
-
// check below bounds the swap window (symlink creation needs privilege on
|
|
78
|
-
// Windows; the residual TOCTOU gap is documented in edit-lock.md).
|
|
79
|
-
fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
|
|
80
|
-
const opened = fstatSync(fd)
|
|
81
|
-
if (!opened.isFile() || !sameNode(leaf, opened)) throw new Error('opened snapshot identity changed')
|
|
82
|
-
verifyPins(parents)
|
|
83
|
-
if (!sameNode(opened, lstatSync(path))) throw new Error('snapshot replaced before read')
|
|
84
|
-
if (opened.size > MAX_SNAPSHOT_BYTES) throw new Error('snapshot exceeds inspection byte limit')
|
|
85
|
-
const buffer = Buffer.alloc(Math.min(opened.size + 1, MAX_SNAPSHOT_BYTES + 1))
|
|
86
|
-
let length = 0
|
|
87
|
-
while (length < buffer.length) {
|
|
88
|
-
const count = readSync(fd, buffer, length, buffer.length - length, length)
|
|
89
|
-
if (!count) break
|
|
90
|
-
length += count
|
|
91
|
-
}
|
|
92
|
-
const after = fstatSync(fd)
|
|
93
|
-
verifyPins(parents)
|
|
94
|
-
if (!sameNode(opened, lstatSync(path)) || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs || length !== opened.size) {
|
|
95
|
-
throw new Error('snapshot changed during bounded read')
|
|
96
|
-
}
|
|
97
|
-
return { presence: 'valid', bytes: buffer.subarray(0, length) }
|
|
98
|
-
} catch (error) {
|
|
99
|
-
return { presence: 'unreadable', message: errorMessage(error) }
|
|
100
|
-
} finally { if (fd !== undefined) closeSync(fd) }
|
|
101
|
-
}
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
/** Cleanup-only automatic recovery for Edit Lock (design D4).
|
|
2
|
-
*
|
|
3
|
-
* Classification uses only the durable turn/end reason. A provider error that
|
|
4
|
-
* ends a turn while the session holds locks marks them abnormal; the session
|
|
5
|
-
* may then only release, answer transfer requests or pause. Attempts, elapsed
|
|
6
|
-
* recovery time and pause time are charged durably and never refunded by a
|
|
7
|
-
* restart. Expiry re-evaluates; it never releases or extends. Exhaustion hands
|
|
8
|
-
* the locks to a human. Business authority returns only via /edit-lock resume.
|
|
9
|
-
*/
|
|
10
|
-
export const RECOVERY_LIMITS = Object.freeze({
|
|
11
|
-
attempts: 3,
|
|
12
|
-
elapsedMs: 5 * 60_000,
|
|
13
|
-
backoffMs: Object.freeze([15_000, 30_000, 60_000]),
|
|
14
|
-
singlePauseMs: 15 * 60_000,
|
|
15
|
-
cumulativePauseMs: 30 * 60_000,
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
/** @param {{attempts: number, elapsedMs: number}} usage @param {number} pendingElapsedMs */
|
|
19
|
-
export function recoveryBudgetLeft(usage, pendingElapsedMs = 0) {
|
|
20
|
-
return usage.attempts < RECOVERY_LIMITS.attempts && usage.elapsedMs + pendingElapsedMs < RECOVERY_LIMITS.elapsedMs
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** @param {{resourceId: string, status: string, reason?: string}[]} locks @param {number} attempt */
|
|
24
|
-
export function cleanupPrompt(locks, attempt) {
|
|
25
|
-
const lines = locks.filter(lock => lock.status === 'abnormal').map(lock => `- ${lock.resourceId} (${lock.reason ?? 'abnormal'})`)
|
|
26
|
-
return [
|
|
27
|
-
`Edit Lock cleanup-only recovery (attempt ${attempt} of ${RECOVERY_LIMITS.attempts}). The previous turn failed while this session held file ownership, so these locks are now abnormal:`,
|
|
28
|
-
...lines,
|
|
29
|
-
'Business edits and new ownership are denied in this state. You may only: release a lock you no longer need (edit_lock_release), answer a pending ownership request (edit_lock_reply), or request a bounded pause while an external problem clears (edit_lock_pause). Do not attempt other file changes.',
|
|
30
|
-
'When cleanup is done, stop. The user resumes normal editing from the Edit Lock panel.',
|
|
31
|
-
].join('\n')
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** @param {{
|
|
35
|
-
* domainFor: (agent: object) => Promise<any> | undefined,
|
|
36
|
-
* followup: (agent: object, text: string) => void,
|
|
37
|
-
* notify: (agent: object, text: string) => void,
|
|
38
|
-
* now?: () => number,
|
|
39
|
-
* setTimer?: (fn: () => void, ms: number) => any, clearTimer?: (handle: any) => void,
|
|
40
|
-
* warn?: (message: string) => void,
|
|
41
|
-
* }} options */
|
|
42
|
-
export function createRecoveryDriver(options) {
|
|
43
|
-
const now = options.now ?? Date.now
|
|
44
|
-
const setTimer = options.setTimer ?? ((fn, ms) => { const handle = setTimeout(fn, ms); handle.unref?.(); return handle })
|
|
45
|
-
const clearTimer = options.clearTimer ?? (handle => clearTimeout(handle))
|
|
46
|
-
/** @typedef {{since: number, timer?: any, pausedUntil?: number, done?: boolean}} Recovery */
|
|
47
|
-
/** @type {WeakMap<object, Recovery>} */
|
|
48
|
-
const active = new WeakMap()
|
|
49
|
-
let closed = false
|
|
50
|
-
|
|
51
|
-
/** Charge time spent recovering since the last mark (pauses excluded).
|
|
52
|
-
* @param {any} domain @param {object} agent @param {Recovery} recovery @param {{attempts?: number}} [extra] */
|
|
53
|
-
async function chargeElapsed(domain, agent, recovery, extra = {}) {
|
|
54
|
-
const elapsedMs = Math.max(0, now() - recovery.since)
|
|
55
|
-
recovery.since = now()
|
|
56
|
-
return domain.chargeRecovery(agent, { ...extra, elapsedMs })
|
|
57
|
-
}
|
|
58
|
-
/** @param {object} agent @param {Recovery} recovery */
|
|
59
|
-
function cancel(agent, recovery) {
|
|
60
|
-
if (recovery.timer) clearTimer(recovery.timer)
|
|
61
|
-
recovery.timer = undefined
|
|
62
|
-
recovery.done = true
|
|
63
|
-
active.delete(agent)
|
|
64
|
-
}
|
|
65
|
-
/** @param {object} agent @param {Recovery} recovery @param {number} delayMs */
|
|
66
|
-
function schedule(agent, recovery, delayMs) {
|
|
67
|
-
if (recovery.timer) clearTimer(recovery.timer)
|
|
68
|
-
recovery.timer = setTimer(() => { recovery.timer = undefined; void attempt(agent, recovery) }, delayMs)
|
|
69
|
-
}
|
|
70
|
-
/** @param {object} agent @param {Recovery} recovery */
|
|
71
|
-
async function attempt(agent, recovery) {
|
|
72
|
-
if (closed || recovery.done || active.get(agent) !== recovery) return
|
|
73
|
-
try {
|
|
74
|
-
const domain = await options.domainFor(agent)
|
|
75
|
-
const status = await domain.status(agent)
|
|
76
|
-
if (status.state !== 'recovering') { cancel(agent, recovery); return }
|
|
77
|
-
if (!status.locks.some(/** @param {any} lock */ lock => lock.status === 'abnormal')) {
|
|
78
|
-
cancel(agent, recovery)
|
|
79
|
-
options.notify(agent, 'Edit Lock cleanup complete: no abnormal locks remain. Editing stays paused until the user chooses Continue editing.')
|
|
80
|
-
return
|
|
81
|
-
}
|
|
82
|
-
const usage = await domain.recoveryUsage(agent)
|
|
83
|
-
if (!recoveryBudgetLeft(usage, now() - recovery.since)) {
|
|
84
|
-
await chargeElapsed(domain, agent, recovery)
|
|
85
|
-
cancel(agent, recovery)
|
|
86
|
-
options.notify(agent, `Edit Lock automatic cleanup stopped after ${RECOVERY_LIMITS.attempts} attempts or ${RECOVERY_LIMITS.elapsedMs / 60_000} minutes. The remaining files stay locked for the user to release or unlock from the Edit Lock panel.`)
|
|
87
|
-
return
|
|
88
|
-
}
|
|
89
|
-
const charged = await chargeElapsed(domain, agent, recovery, { attempts: 1 })
|
|
90
|
-
options.followup(agent, cleanupPrompt(status.locks, charged.attempts))
|
|
91
|
-
} catch (error) {
|
|
92
|
-
options.warn?.(`edit lock recovery attempt failed: ${/** @type {any} */ (error)?.message ?? error}`)
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return Object.freeze({
|
|
97
|
-
/** Durable turn/end feed for one agent. @param {object} agent @param {any} reason */
|
|
98
|
-
async onTurnEnd(agent, reason) {
|
|
99
|
-
if (closed) return
|
|
100
|
-
const domain = await options.domainFor(agent)
|
|
101
|
-
if (!domain) return
|
|
102
|
-
let recovery = active.get(agent)
|
|
103
|
-
if (reason?.kind === 'aborted') { if (recovery) cancel(agent, recovery); return }
|
|
104
|
-
if (!recovery) {
|
|
105
|
-
if (reason?.kind !== 'error') return
|
|
106
|
-
const marked = await domain.classifyAbnormal(agent, 'provider-error')
|
|
107
|
-
if (!marked.length) return
|
|
108
|
-
recovery = { since: now() }
|
|
109
|
-
active.set(agent, recovery)
|
|
110
|
-
options.notify(agent, `Edit Lock: the turn failed while this session held files; ${marked.length} lock(s) are now abnormal and editing is limited to cleanup.`)
|
|
111
|
-
}
|
|
112
|
-
if (recovery.pausedUntil && recovery.pausedUntil > now()) return
|
|
113
|
-
const usage = await domain.recoveryUsage(agent)
|
|
114
|
-
schedule(agent, recovery, RECOVERY_LIMITS.backoffMs[Math.min(usage.attempts, RECOVERY_LIMITS.backoffMs.length - 1)])
|
|
115
|
-
},
|
|
116
|
-
/** Pause request from the cleanup turn; expiry re-evaluates.
|
|
117
|
-
* @param {object} agent @param {number} minutes */
|
|
118
|
-
async pause(agent, minutes) {
|
|
119
|
-
const recovery = active.get(agent)
|
|
120
|
-
if (!recovery) throw new Error('no automatic recovery is in progress for this session')
|
|
121
|
-
const domain = await options.domainFor(agent)
|
|
122
|
-
const result = await domain.pause(agent, minutes)
|
|
123
|
-
await chargeElapsed(domain, agent, recovery)
|
|
124
|
-
recovery.pausedUntil = now() + result.pausedMs
|
|
125
|
-
if (recovery.timer) clearTimer(recovery.timer)
|
|
126
|
-
// Elapsed recovery time restarts after the pause, not during it.
|
|
127
|
-
recovery.timer = setTimer(() => {
|
|
128
|
-
recovery.timer = undefined
|
|
129
|
-
recovery.since = now()
|
|
130
|
-
recovery.pausedUntil = undefined
|
|
131
|
-
void attempt(agent, recovery)
|
|
132
|
-
}, result.pausedMs)
|
|
133
|
-
return result
|
|
134
|
-
},
|
|
135
|
-
/** @param {object} agent */
|
|
136
|
-
state(agent) { const recovery = active.get(agent); return recovery ? { pausedUntil: recovery.pausedUntil ?? null } : null },
|
|
137
|
-
close() { closed = true },
|
|
138
|
-
})
|
|
139
|
-
}
|