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.
Files changed (89) hide show
  1. package/cordis.patch.yml +7 -18
  2. package/lib/client.file-lease-badge.js +307 -0
  3. package/lib/client.js +20 -30
  4. package/lib/client.settings-page.js +10 -34
  5. package/package.json +2 -2
  6. package/src/capabilities/store/checked-contract.d.ts +2 -2
  7. package/src/capabilities/store/fs-adapter.js +2 -3
  8. package/src/context-guard/index.js +2 -2
  9. package/src/delegate/supervision-mount.js +5 -5
  10. package/src/delegate/tool.js +2 -10
  11. package/src/file-lease/client.js +1066 -0
  12. package/src/file-lease/index.js +30 -0
  13. package/src/file-lease/lineage.js +207 -0
  14. package/src/file-lease/node-contracts.d.ts +42 -0
  15. package/src/file-lease/plugin.js +789 -0
  16. package/src/file-lease/singleton.js +155 -0
  17. package/src/file-lease/spawn.js +62 -0
  18. package/src/file-lease/tools.js +459 -0
  19. package/src/file-lease/view.js +144 -0
  20. package/src/hashline-edit/index.js +76 -42
  21. package/src/hashline-edit/write-tool.js +90 -0
  22. package/src/intent-gate/matcher.js +27 -0
  23. package/src/lease-broker/entry.js +98 -0
  24. package/src/lease-broker/node-contracts.d.ts +63 -0
  25. package/src/lease-broker/protocol.js +236 -0
  26. package/src/lease-broker/server.js +399 -0
  27. package/src/lease-broker/state.js +425 -0
  28. package/src/lsp/admin.js +24 -13
  29. package/src/lsp/index.js +5 -2
  30. package/src/lsp/registry.js +83 -17
  31. package/src/lsp/tools.js +137 -27
  32. package/src/notify/commands.js +15 -2
  33. package/src/notify/index.js +48 -10
  34. package/src/notify/web-channel.js +26 -3
  35. package/src/process/index.js +11 -0
  36. package/src/settings/index.js +2 -44
  37. package/src/settings/sections.js +29 -28
  38. package/src/shared/audit.js +6 -1
  39. package/src/todo-driver/index.js +1 -6
  40. package/src/worktree/cards.js +6 -21
  41. package/src/worktree/lane-core.js +1 -19
  42. package/src/worktree/lane-workflows.js +11 -32
  43. package/lib/client.edit-lock-maintenance.js +0 -592
  44. package/lib/client.edit-lock-panel.js +0 -405
  45. package/src/edit-lock/adapter.js +0 -57
  46. package/src/edit-lock/admin-ledger.js +0 -149
  47. package/src/edit-lock/admin-recovery.js +0 -151
  48. package/src/edit-lock/admission.js +0 -82
  49. package/src/edit-lock/binding-attestation.js +0 -29
  50. package/src/edit-lock/binding-guidance.js +0 -72
  51. package/src/edit-lock/binding-machine.js +0 -312
  52. package/src/edit-lock/call-context.js +0 -65
  53. package/src/edit-lock/derived-facts.js +0 -67
  54. package/src/edit-lock/domain.js +0 -165
  55. package/src/edit-lock/domains.js +0 -170
  56. package/src/edit-lock/host.js +0 -149
  57. package/src/edit-lock/incarnations.js +0 -150
  58. package/src/edit-lock/index.js +0 -1303
  59. package/src/edit-lock/inspect.js +0 -219
  60. package/src/edit-lock/lifecycle.js +0 -579
  61. package/src/edit-lock/limits.js +0 -61
  62. package/src/edit-lock/maintenance.js +0 -310
  63. package/src/edit-lock/manager.js +0 -875
  64. package/src/edit-lock/negotiation.js +0 -132
  65. package/src/edit-lock/operation-history.js +0 -214
  66. package/src/edit-lock/peer-client.js +0 -86
  67. package/src/edit-lock/peer-transport.js +0 -76
  68. package/src/edit-lock/peer.js +0 -87
  69. package/src/edit-lock/publisher.js +0 -191
  70. package/src/edit-lock/read-authority.js +0 -101
  71. package/src/edit-lock/recovery.js +0 -139
  72. package/src/edit-lock/remote.js +0 -362
  73. package/src/edit-lock/request-binding.js +0 -42
  74. package/src/edit-lock/request-data.js +0 -40
  75. package/src/edit-lock/reservation.js +0 -301
  76. package/src/edit-lock/reserved-runtime.js +0 -45
  77. package/src/edit-lock/resource-identity.js +0 -130
  78. package/src/edit-lock/runtime.js +0 -145
  79. package/src/edit-lock/settle.js +0 -144
  80. package/src/edit-lock/snapshot.js +0 -175
  81. package/src/edit-lock/stale-sweep.js +0 -120
  82. package/src/edit-lock/state.js +0 -462
  83. package/src/edit-lock/store.js +0 -327
  84. package/src/edit-lock/tool-scope.js +0 -29
  85. package/src/edit-lock/view.js +0 -170
  86. package/src/edit-lock/write-scopes.js +0 -48
  87. package/src/edit-lock/write-tool.js +0 -45
  88. package/src/shared/delegate-edit-lifecycle.js +0 -36
  89. package/src/worktree/authority.js +0 -56
@@ -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
- }
@@ -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
- }