weir-harness 1.2.3 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cordis.patch.yml +7 -18
- package/lib/client.file-lease-badge.js +307 -0
- package/lib/client.js +20 -30
- package/lib/client.settings-page.js +10 -34
- package/package.json +2 -2
- package/src/capabilities/store/checked-contract.d.ts +2 -2
- package/src/capabilities/store/fs-adapter.js +2 -3
- package/src/context-guard/index.js +2 -2
- package/src/delegate/supervision-mount.js +5 -5
- package/src/delegate/tool.js +2 -10
- package/src/file-lease/client.js +1066 -0
- package/src/file-lease/index.js +30 -0
- package/src/file-lease/lineage.js +207 -0
- package/src/file-lease/node-contracts.d.ts +42 -0
- package/src/file-lease/plugin.js +789 -0
- package/src/file-lease/singleton.js +155 -0
- package/src/file-lease/spawn.js +62 -0
- package/src/file-lease/tools.js +459 -0
- package/src/file-lease/view.js +144 -0
- package/src/hashline-edit/index.js +76 -42
- package/src/hashline-edit/write-tool.js +90 -0
- package/src/lease-broker/entry.js +98 -0
- package/src/lease-broker/node-contracts.d.ts +63 -0
- package/src/lease-broker/protocol.js +236 -0
- package/src/lease-broker/server.js +399 -0
- package/src/lease-broker/state.js +425 -0
- package/src/lsp/tools.js +137 -27
- package/src/settings/index.js +2 -44
- package/src/settings/sections.js +2 -24
- package/src/shared/audit.js +6 -1
- package/src/todo-driver/index.js +1 -6
- package/src/worktree/cards.js +6 -21
- package/src/worktree/lane-core.js +1 -19
- package/src/worktree/lane-workflows.js +11 -32
- package/lib/client.edit-lock-maintenance.js +0 -592
- package/lib/client.edit-lock-panel.js +0 -405
- package/src/edit-lock/adapter.js +0 -57
- package/src/edit-lock/admin-ledger.js +0 -149
- package/src/edit-lock/admin-recovery.js +0 -151
- package/src/edit-lock/admission.js +0 -82
- package/src/edit-lock/binding-attestation.js +0 -29
- package/src/edit-lock/binding-guidance.js +0 -72
- package/src/edit-lock/binding-machine.js +0 -312
- package/src/edit-lock/call-context.js +0 -65
- package/src/edit-lock/derived-facts.js +0 -67
- package/src/edit-lock/domain.js +0 -165
- package/src/edit-lock/domains.js +0 -170
- package/src/edit-lock/host.js +0 -149
- package/src/edit-lock/incarnations.js +0 -150
- package/src/edit-lock/index.js +0 -1303
- package/src/edit-lock/inspect.js +0 -219
- package/src/edit-lock/lifecycle.js +0 -579
- package/src/edit-lock/limits.js +0 -61
- package/src/edit-lock/maintenance.js +0 -310
- package/src/edit-lock/manager.js +0 -875
- package/src/edit-lock/negotiation.js +0 -132
- package/src/edit-lock/operation-history.js +0 -214
- package/src/edit-lock/peer-client.js +0 -86
- package/src/edit-lock/peer-transport.js +0 -76
- package/src/edit-lock/peer.js +0 -87
- package/src/edit-lock/publisher.js +0 -191
- package/src/edit-lock/read-authority.js +0 -101
- package/src/edit-lock/recovery.js +0 -139
- package/src/edit-lock/remote.js +0 -362
- package/src/edit-lock/request-binding.js +0 -42
- package/src/edit-lock/request-data.js +0 -40
- package/src/edit-lock/reservation.js +0 -301
- package/src/edit-lock/reserved-runtime.js +0 -45
- package/src/edit-lock/resource-identity.js +0 -130
- package/src/edit-lock/runtime.js +0 -145
- package/src/edit-lock/settle.js +0 -144
- package/src/edit-lock/snapshot.js +0 -175
- package/src/edit-lock/stale-sweep.js +0 -120
- package/src/edit-lock/state.js +0 -462
- package/src/edit-lock/store.js +0 -327
- package/src/edit-lock/tool-scope.js +0 -29
- package/src/edit-lock/view.js +0 -170
- package/src/edit-lock/write-scopes.js +0 -48
- package/src/edit-lock/write-tool.js +0 -45
- package/src/shared/delegate-edit-lifecycle.js +0 -36
- package/src/worktree/authority.js +0 -56
package/src/edit-lock/store.js
DELETED
|
@@ -1,327 +0,0 @@
|
|
|
1
|
-
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
-
import { open, rename, lstat, readdir } from 'node:fs/promises'
|
|
3
|
-
import { constants } from 'node:fs'
|
|
4
|
-
import { join, resolve } from 'node:path'
|
|
5
|
-
import { validateOperationTransitions } from './operation-history.js'
|
|
6
|
-
import { canonical, parseSnapshot, validateImage } from './snapshot.js'
|
|
7
|
-
import { validateAdminTransition } from './admin-ledger.js'
|
|
8
|
-
|
|
9
|
-
// Directory fsync is POSIX-only (EPERM on win32, Node v24 — same stance as
|
|
10
|
-
// reservation.js SYNC_SUPPORTED): the win32 row persists with file fsync +
|
|
11
|
-
// atomic rename and skips the directory sync steps.
|
|
12
|
-
const DIR_SYNC_SUPPORTED = process.platform === 'darwin' || process.platform === 'linux'
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Unmounted historical Edit Lock snapshot storage, NOT a kernel restore interface.
|
|
16
|
-
* The caller MUST already hold an externally proven exclusive directory lifecycle
|
|
17
|
-
* and establish old-publisher quiescence (or explicit audited administrator risk
|
|
18
|
-
* acceptance for the scoped historical writer), continuously through close(). This module
|
|
19
|
-
* supplies neither singleton election nor cross-handle CAS, authentication or live fences.
|
|
20
|
-
* Operation history shares this image; it cannot publish files or restore authority.
|
|
21
|
-
* Receipts are process-local capabilities and MUST NOT be persisted.
|
|
22
|
-
* POSIX local-filesystem foundation: the dedicated directory and its ancestors
|
|
23
|
-
* must remain under the caller's exclusive control, with no concurrent mutation.
|
|
24
|
-
* No cleanup/repair is automatic; failed creates may leave a temp or snapshot.
|
|
25
|
-
* Recover reads only snapshot.json; integrity is a checksum, not authentication.
|
|
26
|
-
* Unsupported directory sync semantics fail the open/write, never weaken the ack.
|
|
27
|
-
* A poisoned handle rejects reads too, so stale memory cannot imply rollback.
|
|
28
|
-
* close() drains and releases admission only; record promises carry write errors.
|
|
29
|
-
*
|
|
30
|
-
* @typedef {{ sessionId: string, executionEpoch: number, interrupted: boolean }} Session
|
|
31
|
-
* @typedef {{ resourceId: string, generation: number }} Generation
|
|
32
|
-
* @typedef {{ resourceId: string, owner: string, generation: number, status: 'active'|'user-interrupted'|'pending-confirmation'|'abnormal', reason?: string }} Lock
|
|
33
|
-
* @typedef {{ sessionId: string, requestId: string }} IssuedRequest
|
|
34
|
-
* @typedef {{ sessionId: string, attempts: number, elapsedMs: number, pauseMs: number }} Recovery
|
|
35
|
-
* Retention (design D1) is a session-level budget: one lock batch shares one
|
|
36
|
-
* cumulative allowance, and `holdUntil` is an absolute epoch-ms instant. A held
|
|
37
|
-
* lock keeps its ordinary status, so `holds` is a separate table, never a lock
|
|
38
|
-
* status. Version 3 adds it; a version 2 image is upgraded losslessly on recover
|
|
39
|
-
* (one empty retention row per session). Version 4 preserves v2/v3 historical
|
|
40
|
-
* assertions as inert records; the next durable write uses version 4.
|
|
41
|
-
* @typedef {{ sessionId: string, holding: boolean, holdUntil: number|null, holdCumulativeMs: number }} HoldState
|
|
42
|
-
* @typedef {{ version: 4|5|6, adminRecoveries?: any[], incarnations?: import('./incarnations.js').IncarnationEntry[], managerIncarnation: string|null, sessions: Session[], generations: Generation[], locks: Lock[], issuedRequests: IssuedRequest[], recovery: Recovery[], holds: HoldState[], operations: import('./operation-history.js').Operation[] }} AuthorityImage
|
|
43
|
-
* @typedef {{ revision: number, state: AuthorityImage }} Snapshot
|
|
44
|
-
*/
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* snapshot() and record() return detached historical data, never live authority.
|
|
48
|
-
* record() captures input before yielding; acknowledgement follows directory sync
|
|
49
|
-
* and close. Revisions are local to this handle, not a singleton mechanism.
|
|
50
|
-
* @param {{ directory: string, domainId: string, mode: 'create'|'recover', maxSnapshotBytes?: number }} options
|
|
51
|
-
* @param {{ checkpoint?: (point: string) => void|Promise<void> }} [testing]
|
|
52
|
-
* Test-only syscall barriers surround REAL IO; they never replace filesystem calls.
|
|
53
|
-
*/
|
|
54
|
-
export async function openEditLockStore({ directory, domainId, mode, maxSnapshotBytes }, testing = {}) {
|
|
55
|
-
valid(id(directory) && id(domainId) && (mode === 'create' || mode === 'recover'), 'store options')
|
|
56
|
-
directory = resolve(directory)
|
|
57
|
-
valid((await lstat(directory)).isDirectory(), 'store directory')
|
|
58
|
-
if (mode === 'create') valid((await readdir(directory)).length === 0, 'create requires empty directory')
|
|
59
|
-
const target = join(resolve(directory), 'snapshot.json')
|
|
60
|
-
/** @type {Snapshot} */
|
|
61
|
-
let current
|
|
62
|
-
/** @param {Snapshot} snapshot */
|
|
63
|
-
async function persist(snapshot) {
|
|
64
|
-
const payload = { version: snapshot.state.version, domainId, ...snapshot }
|
|
65
|
-
const body = canonical(payload)
|
|
66
|
-
const bytes = canonical({ payload, checksum: createHash('sha256').update(body).digest('hex') })
|
|
67
|
-
const temporary = join(resolve(directory), `.snapshot-${randomUUID()}.tmp`)
|
|
68
|
-
/** @type {import('node:fs/promises').FileHandle|undefined} */
|
|
69
|
-
let file
|
|
70
|
-
/** @type {import('node:fs/promises').FileHandle|undefined} */
|
|
71
|
-
let dir
|
|
72
|
-
let renameStarted = false
|
|
73
|
-
/** @param {string} step @param {() => Promise<void>} operation */
|
|
74
|
-
async function io(step, operation) {
|
|
75
|
-
await testing.checkpoint?.(`before:${step}`)
|
|
76
|
-
await operation()
|
|
77
|
-
await testing.checkpoint?.(`after:${step}`)
|
|
78
|
-
}
|
|
79
|
-
try {
|
|
80
|
-
await io('temp-open', async () => { file = await open(temporary, 'wx', 0o600) })
|
|
81
|
-
valid(file, 'opened snapshot descriptor')
|
|
82
|
-
const openedFile = file
|
|
83
|
-
await io('write', async () => { await openedFile.writeFile(bytes, 'utf8') })
|
|
84
|
-
await io('file-sync', async () => { await openedFile.sync() })
|
|
85
|
-
await io('file-close', async () => { await openedFile.close(); file = undefined })
|
|
86
|
-
await io('rename', async () => { renameStarted = true; await rename(temporary, target) })
|
|
87
|
-
// The directory steps fire on every platform — checkpoint cadence is a
|
|
88
|
-
// contract; only the real directory fsync is POSIX-only (EPERM on win32,
|
|
89
|
-
// same stance as reservation.js SYNC_SUPPORTED).
|
|
90
|
-
await io('directory-open', async () => { dir = DIR_SYNC_SUPPORTED ? await open(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | (constants.O_NOFOLLOW ?? 0)) : undefined })
|
|
91
|
-
if (DIR_SYNC_SUPPORTED) valid(dir, 'opened directory descriptor')
|
|
92
|
-
const openedDirectory = dir
|
|
93
|
-
await io('directory-sync', async () => { await openedDirectory?.sync() })
|
|
94
|
-
await io('directory-close', async () => { await openedDirectory?.close(); dir = undefined })
|
|
95
|
-
committedBytes = Buffer.from(bytes, 'utf8')
|
|
96
|
-
} catch (cause) {
|
|
97
|
-
// Never remove/promote a temp or roll back a rename. Once rename was
|
|
98
|
-
// attempted its result is conservatively uncertain, including syscall errors.
|
|
99
|
-
const error = Object.assign(new Error('snapshot persistence failed; handle poisoned', { cause }), {
|
|
100
|
-
code: 'EDIT_LOCK_STORE_PERSISTENCE', commitStatus: renameStarted ? 'uncertain' : 'not-renamed',
|
|
101
|
-
})
|
|
102
|
-
// Close any acquired descriptors, but cleanup cannot turn failure into ack.
|
|
103
|
-
await Promise.allSettled([file?.close(), dir?.close()])
|
|
104
|
-
throw error
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
/** The exact committed bytes of the CURRENT snapshot: the pre-image an
|
|
108
|
-
* online administrative commit (design D4) backs up before writing. Set on
|
|
109
|
-
* open and after every successful persist. */
|
|
110
|
-
let committedBytes
|
|
111
|
-
/** The exact committed bytes read at open (recover mode only): the
|
|
112
|
-
* pre-image a composed administrative commit backs up before writing. */
|
|
113
|
-
let recoveredBytes
|
|
114
|
-
if (mode === 'create') {
|
|
115
|
-
current = { revision: 0, state: { version: 4, managerIncarnation: null, sessions: [], generations: [], locks: [], issuedRequests: [], recovery: [], holds: [], operations: [] } }
|
|
116
|
-
await persist(current)
|
|
117
|
-
} else {
|
|
118
|
-
valid((await lstat(target)).isFile(), 'snapshot must be regular file')
|
|
119
|
-
// win32: O_NOFOLLOW/O_NONBLOCK are undefined — the lstat above and the post-open
|
|
120
|
-
// stat below carry the identity checks (same stance as read-authority.js).
|
|
121
|
-
const file = await open(target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
|
|
122
|
-
try {
|
|
123
|
-
valid((await file.stat()).isFile(), 'snapshot must be regular file')
|
|
124
|
-
let bytes
|
|
125
|
-
if (maxSnapshotBytes !== undefined) {
|
|
126
|
-
valid(integer(maxSnapshotBytes, 1), 'snapshot byte limit')
|
|
127
|
-
const stat = await file.stat()
|
|
128
|
-
valid(stat.size <= maxSnapshotBytes, 'snapshot byte limit')
|
|
129
|
-
bytes = Buffer.alloc(stat.size + 1)
|
|
130
|
-
let count = 0
|
|
131
|
-
while (count < bytes.length) {
|
|
132
|
-
const read = await file.read(bytes, count, bytes.length - count, count)
|
|
133
|
-
if (!read.bytesRead) break
|
|
134
|
-
count += read.bytesRead
|
|
135
|
-
}
|
|
136
|
-
valid(count === stat.size, 'snapshot size changed')
|
|
137
|
-
bytes = bytes.subarray(0, count)
|
|
138
|
-
} else bytes = await file.readFile()
|
|
139
|
-
// The byte-level validation (canonical form, version, domain, checksum,
|
|
140
|
-
// lossless upgrades, image invariants) lives in ./snapshot.js, shared
|
|
141
|
-
// verbatim with the read-only maintenance inspector: a snapshot the
|
|
142
|
-
// inspector calls valid is exactly one this recover accepts.
|
|
143
|
-
current = parseSnapshot(bytes, domainId)
|
|
144
|
-
recoveredBytes = Buffer.from(bytes)
|
|
145
|
-
committedBytes = Buffer.from(bytes)
|
|
146
|
-
} finally { await file.close() }
|
|
147
|
-
}
|
|
148
|
-
let tail = Promise.resolve()
|
|
149
|
-
let closed = false
|
|
150
|
-
/** @type {unknown} */
|
|
151
|
-
let poison
|
|
152
|
-
function healthy() { if (poison) throw new Error('store poisoned; recover under exclusive lifecycle', { cause: poison }) }
|
|
153
|
-
/** Process-local evidence is issued only after permanently closing a live attempt. */
|
|
154
|
-
const undispatched = new WeakMap()
|
|
155
|
-
const administrativeInputs = new WeakSet()
|
|
156
|
-
const recoveredKeys = new Set(current.state.operations.map(o => canonical([o.sessionId, o.operationId])))
|
|
157
|
-
const api = {
|
|
158
|
-
snapshot() { healthy(); return structuredClone(current) },
|
|
159
|
-
/** Committed bytes as read at open; undefined for a created store. The
|
|
160
|
-
* caller uses them only as the pre-image backup of an administrative
|
|
161
|
-
* commit in this handle's first record. @returns {Buffer|undefined} */
|
|
162
|
-
recoveredBytes() { healthy(); return recoveredBytes ? Buffer.from(recoveredBytes) : undefined },
|
|
163
|
-
/** Exact bytes of the current committed snapshot, updated after every
|
|
164
|
-
* successful persist. The online administrative transaction (design D4)
|
|
165
|
-
* backs these up as the pre-image of its commit; it never uses the
|
|
166
|
-
* open-time recovered bytes, which may be many revisions behind.
|
|
167
|
-
* @returns {Buffer|undefined} */
|
|
168
|
-
currentBytes() { healthy(); return committedBytes ? Buffer.from(committedBytes) : undefined },
|
|
169
|
-
/** @param {{ expectedRevision: number, nextState: AuthorityImage, base?: AuthorityImage }} input */
|
|
170
|
-
async record(input) {
|
|
171
|
-
if (closed) throw new Error('store closed')
|
|
172
|
-
healthy()
|
|
173
|
-
const proof = undispatched.get(input)
|
|
174
|
-
const administrative = administrativeInputs.delete(input)
|
|
175
|
-
undispatched.delete(input)
|
|
176
|
-
shape(input, administrative && Object.hasOwn(input, 'base') ? ['expectedRevision', 'nextState', 'base'] : ['expectedRevision', 'nextState'])
|
|
177
|
-
const { expectedRevision, nextState } = input
|
|
178
|
-
valid(integer(expectedRevision), 'expected revision')
|
|
179
|
-
validateImage(nextState)
|
|
180
|
-
const state = structuredClone(nextState)
|
|
181
|
-
validateImage(state)
|
|
182
|
-
const pending = tail.then(async () => {
|
|
183
|
-
healthy()
|
|
184
|
-
if (expectedRevision !== current.revision) throw new Error('revision conflict')
|
|
185
|
-
let previous = current.state
|
|
186
|
-
if (proof) {
|
|
187
|
-
const index = previous.operations.findIndex(o => o.sessionId === proof.sessionId && o.operationId === proof.operationId)
|
|
188
|
-
valid(index >= 0 && canonical(previous.operations[index]) === proof.operation, 'undispatched binding')
|
|
189
|
-
previous = structuredClone(previous)
|
|
190
|
-
// Validate the ordinary prepared -> not-published transition, only for
|
|
191
|
-
// the privately proven never-invoked operation. All other invariants remain.
|
|
192
|
-
previous.operations[index].phase = 'prepared'
|
|
193
|
-
previous.operations[index].fence = null
|
|
194
|
-
}
|
|
195
|
-
validateTransition(previous, state)
|
|
196
|
-
const base = administrative && Object.hasOwn(input, 'base') ? input.base : undefined
|
|
197
|
-
if (base !== undefined) {
|
|
198
|
-
// The composed administrative commit (design D3): the supplied base
|
|
199
|
-
// is the ordinary part of the transition and must itself validate.
|
|
200
|
-
validateImage(base)
|
|
201
|
-
validateTransition(previous, base)
|
|
202
|
-
}
|
|
203
|
-
validateAdminTransition(previous, state, administrative, base)
|
|
204
|
-
if (administrative) valid(state.adminRecoveries?.at(-1)?.committedRevision === current.revision + 1, 'administrative revision')
|
|
205
|
-
valid(integer(current.revision + 1), 'revision overflow')
|
|
206
|
-
const next = { revision: current.revision + 1, state }
|
|
207
|
-
try { await persist(next) } catch (error) { poison = error; throw error }
|
|
208
|
-
current = next
|
|
209
|
-
return structuredClone(current)
|
|
210
|
-
})
|
|
211
|
-
tail = pending.then(() => {}, () => {})
|
|
212
|
-
return pending
|
|
213
|
-
},
|
|
214
|
-
/** Trusted offline maintenance seam. Caller owns the publisher reservation,
|
|
215
|
-
* validated durable backup and operator authorization; never exposed to tools.
|
|
216
|
-
* @param {{ expectedRevision: number, nextState: AuthorityImage }} input */
|
|
217
|
-
recordAdministrativeRecovery(input) {
|
|
218
|
-
administrativeInputs.add(input)
|
|
219
|
-
return api.record(input)
|
|
220
|
-
},
|
|
221
|
-
/** Internal manager seam, not a tool API or an authorization check.
|
|
222
|
-
* The caller exclusively owns the original mutation closure and lifecycle.
|
|
223
|
-
* @template T
|
|
224
|
-
* @param {{ expectedRevision: number, nextState: AuthorityImage }} input
|
|
225
|
-
* @param {{sessionId: string, operationId: string}} key
|
|
226
|
-
* @param {() => T} mutation */
|
|
227
|
-
async beginPublication(input, key, mutation) {
|
|
228
|
-
healthy()
|
|
229
|
-
valid(typeof mutation === 'function', 'original mutation')
|
|
230
|
-
const sessionId = key.sessionId, operationId = key.operationId
|
|
231
|
-
const old = current.state.operations.find(o => o.sessionId === sessionId && o.operationId === operationId)
|
|
232
|
-
valid(old?.phase === 'prepared' && !recoveredKeys.has(canonical([sessionId, operationId])), 'live prepared attempt required')
|
|
233
|
-
const next = input.nextState.operations.find(o => o.sessionId === sessionId && o.operationId === operationId)
|
|
234
|
-
valid(next?.phase === 'publishing', 'publishing intent required')
|
|
235
|
-
const saved = await api.record(input)
|
|
236
|
-
const operation = saved.state.operations.find(o => o.sessionId === sessionId && o.operationId === operationId)
|
|
237
|
-
let available = true
|
|
238
|
-
function consume() {
|
|
239
|
-
if (!available) throw new Error('publication attempt closed')
|
|
240
|
-
available = false
|
|
241
|
-
healthy()
|
|
242
|
-
if (closed || current.revision !== saved.revision) throw new Error('publication attempt stale or store closed')
|
|
243
|
-
}
|
|
244
|
-
return Object.freeze({
|
|
245
|
-
invoke() { consume(); return mutation() },
|
|
246
|
-
/** @param {'cancelled-before-dispatch'|'rejected-before-dispatch'} reason */
|
|
247
|
-
async finishWithoutDispatch(reason) {
|
|
248
|
-
consume()
|
|
249
|
-
const state = structuredClone(saved.state)
|
|
250
|
-
const op = state.operations.find(o => o.sessionId === sessionId && o.operationId === operationId)
|
|
251
|
-
if (!op) throw new Error('missing publication')
|
|
252
|
-
op.phase = 'not-published'; op.fence = null
|
|
253
|
-
op.outcome = { kind: 'not-published', reason }
|
|
254
|
-
const settlement = { expectedRevision: saved.revision, nextState: state }
|
|
255
|
-
undispatched.set(settlement, { sessionId, operationId, operation: canonical(operation) })
|
|
256
|
-
return api.record(settlement)
|
|
257
|
-
},
|
|
258
|
-
})
|
|
259
|
-
},
|
|
260
|
-
async close() { closed = true; await tail },
|
|
261
|
-
}
|
|
262
|
-
return Object.freeze(api)
|
|
263
|
-
}
|
|
264
|
-
/** @param {unknown} condition @param {string} message @returns {asserts condition} */
|
|
265
|
-
function valid(condition, message) {
|
|
266
|
-
if (!condition) throw new Error(`invalid ${message}`)
|
|
267
|
-
}
|
|
268
|
-
/** @param {unknown} value @returns {value is string} */
|
|
269
|
-
function id(value) { return typeof value === 'string' && value.length > 0 }
|
|
270
|
-
/** @param {unknown} value @param {number} [minimum] @returns {value is number} */
|
|
271
|
-
function integer(value, minimum = 0) { return typeof value === 'number' && Number.isSafeInteger(value) && !Object.is(value, -0) && value >= minimum }
|
|
272
|
-
/** @param {unknown} value @param {string[]} keys @returns {asserts value is Record<string, unknown>} */
|
|
273
|
-
function shape(value, keys) {
|
|
274
|
-
valid(value !== null && typeof value === 'object' && !Array.isArray(value), 'object')
|
|
275
|
-
valid(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null, 'object prototype')
|
|
276
|
-
valid(Reflect.ownKeys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)), 'schema keys')
|
|
277
|
-
for (const key of keys) {
|
|
278
|
-
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
|
279
|
-
valid(descriptor && 'value' in descriptor && descriptor.enumerable, 'data property')
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/** Storage invariants, not authorization of a kernel transition.
|
|
284
|
-
* Counters are cumulative lifetime charges, never resettable retry policy.
|
|
285
|
-
* @param {AuthorityImage} before @param {AuthorityImage} after */
|
|
286
|
-
function validateTransition(before, after) {
|
|
287
|
-
validateOperationTransitions(before, after)
|
|
288
|
-
valid(before.managerIncarnation === null || after.managerIncarnation !== null, 'incarnation history')
|
|
289
|
-
const sessions = new Map(after.sessions.map(s => [s.sessionId, s]))
|
|
290
|
-
for (const old of before.sessions) {
|
|
291
|
-
const next = sessions.get(old.sessionId)
|
|
292
|
-
valid(next && next.executionEpoch >= old.executionEpoch, 'epoch history')
|
|
293
|
-
if (next.interrupted !== old.interrupted) valid(next.executionEpoch > old.executionEpoch, 'interruption epoch')
|
|
294
|
-
}
|
|
295
|
-
const generations = new Map(after.generations.map(g => [g.resourceId, g.generation]))
|
|
296
|
-
const previousGenerations = new Map(before.generations.map(g => [g.resourceId, g.generation]))
|
|
297
|
-
const previousLocks = new Map(before.locks.map(lock => [lock.resourceId, lock]))
|
|
298
|
-
for (const old of before.generations) valid((generations.get(old.resourceId) ?? 0) >= old.generation, 'generation history')
|
|
299
|
-
for (const lock of after.locks) {
|
|
300
|
-
const old = previousLocks.get(lock.resourceId)
|
|
301
|
-
if (!old || old.owner !== lock.owner) valid(lock.generation > (previousGenerations.get(lock.resourceId) ?? 0), 'new ownership generation')
|
|
302
|
-
if (old?.status === 'abnormal' && old.generation === lock.generation) valid(lock.status === 'abnormal', 'retained abnormal classification')
|
|
303
|
-
}
|
|
304
|
-
const requests = new Set(after.issuedRequests.map(r => canonical([r.sessionId, r.requestId])))
|
|
305
|
-
for (const old of before.issuedRequests) valid(requests.has(canonical([old.sessionId, old.requestId])), 'request tombstone removal')
|
|
306
|
-
const recoveries = new Map(after.recovery.map(r => [r.sessionId, r]))
|
|
307
|
-
for (const old of before.recovery) {
|
|
308
|
-
const next = recoveries.get(old.sessionId)
|
|
309
|
-
valid(next && next.attempts >= old.attempts && next.elapsedMs >= old.elapsedMs && next.pauseMs >= old.pauseMs, 'recovery history')
|
|
310
|
-
}
|
|
311
|
-
// Retention history: the cumulative allowance only ever grows inside a batch.
|
|
312
|
-
// A row may appear or disappear (a batch starts with the first lock and ends
|
|
313
|
-
// with the last), but an existing row never loses allowance, and starting a
|
|
314
|
-
// fresh batch is only legitimate when the session held no lock at all.
|
|
315
|
-
const previousHolds = new Map(before.holds.map(held => [held.sessionId, held]))
|
|
316
|
-
for (const held of after.holds) {
|
|
317
|
-
const old = previousHolds.get(held.sessionId)
|
|
318
|
-
if (old) {
|
|
319
|
-
// The allowance never shrinks inside a batch; it returns to zero only when the
|
|
320
|
-
// batch has ended, i.e. the session holds no lock any more.
|
|
321
|
-
const batchEnded = !after.locks.some(lock => lock.owner === held.sessionId)
|
|
322
|
-
valid(held.holdCumulativeMs >= old.holdCumulativeMs || (batchEnded && held.holdCumulativeMs === 0), 'retention history')
|
|
323
|
-
} else {
|
|
324
|
-
valid(held.holdCumulativeMs === 0 || !before.locks.some(lock => lock.owner === held.sessionId), 'retention batch restart')
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { createManagedWriteTool } from './write-tool.js'
|
|
2
|
-
|
|
3
|
-
/** Install before an agent is published. The composition must already have
|
|
4
|
-
* mounted hash_edit/LSP with THIS service captured; late replacement is unsafe.
|
|
5
|
-
* Only inherited stock tools are supported. Own-scope stock definitions must be
|
|
6
|
-
* disposed by their owning composition before calling this function.
|
|
7
|
-
* @param {any} agent
|
|
8
|
-
* @param {any} ctx
|
|
9
|
-
* @param {any} service
|
|
10
|
-
* @param {any} sandboxPolicy */
|
|
11
|
-
export function installEditLockWriteScope(agent, ctx, service, sandboxPolicy) {
|
|
12
|
-
if (!agent?.ctx?.tools || !service?.publish || !service?.publishBatch) throw new Error('edit lock tool scope requires managed service')
|
|
13
|
-
// Restrictions remain on teardown: unmount must never expose stock writers.
|
|
14
|
-
agent.ctx.tools.restrict({deny:['write','edit']})
|
|
15
|
-
const definition = createManagedWriteTool(ctx, service, sandboxPolicy)
|
|
16
|
-
// Composition guards admit only claimed definitions.
|
|
17
|
-
service.claim?.(definition)
|
|
18
|
-
const dispose = agent.ctx.tools.register(definition)
|
|
19
|
-
if (ctx.tools.get('edit',agent) || ctx.tools.get('write',agent) !== definition) {
|
|
20
|
-
dispose()
|
|
21
|
-
throw new Error('edit lock scope did not exclusively replace stock writers')
|
|
22
|
-
}
|
|
23
|
-
let closed = false
|
|
24
|
-
return () => {
|
|
25
|
-
if (closed) return
|
|
26
|
-
closed = true
|
|
27
|
-
dispose()
|
|
28
|
-
}
|
|
29
|
-
}
|
package/src/edit-lock/view.js
DELETED
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
/** Structured status view for the Edit Lock panel (design D6).
|
|
2
|
-
*
|
|
3
|
-
* The panel derives colour and controls from THIS shape, never from the
|
|
4
|
-
* human-readable command text, so wording can change without silently breaking
|
|
5
|
-
* the panel. Pure: no IO, no clock (the caller supplies `now`), no ctx.
|
|
6
|
-
*
|
|
7
|
-
* The primary view speaks the user's language: what state the session is in,
|
|
8
|
-
* which files it holds (short names), and the one thing to do next. Technical
|
|
9
|
-
* identifiers (session id, execution epoch, generation, absolute paths) are kept
|
|
10
|
-
* in `technical` and per-lock `detail`, for the expandable detail only.
|
|
11
|
-
*/
|
|
12
|
-
import { isAbsolute, relative, sep } from 'node:path'
|
|
13
|
-
import { revokedOwner, settleHoldFacts } from './derived-facts.js'
|
|
14
|
-
|
|
15
|
-
/** Session states the panel renders, in precedence order. */
|
|
16
|
-
export const VIEW_STATES = Object.freeze(['revoked', 'unavailable', 'stopped', 'attention', 'confirm', 'holding', 'editing', 'idle'])
|
|
17
|
-
|
|
18
|
-
/** Short, stable name for a file: relative to the work directory when inside it.
|
|
19
|
-
* @param {string} path @param {string | undefined} cwd */
|
|
20
|
-
export function shortName(path, cwd) {
|
|
21
|
-
if (typeof cwd === 'string' && isAbsolute(path) && isAbsolute(cwd)) {
|
|
22
|
-
const rel = relative(cwd, path)
|
|
23
|
-
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return rel.split(sep).join('/')
|
|
24
|
-
}
|
|
25
|
-
return path
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** The single next action for one row, or null. Own active locks can be released
|
|
29
|
-
* by the human; another session's lock only needs a human when that session can
|
|
30
|
-
* no longer act on it (stopped or abnormal). An actively edited foreign file has
|
|
31
|
-
* no action: the right move is to wait or let the assistant ask for it.
|
|
32
|
-
* @param {any} lock @param {boolean} mine */
|
|
33
|
-
function rowAction(lock, mine) {
|
|
34
|
-
if (mine) {
|
|
35
|
-
if (lock.status === 'pending-confirmation') return 'confirm'
|
|
36
|
-
return 'release'
|
|
37
|
-
}
|
|
38
|
-
if (lock.status === 'abnormal' || lock.status === 'user-interrupted') return 'unlock'
|
|
39
|
-
return null
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* @param {{
|
|
44
|
-
* status: { sessionId: string, state: string, interrupted: boolean|null, revoked?: boolean|null, executionEpoch: number|null, locks: any[], recovery?: any, retention?: any },
|
|
45
|
-
* locks: any[],
|
|
46
|
-
* recoveryState?: { pausedUntil?: number|null }|null,
|
|
47
|
-
* cwd?: string,
|
|
48
|
-
* root?: string,
|
|
49
|
-
* mode?: string,
|
|
50
|
-
* now: number,
|
|
51
|
-
* }} input
|
|
52
|
-
*/
|
|
53
|
-
export function buildView({ status, locks, recoveryState, cwd, root, mode, now }) {
|
|
54
|
-
const own = status.locks ?? []
|
|
55
|
-
const retention = status.retention ?? null
|
|
56
|
-
const recovery = status.recovery ?? null
|
|
57
|
-
const held = Boolean(retention?.held && retention.remainingMs > 0)
|
|
58
|
-
// This is the existing lifecycle restriction, not an inferred driver/timer state.
|
|
59
|
-
const cleanupOnly = status.state === 'recovering' && status.revoked !== true
|
|
60
|
-
// Only the owning live driver's future deadline establishes a pause.
|
|
61
|
-
// Cumulative usage survives expiry and cold reads; it grants no live state.
|
|
62
|
-
const pausedUntil = cleanupOnly && typeof recoveryState?.pausedUntil === 'number'
|
|
63
|
-
&& Number.isFinite(recoveryState.pausedUntil) && recoveryState.pausedUntil > now
|
|
64
|
-
? recoveryState.pausedUntil : null
|
|
65
|
-
/** @type {typeof VIEW_STATES[number]} */
|
|
66
|
-
let state
|
|
67
|
-
// Revoked wins over everything: an administrative revocation is a distinct,
|
|
68
|
-
// durable fact the user must see. It is repairable, not terminal: the panel
|
|
69
|
-
// offers the same continue-editing path as for a stopped session.
|
|
70
|
-
if (status.revoked === true) state = 'revoked'
|
|
71
|
-
else if (status.state === 'stopped' || status.state === 'resuming') state = 'stopped'
|
|
72
|
-
else if (status.state === 'recovering' || own.some(lock => lock.status === 'abnormal')) state = 'attention'
|
|
73
|
-
else if (own.some(lock => lock.status === 'pending-confirmation')) state = 'confirm'
|
|
74
|
-
else if (held) state = 'holding'
|
|
75
|
-
else if (own.length > 0) state = 'editing'
|
|
76
|
-
else state = 'idle'
|
|
77
|
-
|
|
78
|
-
const rows = (locks ?? []).map(lock => {
|
|
79
|
-
const mine = lock.owner === status.sessionId
|
|
80
|
-
return {
|
|
81
|
-
name: shortName(lock.resourceId, cwd),
|
|
82
|
-
mine,
|
|
83
|
-
status: lock.status,
|
|
84
|
-
reason: lock.reason ?? null,
|
|
85
|
-
action: mine && cleanupOnly ? 'release' : rowAction(lock, mine),
|
|
86
|
-
detail: { path: lock.resourceId, owner: lock.owner, generation: lock.generation },
|
|
87
|
-
}
|
|
88
|
-
})
|
|
89
|
-
// Own files first, then files that need a human, then everything else.
|
|
90
|
-
const rank = (/** @type {any} */ row) => (row.mine ? 0 : row.action ? 1 : 2)
|
|
91
|
-
rows.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name))
|
|
92
|
-
|
|
93
|
-
return {
|
|
94
|
-
state,
|
|
95
|
-
cleanupOnly,
|
|
96
|
-
pausedUntil,
|
|
97
|
-
files: rows,
|
|
98
|
-
ownCount: own.length,
|
|
99
|
-
pendingCount: own.filter(lock => lock.status === 'pending-confirmation').length,
|
|
100
|
-
hold: held ? { until: retention.holdUntil, remainingMinutes: Math.ceil(retention.remainingMs / 60_000), usedMinutes: Math.round(retention.holdCumulativeMs / 60_000) } : null,
|
|
101
|
-
// Usage is cumulative history: pauseMs cannot establish a current pause.
|
|
102
|
-
recovery: recovery && (cleanupOnly || recovery.attempts || recovery.elapsedMs || recovery.pauseMs)
|
|
103
|
-
? { attempts: recovery.attempts, elapsedSeconds: Math.round(recovery.elapsedMs / 1000), pauseMinutes: Math.round(recovery.pauseMs / 60_000) }
|
|
104
|
-
: null,
|
|
105
|
-
technical: { sessionId: status.sessionId, executionEpoch: status.executionEpoch, root: root ?? null, mode: mode ?? null, at: now },
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** View for a session the host cannot resolve right now. Without a reason it is
|
|
110
|
-
* still starting; with one, registration failed and the reason is shown, because
|
|
111
|
-
* a permanent "starting" hides a fail-closed workspace. Only a trusted live
|
|
112
|
-
* binding diagnostic can enable connection retry; never editing authority.
|
|
113
|
-
* @param {string | null} [reason] @param {any} [bindingFailure] */
|
|
114
|
-
export function unavailableView(reason = null, bindingFailure = null) {
|
|
115
|
-
return { state: 'unavailable', reason: reason ?? null, ...(bindingFailure ? { bindingFailure } : {}), files: [], ownCount: 0, pendingCount: 0, hold: null, recovery: null, technical: null }
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Cold-session view (design D2): map one validated authority image into the
|
|
120
|
-
* same buildView input a live status read would produce, so a restored session
|
|
121
|
-
* shows its TRUE recorded state — interrupted as stopped, an ADMIN OVERRIDE as
|
|
122
|
-
* repairable revoked (epoch-scoped: the owner is revoked only while its
|
|
123
|
-
* recorded epoch is within the disposition's revokedEpoch), its own locks and
|
|
124
|
-
* the domain's locks, retention settled against the read instant — never a
|
|
125
|
-
* reasonless "starting". The `cold` marker
|
|
126
|
-
* tells the panel to replace live-only actions with activation guidance, and
|
|
127
|
-
* the auto-resume gate travels with the view so the guidance matches the
|
|
128
|
-
* committed setting. Pure like buildView: no IO, no clock, no ctx — the caller
|
|
129
|
-
* supplies the parsed image and `now`.
|
|
130
|
-
* @param {{
|
|
131
|
-
* image: any,
|
|
132
|
-
* sessionId: string,
|
|
133
|
-
* cwd?: string,
|
|
134
|
-
* root?: string,
|
|
135
|
-
* autoResume?: boolean,
|
|
136
|
-
* now: number,
|
|
137
|
-
* }} input
|
|
138
|
-
*/
|
|
139
|
-
export function buildColdView({ image, sessionId, cwd, root, autoResume = true, now }) {
|
|
140
|
-
const sessions = Array.isArray(image?.sessions) ? image.sessions : []
|
|
141
|
-
const locks = (Array.isArray(image?.locks) ? image.locks : []).filter(lock => lock !== null && typeof lock === 'object')
|
|
142
|
-
const holds = Array.isArray(image?.holds) ? image.holds : []
|
|
143
|
-
const recoveries = Array.isArray(image?.recovery) ? image.recovery : []
|
|
144
|
-
const session = sessions.find(row => row?.sessionId === sessionId) ?? null
|
|
145
|
-
// The manager derives the revoked flag from the administrative ledger at
|
|
146
|
-
// status time (revokedOwner); the cold read applies the SAME shared rule in
|
|
147
|
-
// its deliberately tolerant partial mode (design D1).
|
|
148
|
-
const revoked = revokedOwner(image, sessionId, { partial: true })
|
|
149
|
-
// Read-time retention settlement through the shared derived fact (design D5,
|
|
150
|
-
// partial mode — byte-equivalent to the kernel's settleHold): a hold counts
|
|
151
|
-
// only while the session owns a lock, and the remaining budget is computed
|
|
152
|
-
// against the read instant, so a missed expiry timer can never leave stale
|
|
153
|
-
// ownership behind a status read. No hold row settles to no retention (null).
|
|
154
|
-
const held = holds.find(row => row?.sessionId === sessionId) ?? null
|
|
155
|
-
const owns = locks.some(lock => lock.owner === sessionId)
|
|
156
|
-
const retention = held ? settleHoldFacts(sessionId, held, owns, now, { partial: true }) : null
|
|
157
|
-
const status = {
|
|
158
|
-
sessionId,
|
|
159
|
-
// Interrupted records a stopped session (a restarted host interrupts every
|
|
160
|
-
// known session durably). Otherwise the state derives from locks/retention.
|
|
161
|
-
state: session?.interrupted === true ? 'stopped' : 'active',
|
|
162
|
-
interrupted: session?.interrupted ?? null,
|
|
163
|
-
revoked,
|
|
164
|
-
executionEpoch: session?.executionEpoch ?? null,
|
|
165
|
-
locks: locks.filter(lock => lock.owner === sessionId),
|
|
166
|
-
recovery: recoveries.find(row => row?.sessionId === sessionId) ?? null,
|
|
167
|
-
retention,
|
|
168
|
-
}
|
|
169
|
-
return { ...buildView({ status, locks, cwd, root, now }), cold: true, autoResume: autoResume !== false }
|
|
170
|
-
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
// Host-lifetime registry of per-agent managed write scopes (change
|
|
2
|
-
// edit-lock-binding-self-heal, design D3).
|
|
3
|
-
//
|
|
4
|
-
// installEditLockWriteScope registers the managed write tool into the agent's
|
|
5
|
-
// OWN scope layer, and that layer survives a preset rebind: recompose only
|
|
6
|
-
// rebinds the agent's scope parent chain, it never disposes own-layer
|
|
7
|
-
// registrations (dsh-tools view semantics). A later setup — after a preset
|
|
8
|
-
// switch, a bundle re-apply, or a lazy re-bind at the write guard — must
|
|
9
|
-
// therefore dispose the previous generation's registration BEFORE registering,
|
|
10
|
-
// or the scope registry throws duplicate-registration and the re-bind dies in
|
|
11
|
-
// the setup catch (this is what made even a manual re-bind fail). The disposer
|
|
12
|
-
// cannot live in the mount closure: a preset switch orphans the whole mount
|
|
13
|
-
// generation while the agent and its own layer live on. Like the settings
|
|
14
|
-
// evidence store, the container is keyed by host root and never disposed with
|
|
15
|
-
// a mount; entries are keyed by agent and released at agent/disposed.
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* @typedef {{ owner: object, dispose: () => void }} WriteScopeEntry
|
|
19
|
-
* `owner` is the installing mount's service object, so a reader can tell a
|
|
20
|
-
* stale foreign-generation layer apart from its own.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
/** @type {WeakMap<object, WeakMap<object, WriteScopeEntry>>} */
|
|
24
|
-
const scopesByHost = new WeakMap()
|
|
25
|
-
|
|
26
|
-
/** The host's entry store, created on first use. Weak keys isolate hosts and
|
|
27
|
-
* release stopped ones; weak agent keys release disposed agents.
|
|
28
|
-
* @param {object} host @returns {WeakMap<object, WriteScopeEntry>} */
|
|
29
|
-
export function writeScopesFor(host) {
|
|
30
|
-
let scopes = scopesByHost.get(host)
|
|
31
|
-
if (!scopes) {
|
|
32
|
-
scopes = new WeakMap()
|
|
33
|
-
scopesByHost.set(host, scopes)
|
|
34
|
-
}
|
|
35
|
-
return scopes
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Dispose and forget the agent's previous registration, whatever generation
|
|
39
|
-
* installed it: even a same-generation re-entry (a retry after a
|
|
40
|
-
* half-finished setup) must not double-register. A stale layer must never
|
|
41
|
-
* block re-installation, so disposal failures are swallowed.
|
|
42
|
-
* @param {WeakMap<object, WriteScopeEntry>} scopes @param {object} agent */
|
|
43
|
-
export function disposeWriteScope(scopes, agent) {
|
|
44
|
-
const entry = scopes.get(agent)
|
|
45
|
-
if (!entry) return
|
|
46
|
-
scopes.delete(agent)
|
|
47
|
-
try { entry.dispose() } catch { /* disposal is best-effort */ }
|
|
48
|
-
}
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { probeEscalation, resolveCallPolicy } from '../hashline-edit/sandbox.js'
|
|
2
|
-
import { diffResult } from '../hashline-edit/diff.js'
|
|
3
|
-
|
|
4
|
-
/** Build only; the lifecycle mount must hide the inherited stock definition
|
|
5
|
-
* before registering this tool. Never falls back to local mutation.
|
|
6
|
-
* @param {any} ctx @param {{publish: (exec: any, request: any) => Promise<any>}} host
|
|
7
|
-
* @param {any} sandboxPolicy */
|
|
8
|
-
export function createManagedWriteTool(ctx, host, sandboxPolicy) {
|
|
9
|
-
const escalation = probeEscalation(ctx.fs)
|
|
10
|
-
return {
|
|
11
|
-
name: 'write',
|
|
12
|
-
description: 'Create or fully replace a UTF-8 text file through Edit Lock. Read an existing file before overwriting it.',
|
|
13
|
-
parameters: { type: 'object', properties: {
|
|
14
|
-
file_path: { type: 'string', description: 'Path to write, resolved by the filesystem backend.' },
|
|
15
|
-
content: { type: 'string', description: 'Full UTF-8 text content to write.' },
|
|
16
|
-
...(escalation?.fields ?? {}),
|
|
17
|
-
}, required: ['file_path', 'content'] },
|
|
18
|
-
output: {
|
|
19
|
-
schema: { type: 'object' },
|
|
20
|
-
render: (/** @type {any} */ _args, /** @type {any} */ value) => [{ type: 'text', text: `${value.operation === 'create' ? 'Created' : 'Updated'} file: ${value.path}` }],
|
|
21
|
-
presentationMeta: (/** @type {any} */ _args, /** @type {any} */ value) => ({ diffs: value.fragments }),
|
|
22
|
-
},
|
|
23
|
-
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
24
|
-
if (typeof args.file_path !== 'string' || !args.file_path.trim() || typeof args.content !== 'string') throw new Error('write requires file_path and content strings')
|
|
25
|
-
const { policy, resolveCwd } = await resolveCallPolicy(args, {
|
|
26
|
-
escalation, sandboxPolicy, session: exec.agent?.session, sessionCwd: exec.agent?.session?.header?.cwd,
|
|
27
|
-
approval: { approver: ctx.get?.('approval'), agent: exec.agent, toolName: 'write',
|
|
28
|
-
...(exec.callId !== undefined ? {callId: exec.callId} : {}), ...(exec.signal ? {signal: exec.signal} : {}) },
|
|
29
|
-
})
|
|
30
|
-
if (!resolveCwd || !policy) throw new Error('managed write requires trusted cwd and effective policy')
|
|
31
|
-
const target = await ctx.fs.resolve(args.file_path, {cwd: resolveCwd})
|
|
32
|
-
// Keep the stock observation policy's exact guard; do not stat a fresh
|
|
33
|
-
// version and thereby silently authorize an unread overwrite.
|
|
34
|
-
const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)
|
|
35
|
-
if (!intent || !['createIfAbsent', 'replaceIfVersion'].includes(intent.kind)) throw new Error('managed write requires an explicit observation guard')
|
|
36
|
-
const before = intent.kind === 'replaceIfVersion' ? await ctx.fs.readText(target, exec.signal) : null
|
|
37
|
-
const outcome = await host.publish(exec, {tool: 'write', filePath: args.file_path, args, content: args.content,
|
|
38
|
-
expected: intent, cwd: resolveCwd, effectivePolicy: policy})
|
|
39
|
-
if (!outcome || !('version' in outcome)) throw new Error('write publication not acknowledged')
|
|
40
|
-
ctx.emit('fs/observed', target, {kind: 'present', version: outcome.version}, exec)
|
|
41
|
-
const fragments = before === null ? [] : diffResult(target.displayPath, before, args.content).fragments
|
|
42
|
-
return {path: target.displayPath, operation: outcome.kind === 'created' ? 'create' : 'update', before, after: args.content, fragments}
|
|
43
|
-
},
|
|
44
|
-
}
|
|
45
|
-
}
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
// Same-instance bridge: trusted supervision actions, never message provenance.
|
|
2
|
-
// Each Edit Lock mount claims only its own parent composition. No host service
|
|
3
|
-
// or persisted/custom session event is introduced.
|
|
4
|
-
const mounts = new Set()
|
|
5
|
-
|
|
6
|
-
export function registerDelegateEditLifecycle(mount) {
|
|
7
|
-
mounts.add(mount)
|
|
8
|
-
return () => mounts.delete(mount)
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export async function withSupervisedEditResume(parent, childId, deliver) {
|
|
12
|
-
const cleanups = []
|
|
13
|
-
let delivered = false
|
|
14
|
-
try {
|
|
15
|
-
for (const mount of mounts) {
|
|
16
|
-
if (mount.owns(parent)) cleanups.push(await mount.resume(childId))
|
|
17
|
-
}
|
|
18
|
-
const result = await deliver()
|
|
19
|
-
delivered = true
|
|
20
|
-
return result
|
|
21
|
-
} finally {
|
|
22
|
-
// Await rollback before reporting a failed delivery to the coordinator.
|
|
23
|
-
const outcomes = await Promise.allSettled(cleanups.map(cleanup => cleanup?.(delivered)))
|
|
24
|
-
const failed = outcomes.find(outcome => outcome.status === 'rejected')
|
|
25
|
-
if (failed?.status === 'rejected') throw failed.reason
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Joining is not a request to destroy a running agent. Mounts resolve only
|
|
30
|
-
// after a runtime quiescence event and the ordinary disposition acknowledgement.
|
|
31
|
-
// The optional exact handle pins a one-shot waiter to its incarnation.
|
|
32
|
-
export async function settleDelegateEditLifecycle(parent, childId, agent) {
|
|
33
|
-
for (const mount of mounts) {
|
|
34
|
-
if (mount.owns(parent)) await mount.settle(childId, agent)
|
|
35
|
-
}
|
|
36
|
-
}
|