weir-harness 1.2.3 → 2.1.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.capability-badge.js +2 -0
- package/lib/client.capability-manager.js +46 -179
- package/lib/client.capability-model.js +428 -3
- package/lib/client.file-lease-badge.js +307 -0
- package/lib/client.file-lease-tool-model.js +189 -0
- package/lib/client.file-lease-tool-view.js +434 -0
- package/lib/client.js +124 -36
- package/lib/client.mcp-manager.js +563 -0
- package/lib/client.settings-page.js +10 -34
- package/package.json +2 -2
- package/skills/mcp-server-setup/SKILL.md +124 -0
- package/src/capabilities/apply-engine.js +18 -4
- package/src/capabilities/capability-remote.js +296 -45
- package/src/capabilities/initial-selection.js +3 -2
- package/src/capabilities/lifecycle-preload.js +69 -11
- package/src/capabilities/lifecycle-snapshot.js +127 -39
- package/src/capabilities/mcp-admission.js +2 -2
- package/src/capabilities/mcp-config.js +344 -0
- package/src/capabilities/mcp-credentials.js +180 -0
- package/src/capabilities/mcp-diagnostics.js +169 -0
- package/src/capabilities/mcp-drain.js +18 -4
- package/src/capabilities/mcp-facade.js +3 -3
- package/src/capabilities/mcp-gate.js +51 -52
- package/src/capabilities/mcp-install-tool.js +276 -0
- package/src/capabilities/mcp-manager.js +368 -142
- package/src/capabilities/mcp-mount.js +219 -79
- package/src/capabilities/mcp-operations.js +359 -0
- package/src/capabilities/mcp-process.js +43 -0
- package/src/capabilities/mcp-registry.js +74 -60
- package/src/capabilities/mcp-runtime.js +103 -0
- package/src/capabilities/read-payloads.js +8 -3
- package/src/capabilities/skill-applicability.js +185 -0
- package/src/capabilities/skill-builtin-migration.js +2 -1
- package/src/capabilities/skill-selection-plugin.js +161 -56
- 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 +1299 -0
- package/src/file-lease/index.js +34 -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 +1013 -0
- package/src/file-lease/singleton.js +155 -0
- package/src/file-lease/spawn.js +62 -0
- package/src/file-lease/tools.js +847 -0
- package/src/file-lease/view.js +144 -0
- package/src/file-lease/watch.js +473 -0
- package/src/hashline-edit/index.js +76 -42
- package/src/hashline-edit/write-tool.js +90 -0
- package/src/intent-gate/index.js +29 -12
- package/src/intent-gate/matcher.js +36 -2
- package/src/lease-broker/entry.js +98 -0
- package/src/lease-broker/node-contracts.d.ts +63 -0
- package/src/lease-broker/protocol.js +237 -0
- package/src/lease-broker/server.js +402 -0
- package/src/lease-broker/state.js +482 -0
- package/src/lsp/tools.js +137 -27
- package/src/process/index.js +4 -2
- package/src/settings/index.js +2 -44
- package/src/settings/sections.js +2 -24
- package/src/shared/audit.js +9 -1
- package/src/shared/child-scope.js +2 -0
- 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/src/worktree/rules.js +1 -1
- 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/index.js
DELETED
|
@@ -1,1303 +0,0 @@
|
|
|
1
|
-
// Weir Edit Lock composition. Disabled (zero behavior, no service) unless
|
|
2
|
-
// enabled by the row config or the Weir settings `editLock.enabled` key
|
|
3
|
-
// (read at mount; a change applies after restart). Plain ESM, ctx-only.
|
|
4
|
-
//
|
|
5
|
-
// Domains: each agent binds at creation to the management root of its session
|
|
6
|
-
// cwd (git top level, else the cwd; an enclosing existing authority wins). The
|
|
7
|
-
// authority lives in <root>/.weir/edit-lock and is excluded from git through
|
|
8
|
-
// .git/info/exclude. The first host to reserve a root publishes for it; every
|
|
9
|
-
// other cooperating host becomes its client over a local socket.
|
|
10
|
-
//
|
|
11
|
-
// Order contract: this row MUST precede hashline-edit and lsp. A mis-ordered
|
|
12
|
-
// or unmanaged editor is not trusted: the pre-execute guard denies every
|
|
13
|
-
// write/edit/hash_edit/lsp_rename definition not claimed through the service.
|
|
14
|
-
import { existsSync, mkdirSync, readdirSync, realpathSync } from 'node:fs'
|
|
15
|
-
import { isAbsolute, join } from 'node:path'
|
|
16
|
-
import { openReservedEditLockRuntime } from './reserved-runtime.js'
|
|
17
|
-
import { reservationPathFor } from './reservation.js'
|
|
18
|
-
import { createEditLockLifecycle } from './lifecycle.js'
|
|
19
|
-
import { installEditLockWriteScope } from './tool-scope.js'
|
|
20
|
-
import { writeScopesFor } from './write-scopes.js'
|
|
21
|
-
import { createBindingMachine } from './binding-machine.js'
|
|
22
|
-
import { createResourceIdentity } from './resource-identity.js'
|
|
23
|
-
import { userTextMessage } from '../shared/user-message.js'
|
|
24
|
-
import { isGenuineUserMessage } from '../shared/runtime-messages.js'
|
|
25
|
-
import { createAudit, AUDIT_TYPES } from '../shared/audit.js'
|
|
26
|
-
import { createStaleSweepScheduler } from './stale-sweep.js'
|
|
27
|
-
import { createRemoteEditLockDomain, endpointFor, serveEditLockEndpoint } from './remote.js'
|
|
28
|
-
import { registerBindingRuntime, createBindingAttester } from './binding-attestation.js'
|
|
29
|
-
import { localDomain, remoteDomain } from './domain.js'
|
|
30
|
-
import { AUTHORITY_DIR, createDomainRegistry, excludeFromGit, managementRootFor, sharedDomainRegistryFor } from './domains.js'
|
|
31
|
-
import { createRecoveryDriver } from './recovery.js'
|
|
32
|
-
import { createSettlementDriver } from './settle.js'
|
|
33
|
-
import { editLockLimits } from './limits.js'
|
|
34
|
-
import { buildColdView, buildView, unavailableView } from './view.js'
|
|
35
|
-
import { parseSnapshot } from './snapshot.js'
|
|
36
|
-
import { readSnapshotBytes, errorMessage } from './read-authority.js'
|
|
37
|
-
import { registerDelegateEditLifecycle } from '../shared/delegate-edit-lifecycle.js'
|
|
38
|
-
|
|
39
|
-
const name = 'weir-edit-lock'
|
|
40
|
-
const inject = ['tools', 'fs', 'agents']
|
|
41
|
-
|
|
42
|
-
/** Tool names whose definitions must be Edit Lock routed when enabled. */
|
|
43
|
-
export const GUARDED_TOOLS = Object.freeze(['write', 'edit', 'hash_edit', 'lsp_rename', 'str_replace_editor'])
|
|
44
|
-
|
|
45
|
-
/** Claimed managed-editor executions, shared by every mount of this bundle in
|
|
46
|
-
* the process (change edit-lock-shared-domain-mounts, design D3): membership
|
|
47
|
-
* is execute-object identity, so cross-host admission is impossible and an
|
|
48
|
-
* execution claimed by a disposed mount is harmless — no live guard reads it.
|
|
49
|
-
* Whichever mount's guard stage hears an execution, the verdict is the same.
|
|
50
|
-
* @type {WeakSet<Function>} */
|
|
51
|
-
const managed = new WeakSet()
|
|
52
|
-
|
|
53
|
-
/** Host-lifetime live weirEditLock services (design D3): every mount registers
|
|
54
|
-
* its service at apply and unregisters at dispose, so the endpoint currency
|
|
55
|
-
* proof can accept any live sibling mount's service identity.
|
|
56
|
-
* @type {WeakMap<object, Set<object>>} */
|
|
57
|
-
const liveServicesByHost = new WeakMap()
|
|
58
|
-
/** @param {object} host @returns {Set<object>} */
|
|
59
|
-
function liveServicesFor(host) {
|
|
60
|
-
let services = liveServicesByHost.get(host)
|
|
61
|
-
if (!services) {
|
|
62
|
-
services = new Set()
|
|
63
|
-
liveServicesByHost.set(host, services)
|
|
64
|
-
}
|
|
65
|
-
return services
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Row config wins over the settings section; `root` + `authorityDirectory`
|
|
69
|
-
* pin one fixed domain (development compositions and probes).
|
|
70
|
-
* @param {unknown} config @param {unknown} [settings] */
|
|
71
|
-
export function editLockOptions(config, settings) {
|
|
72
|
-
const value = { .../** @type {any} */ (settings ?? {}), .../** @type {any} */ (config ?? {}) }
|
|
73
|
-
if (value.enabled !== true) return null
|
|
74
|
-
if (value.root === undefined && value.authorityDirectory === undefined) return { fixed: null }
|
|
75
|
-
for (const key of ['root', 'authorityDirectory']) {
|
|
76
|
-
if (typeof value[key] !== 'string' || !isAbsolute(value[key])) throw new Error(`edit lock: ${key} must be an absolute path`)
|
|
77
|
-
}
|
|
78
|
-
return { fixed: { root: value.root, directory: value.authorityDirectory } }
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Never initialize over unknown content: empty → create, committed snapshot → recover.
|
|
82
|
-
* @param {string} directory @returns {'create'|'recover'} */
|
|
83
|
-
export function storeMode(directory) {
|
|
84
|
-
if (!existsSync(directory)) mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
85
|
-
const entries = readdirSync(directory)
|
|
86
|
-
if (entries.length === 0) return 'create'
|
|
87
|
-
if (entries.includes('snapshot.json')) return 'recover'
|
|
88
|
-
throw new Error(`edit lock: authority directory ${directory} has no committed snapshot and is not empty`)
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** @param {any} agent */
|
|
92
|
-
function sessionOf(agent) {
|
|
93
|
-
return typeof agent?.id === 'string' && agent.id ? agent.id : undefined
|
|
94
|
-
}
|
|
95
|
-
/** Confirm every pending-confirmation lock of one session. Each file goes
|
|
96
|
-
* through the ordinary per-file check, so a file that does not qualify is simply
|
|
97
|
-
* skipped; the bulk path can never widen authority. @param {any} domain
|
|
98
|
-
* @param {any} agent @param {{resourceId: string}[]} pending */
|
|
99
|
-
async function confirmAll(domain, agent, pending) {
|
|
100
|
-
const confirmed = []
|
|
101
|
-
for (const lock of pending) {
|
|
102
|
-
try {
|
|
103
|
-
await domain.confirm(agent, lock.resourceId)
|
|
104
|
-
confirmed.push(lock.resourceId)
|
|
105
|
-
} catch { /* not confirmable any more; the next status shows why */ }
|
|
106
|
-
}
|
|
107
|
-
return confirmed
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Message-driven auto-resume procedure (design D2/D3/D4): the trusted resume
|
|
111
|
-
* runs with a fresh server-minted requestId (never a commandId), then the
|
|
112
|
-
* ordinary per-file confirmation replays over the retained locks — exactly the
|
|
113
|
-
* panel's Continue editing sequence, so it never widens authority. A zero-lock
|
|
114
|
-
* session confirms the empty set. Any failure (a revoked session, a lost race
|
|
115
|
-
* with the manual Continue) only warns; edits stay denied and the panel keeps
|
|
116
|
-
* showing the state. @param {any} domain @param {any} agent
|
|
117
|
-
* @param {(message: string) => void} [warn] */
|
|
118
|
-
async function autoResume(domain, agent, warn) {
|
|
119
|
-
try {
|
|
120
|
-
const status = await domain.resume(agent, `auto:user-message:${crypto.randomUUID()}`)
|
|
121
|
-
const pending = status.locks.filter(/** @param {any} lock */ lock => lock.status === 'pending-confirmation')
|
|
122
|
-
await confirmAll(domain, agent, pending)
|
|
123
|
-
} catch (error) {
|
|
124
|
-
warn?.(`edit lock auto-resume failed: ${/** @type {any} */ (error)?.message ?? error}`)
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** @param {any} domain @param {any} agent @param {string} raw @param {string} commandId @param {{holdDefaultMinutes: number, holdSingleMaxMinutes: number, holdCumulativeMaxMinutes: number}} limits */
|
|
129
|
-
async function runCommand(domain, agent, raw, commandId, limits) {
|
|
130
|
-
const [verb, ...rest] = raw.trim().split(/\s+/u)
|
|
131
|
-
if (!verb || verb === 'status') return describe(await domain.status(agent))
|
|
132
|
-
if (verb === 'stop') {
|
|
133
|
-
const status = await domain.stop(agent)
|
|
134
|
-
return `Edit authority durably revoked for this session.\n${describe(status)}`
|
|
135
|
-
}
|
|
136
|
-
if (verb === 'resume') {
|
|
137
|
-
const status = await domain.resume(agent, `command:${commandId}`)
|
|
138
|
-
const pending = status.locks.filter(/** @param {any} lock */ lock => lock.status === 'pending-confirmation')
|
|
139
|
-
// Resume restores authority only; retained files still need an explicit
|
|
140
|
-
// confirmation (the panel's Continue editing runs resume then confirm --all).
|
|
141
|
-
return `Edit authority resumed with a new execution epoch.${pending.length ? ` ${pending.length} retained file(s) wait for confirmation: /edit-lock confirm --all, or /edit-lock confirm <path> for one.` : ''}\n${describe(status)}`
|
|
142
|
-
}
|
|
143
|
-
if (verb === 'hold') {
|
|
144
|
-
const minutes = rest.length > 0 && rest[0] !== '' ? Number(rest[0]) : limits.holdDefaultMinutes
|
|
145
|
-
if (!Number.isFinite(minutes) || minutes <= 0) throw new Error('Usage: /edit-lock hold [minutes]')
|
|
146
|
-
const held = await domain.hold(agent, Math.round(minutes * 60_000), { singleMaxMs: limits.holdSingleMaxMinutes * 60_000, cumulativeMaxMs: limits.holdCumulativeMaxMinutes * 60_000 })
|
|
147
|
-
return `Keeping ${held.lockCount} file(s) until ${new Date(held.holdUntil).toLocaleTimeString()} (${Math.round(held.holdCumulativeMs / 60_000)} of ${limits.holdCumulativeMaxMinutes} minutes used in this batch).\n${describe(await domain.status(agent))}`
|
|
148
|
-
}
|
|
149
|
-
if (verb === 'confirm') {
|
|
150
|
-
// `--all` only replays the per-file confirmation over files that already
|
|
151
|
-
// qualify; it never widens authority (spec: One-step confirmation).
|
|
152
|
-
if (rest.length === 1 && rest[0] === '--all') {
|
|
153
|
-
const status = await domain.status(agent)
|
|
154
|
-
const pending = status.locks.filter(/** @param {any} lock */ lock => lock.status === 'pending-confirmation')
|
|
155
|
-
const confirmed = await confirmAll(domain, agent, pending)
|
|
156
|
-
return `Confirmed ${confirmed.length} of ${pending.length} retained file(s).\n${describe(await domain.status(agent))}`
|
|
157
|
-
}
|
|
158
|
-
const path = rest.join(' ')
|
|
159
|
-
if (!path) throw new Error('Usage: /edit-lock confirm <path> | /edit-lock confirm --all')
|
|
160
|
-
const cwd = agent?.session?.header?.cwd
|
|
161
|
-
if (typeof cwd !== 'string') throw new Error('session cwd unavailable')
|
|
162
|
-
const observation = createResourceIdentity().resolve(path, { cwd })
|
|
163
|
-
if (observation.kind !== 'file') throw new Error('confirm requires an existing regular file')
|
|
164
|
-
const status = await domain.confirm(agent, observation.resourceId)
|
|
165
|
-
return `Confirmed ${observation.resourceId}.\n${describe(status)}`
|
|
166
|
-
}
|
|
167
|
-
if (verb === 'release') {
|
|
168
|
-
// Human release of one of this session's own files. It only removes
|
|
169
|
-
// authority, so it stays available while stopped or recovering.
|
|
170
|
-
const path = rest.join(' ')
|
|
171
|
-
if (!path) throw new Error('Usage: /edit-lock release <path>')
|
|
172
|
-
const cwd = agent?.session?.header?.cwd
|
|
173
|
-
if (typeof cwd !== 'string') throw new Error('session cwd unavailable')
|
|
174
|
-
const own = (await domain.status(agent)).locks.find((/** @type {any} */ lock) => lock.resourceId === path)
|
|
175
|
-
const result = await domain.service.release({ agent }, { filePath: own ? own.resourceId : path, cwd })
|
|
176
|
-
return `Released ${result.resourceId}.\n${describe(await domain.status(agent))}`
|
|
177
|
-
}
|
|
178
|
-
if (verb === 'locks') {
|
|
179
|
-
const locks = await domain.locks(agent)
|
|
180
|
-
return locks.length ? locks.map((/** @type {any} */ lock) => `- ${lock.resourceId} owner=${lock.owner} [${lock.status}${lock.reason ? `: ${lock.reason}` : ''}] generation ${lock.generation}`).join('\n') : 'No Edit Lock ownership is held.'
|
|
181
|
-
}
|
|
182
|
-
if (verb === 'unlock') {
|
|
183
|
-
const generation = Number(rest.at(-1))
|
|
184
|
-
const path = rest.slice(0, -1).join(' ')
|
|
185
|
-
if (!path || !Number.isSafeInteger(generation)) throw new Error('Usage: /edit-lock unlock <path> <generation> (see /edit-lock locks)')
|
|
186
|
-
// An exact listed resource id also works for files that no longer exist.
|
|
187
|
-
let resourceId = (await domain.locks(agent)).find((/** @type {any} */ lock) => lock.resourceId === path)?.resourceId
|
|
188
|
-
if (!resourceId) {
|
|
189
|
-
const cwd = agent?.session?.header?.cwd
|
|
190
|
-
if (typeof cwd !== 'string') throw new Error('session cwd unavailable')
|
|
191
|
-
const observation = createResourceIdentity().resolve(path, { cwd })
|
|
192
|
-
if (observation.kind !== 'file') throw new Error('unlock requires an existing regular file or an exact locked resource id')
|
|
193
|
-
resourceId = observation.resourceId
|
|
194
|
-
}
|
|
195
|
-
try {
|
|
196
|
-
const result = await domain.unlock(agent, resourceId, generation)
|
|
197
|
-
return `Unlocked ${result.resourceId} (generation ${result.generation}, owner ${result.owner}). Content was not validated.`
|
|
198
|
-
} catch (error) {
|
|
199
|
-
// Actionable guidance (recovery UX D4): a fence refusal must name the
|
|
200
|
-
// resolution path, never leave a bare error code. The guidance is
|
|
201
|
-
// template-layer copy and stays English per the text discipline.
|
|
202
|
-
if (/** @type {any} */ (error)?.admissionRefusal === true) {
|
|
203
|
-
throw Object.assign(new Error(`${/** @type {any} */ (error).message}. Settle it with the administrative recovery in the Edit Lock maintenance panel (Settings → Edit Lock maintenance): it clears the unresolved publication fence and releases the owner's locks in one step.`), { admissionRefusal: true })
|
|
204
|
-
}
|
|
205
|
-
throw error
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
throw new Error('Usage: /edit-lock [status|locks|hold [minutes]|release <path>|stop|resume|confirm <path>|confirm --all|unlock <path> <generation>]')
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/** Ordinary owner tools. Ownership derives from exec.agent, never arguments.
|
|
212
|
-
* @param {any} ctx @param {any} service */
|
|
213
|
-
function registerLockTools(ctx, service) {
|
|
214
|
-
const filePath = { file_path: { type: 'string', description: 'Existing file path, resolved against the session working directory.' } }
|
|
215
|
-
/** @param {any} args @param {any} exec */
|
|
216
|
-
const request = (args, exec) => {
|
|
217
|
-
if (typeof args?.file_path !== 'string' || !args.file_path.trim()) throw new Error('file_path must be a non-empty string')
|
|
218
|
-
const cwd = exec.agent?.session?.header?.cwd
|
|
219
|
-
if (typeof cwd !== 'string') throw new Error('session working directory unavailable')
|
|
220
|
-
return { filePath: args.file_path, cwd }
|
|
221
|
-
}
|
|
222
|
-
const text = (/** @type {string} */ value) => ({ schema: { type: 'object' }, render: (/** @type {any} */ _args, /** @type {any} */ result) => [{ type: 'text', text: result[value] }] })
|
|
223
|
-
return [
|
|
224
|
-
ctx.tools.register({
|
|
225
|
-
name: 'edit_lock_acquire',
|
|
226
|
-
description: 'Acquire Edit Lock ownership of one existing file before editing it. Edits also acquire implicitly; call this explicitly to reserve a file, and for every file listed as pending-confirmation after a resume. Fails if another session owns the file.',
|
|
227
|
-
parameters: { type: 'object', properties: filePath, required: ['file_path'] },
|
|
228
|
-
output: text('message'),
|
|
229
|
-
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
230
|
-
const result = await service.acquire(exec, request(args, exec))
|
|
231
|
-
return { ...result, message: `Acquired ${result.resourceId} (generation ${result.generation}).` }
|
|
232
|
-
},
|
|
233
|
-
}),
|
|
234
|
-
ctx.tools.register({
|
|
235
|
-
name: 'edit_lock_release',
|
|
236
|
-
description: 'Release this session\'s Edit Lock ownership of one file so other sessions can edit it. Release when you are done editing a file. Releasing does not validate the file content.',
|
|
237
|
-
parameters: { type: 'object', properties: filePath, required: ['file_path'] },
|
|
238
|
-
output: text('message'),
|
|
239
|
-
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
240
|
-
const result = await service.release(exec, request(args, exec))
|
|
241
|
-
return { ...result, message: `Released ${result.resourceId}.` }
|
|
242
|
-
},
|
|
243
|
-
}),
|
|
244
|
-
ctx.tools.register({
|
|
245
|
-
name: 'edit_lock_hold',
|
|
246
|
-
description: 'Ask to keep this session\'s Edit Lock ownership for a bounded time counted from now, when you still need the files after this turn ends. The maximum per request and the budget for the whole batch are configurable; once the budget is used up you can only release. Keeping a file never grants permission you did not already have, and no file can be kept permanently.',
|
|
247
|
-
parameters: { type: 'object', properties: {
|
|
248
|
-
minutes: { type: 'number', description: 'How long to keep the files, counted from now. Omit to use the configured default.' },
|
|
249
|
-
}, required: [] },
|
|
250
|
-
output: text('message'),
|
|
251
|
-
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
252
|
-
const minutes = args?.minutes === undefined ? undefined : Number(args.minutes)
|
|
253
|
-
if (minutes !== undefined && (!Number.isFinite(minutes) || minutes <= 0)) throw new Error('minutes must be a positive number')
|
|
254
|
-
const result = await service.hold(exec, minutes === undefined ? undefined : Math.round(minutes * 60_000))
|
|
255
|
-
return { ...result, message: `Keeping ${result.lockCount} file(s) until ${new Date(result.holdUntil).toISOString()}; ${Math.round(result.holdCumulativeMs / 60_000)} of ${result.cumulativeMaxMinutes} minutes used in this batch.` }
|
|
256
|
-
},
|
|
257
|
-
}),
|
|
258
|
-
ctx.tools.register({
|
|
259
|
-
name: 'edit_lock_try_steal',
|
|
260
|
-
description: 'Ask the session that owns a file to hand Edit Lock ownership over. Returns a pending request_id immediately; it never waits for the holder. The holder decides at a safe point; no reply within the negotiation window keeps their ownership. You are notified of the outcome; check edit_lock_status.',
|
|
261
|
-
parameters: { type: 'object', properties: filePath, required: ['file_path'] },
|
|
262
|
-
output: text('message'),
|
|
263
|
-
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
264
|
-
const result = await service.trySteal(exec, request(args, exec))
|
|
265
|
-
return { ...result, message: `Request ${result.requestId} is pending with holder ${result.holder} (${result.holderStatus}). Do not edit the file until ownership is granted.` }
|
|
266
|
-
},
|
|
267
|
-
}),
|
|
268
|
-
ctx.tools.register({
|
|
269
|
-
name: 'edit_lock_pause',
|
|
270
|
-
description: 'Only during Edit Lock cleanup-only recovery: ask to keep your abnormal locks while an external problem clears (for example a provider outage). Up to 15 minutes per pause and 30 minutes in total; expiry re-checks the situation, it never releases or extends the locks.',
|
|
271
|
-
parameters: { type: 'object', properties: { minutes: { type: 'number', description: 'Pause length in minutes (at most 15).' } }, required: ['minutes'] },
|
|
272
|
-
output: text('message'),
|
|
273
|
-
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
274
|
-
const result = await service.pause(exec, Number(args?.minutes))
|
|
275
|
-
return { ...result, message: `Paused for ${Math.round(result.pausedMs / 60_000)} minute(s); ${Math.round(result.cumulativePauseMs / 60_000)} of 30 cumulative minutes used.` }
|
|
276
|
-
},
|
|
277
|
-
}),
|
|
278
|
-
ctx.tools.register({
|
|
279
|
-
name: 'edit_lock_status',
|
|
280
|
-
description: 'List Edit Lock ownership in this work directory: file, owner session, status and reservation. Read-only; grants nothing.',
|
|
281
|
-
parameters: { type: 'object', properties: {}, required: [] },
|
|
282
|
-
output: text('message'),
|
|
283
|
-
async execute(/** @type {any} */ _args, /** @type {any} */ exec) {
|
|
284
|
-
const unavailable = service.startupFailure(exec.agent)
|
|
285
|
-
if (unavailable) return { ...unavailable, message: `Edit Lock unavailable: ${unavailable.reason}. ${statusRetryHint(unavailable.bindingFailure)}` }
|
|
286
|
-
const locks = await service.locks(exec)
|
|
287
|
-
const lines = locks.map((/** @type {any} */ lock) => `- ${lock.resourceId} owner=${lock.mine ? 'this session' : lock.owner} status=${lock.status} generation=${lock.generation}${lock.reason ? ` reason=${lock.reason}` : ''}`)
|
|
288
|
-
// The reservation is part of what the holder needs to know: without it the
|
|
289
|
-
// answer looks identical before and after a successful keep, which invites
|
|
290
|
-
// asking again instead of carrying on with the work.
|
|
291
|
-
const retention = await service.retention(exec)
|
|
292
|
-
if (retention?.held && retention.remainingMs > 0) {
|
|
293
|
-
lines.push(`Reserved for ${Math.ceil(retention.remainingMs / 60_000)} more minute(s); ${Math.round(retention.holdCumulativeMs / 60_000)} minute(s) used in this batch.`)
|
|
294
|
-
}
|
|
295
|
-
return { locks, retention, message: lines.length ? lines.join('\n') : 'No Edit Lock ownership is held.' }
|
|
296
|
-
},
|
|
297
|
-
}),
|
|
298
|
-
]
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** @param {any} service */
|
|
302
|
-
function replyToolDefinition(service) {
|
|
303
|
-
return {
|
|
304
|
-
name: 'edit_lock_reply',
|
|
305
|
-
description: 'Answer a pending Edit Lock ownership request addressed to this session. decision "release" hands the file to the requester (your ownership ends); "keep" retains it. Only the current holder execution can answer; stale or expired requests are refused.',
|
|
306
|
-
parameters: { type: 'object', properties: {
|
|
307
|
-
request_id: { type: 'string', description: 'request_id from the ownership request notice.' },
|
|
308
|
-
decision: { type: 'string', enum: ['release', 'keep'], description: 'release to hand over, keep to retain.' },
|
|
309
|
-
}, required: ['request_id', 'decision'] },
|
|
310
|
-
output: { schema: { type: 'object' }, render: (/** @type {any} */ _args, /** @type {any} */ result) => [{ type: 'text', text: `Request ${result.requestId}: ${result.state}.` }] },
|
|
311
|
-
async execute(/** @type {any} */ args, /** @type {any} */ exec) {
|
|
312
|
-
if (typeof args?.request_id !== 'string') throw new Error('request_id must be a string')
|
|
313
|
-
return service.reply(exec, { requestId: args.request_id, decision: args.decision })
|
|
314
|
-
},
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/** @param {any} status */
|
|
319
|
-
function describe(status) {
|
|
320
|
-
const lines = [`Edit Lock session ${status.sessionId}: ${status.state}${status.interrupted ? ' (interrupted)' : ''}, epoch ${status.executionEpoch ?? '-'}`]
|
|
321
|
-
if (status.revoked) lines.push('Editing authority was revoked by an administrative recovery and its locks were released. Continue editing or send any message to restore editing in a new execution epoch; the revocation record is preserved.')
|
|
322
|
-
for (const lock of status.locks) lines.push(`- ${lock.resourceId} [${lock.status}${lock.reason ? `: ${lock.reason}` : ''}] generation ${lock.generation}`)
|
|
323
|
-
if (status.locks.length === 0) lines.push('- no locks held')
|
|
324
|
-
const recovery = status.recovery
|
|
325
|
-
if (recovery && (recovery.attempts || recovery.elapsedMs || recovery.pauseMs)) {
|
|
326
|
-
lines.push(`Recovery: ${recovery.attempts}/3 attempts, ${Math.round(recovery.elapsedMs / 1000)}s of 300s, pause ${Math.round(recovery.pauseMs / 60_000)}/30 min used`)
|
|
327
|
-
}
|
|
328
|
-
// Retention is reported as the one thing the user needs from it: how long the
|
|
329
|
-
// files stay reserved. The raw budget belongs to the structured view, not here.
|
|
330
|
-
const retention = status.retention
|
|
331
|
-
if (retention?.held && retention.remainingMs > 0) {
|
|
332
|
-
lines.push(`Reserved for ${Math.ceil(retention.remainingMs / 60_000)} more minute(s); ${Math.round(retention.holdCumulativeMs / 60_000)} minute(s) used in this batch.`)
|
|
333
|
-
}
|
|
334
|
-
return lines.join('\n')
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// The binding-failure notice copy moved with the binding state machine to
|
|
338
|
-
// binding-machine.js (change edit-lock-deepening, design D3) — it renders only
|
|
339
|
-
// inside setupAgentOnce's failure record.
|
|
340
|
-
|
|
341
|
-
/** Read-only binding-unavailable guidance tail for the tool/service surfaces
|
|
342
|
-
* (domainFor and edit_lock_status), split by the recorded holder class.
|
|
343
|
-
* @param {string} failure @param {any} bindingFailure */
|
|
344
|
-
function bindingUnavailableMessage(failure, bindingFailure) {
|
|
345
|
-
const kind = bindingFailure?.holderKind
|
|
346
|
-
if (kind === 'transferable') return `Edit Lock binding is unavailable (${failure}). The holder is an inactive attachment of this same session: the binding transfers automatically when the user next sends a message, and /edit-lock retry also takes it over; neither resumes editing by itself. Do not retry the failed edit.`
|
|
347
|
-
if (kind === 'serving') return `Edit Lock binding is unavailable (${failure}). This session is being edited in another attachment right now; retry the connection once that turn finishes. Do not retry the failed edit.`
|
|
348
|
-
return `Edit Lock binding is unavailable (${failure}). Do not retry the failed edit: the binding is retried automatically when the user next sends a message, after which editing works normally. Never remove a live publisher reservation.`
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/** edit_lock_status read-only guidance tail, split by the recorded holder
|
|
352
|
-
* class (change edit-lock-binding-transfer). @param {any} bindingFailure */
|
|
353
|
-
function statusRetryHint(bindingFailure) {
|
|
354
|
-
if (bindingFailure?.retryable !== true) return 'Inspect Edit Lock maintenance before continuing.'
|
|
355
|
-
if (bindingFailure.holderKind === 'transferable') return 'The holder is an inactive attachment of this same session: editing attaches automatically when you next send a message, and /edit-lock retry also takes it over; neither resumes editing by itself.'
|
|
356
|
-
if (bindingFailure.holderKind === 'serving') return 'This session is being edited in another attachment right now; /edit-lock retry retries the connection once that turn finishes — it does not resume editing by itself.'
|
|
357
|
-
return 'Do not retry failed edits: the binding is retried automatically when the user next sends a message, and /edit-lock retry also retries the connection; neither resumes editing by itself.'
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** Composition dependencies, not persisted settings. Defaults are the host paths.
|
|
361
|
-
* @param {{ platform?: string, resolveRoot?: typeof managementRootFor, endpoint?: typeof endpointFor, exclude?: typeof excludeFromGit }} [dependencies] */
|
|
362
|
-
export function createEditLockPlugin({ platform = process.platform, resolveRoot = managementRootFor, endpoint = endpointFor, exclude = excludeFromGit } = {}) {
|
|
363
|
-
/** Arrow on purpose: cordis constructs prototype-bearing callbacks with `new`
|
|
364
|
-
* and then drops their returned disposer, which would leak the reservation.
|
|
365
|
-
* @param {any} ctx @param {unknown} config */
|
|
366
|
-
return (ctx, config = {}) => {
|
|
367
|
-
const settings = ctx.get?.('weirSettings')
|
|
368
|
-
const options = editLockOptions(config, settings?.get?.('editLock'))
|
|
369
|
-
// A generation-owned reporter; a later mount cannot overwrite this row.
|
|
370
|
-
const evidence = settings?.editLockEvidence?.recordMount?.({
|
|
371
|
-
enabled: Boolean(options), pinned: Object.hasOwn(config ?? {}, 'enabled'),
|
|
372
|
-
fixed: options?.fixed != null, phase: options ? 'installing' : 'installed',
|
|
373
|
-
})
|
|
374
|
-
if (!options) return evidence ? () => evidence.dispose() : undefined
|
|
375
|
-
// Resource identity is the narrowest supported envelope (Darwin/Linux/Win32;
|
|
376
|
-
// each has a local endpoint). Its factory performs no filesystem operations.
|
|
377
|
-
// Keep the provider mounted on failure: absence would select unlocked editors.
|
|
378
|
-
// This mount-time decision never follows a later saved-off setting.
|
|
379
|
-
let platformFailure = null
|
|
380
|
-
try { createResourceIdentity({ platform }) } catch (error) {
|
|
381
|
-
platformFailure = Object.assign(new Error(`Edit Lock ${errorMessage(error)}; managed editing is unavailable. Supported platforms: darwin, linux, win32.`), { code: 'weir-edit-lock/unsupported-platform' })
|
|
382
|
-
evidence?.failed(platformFailure)
|
|
383
|
-
ctx.logger?.warn?.(platformFailure.message)
|
|
384
|
-
}
|
|
385
|
-
/** The shared-registry handle, assigned inside the mount try so a failed
|
|
386
|
-
* apply can release its count (design D1: a failed mount enters no count).
|
|
387
|
-
* @type {any} */
|
|
388
|
-
let shared = null
|
|
389
|
-
try {
|
|
390
|
-
/** @type {any} */
|
|
391
|
-
let sandboxPolicyRef = null
|
|
392
|
-
ctx.inject?.(['sandboxPolicy'], (/** @type {any} */ scope) => { sandboxPolicyRef = scope.sandboxPolicy })
|
|
393
|
-
// Capture the original host filesystem once; the manager is its only writer.
|
|
394
|
-
const fs = ctx.fs
|
|
395
|
-
const audit = createAudit(ctx)
|
|
396
|
-
// The host-lifetime key shared by the write-scope store, the domain registry
|
|
397
|
-
// container and the live-service set (design D1/D3) — identical to the key
|
|
398
|
-
// expression the settings evidence store uses.
|
|
399
|
-
const host = ctx.root ?? ctx
|
|
400
|
-
const liveServices = liveServicesFor(host)
|
|
401
|
-
/** Successful undefined means no live runtime; missing service means unknown.
|
|
402
|
-
* @param {string} sessionId */
|
|
403
|
-
const servingRuntime = sessionId => {
|
|
404
|
-
const agents = ctx.get?.('agents')
|
|
405
|
-
if (typeof agents?.get !== 'function') throw new Error('agent registry unavailable')
|
|
406
|
-
return agents.get(sessionId)
|
|
407
|
-
}
|
|
408
|
-
const attestBinding = createBindingAttester(servingRuntime)
|
|
409
|
-
/** Detached stale-lock sweeps, coalesced per management-domain ROOT with a
|
|
410
|
-
* single in-flight sweep and a 60s cooldown (see stale-sweep.js). */
|
|
411
|
-
const sweeps = createStaleSweepScheduler({
|
|
412
|
-
warn: message => ctx.logger?.warn?.(message),
|
|
413
|
-
setTimer: fn => (safeSetTimer(ctx) ?? globalThis.setTimeout)(fn, 0),
|
|
414
|
-
})
|
|
415
|
-
// The binding state machine's stores (disposedAgents, startFailures,
|
|
416
|
-
// bindingFailures, setupInFlight, boundAgents, dispositions,
|
|
417
|
-
// agentDispositions) live in binding-machine.js and arrive destructured
|
|
418
|
-
// below (change edit-lock-deepening, design D3).
|
|
419
|
-
/** One queued automatic-recovery summary notice per management root (design
|
|
420
|
-
* D3): delivered to the first agent that finishes domain start, then gone.
|
|
421
|
-
* Shared across sibling mounts with the domain registry (change
|
|
422
|
-
* edit-lock-shared-domain-mounts): the domain opens through the first
|
|
423
|
-
* mount's closure, so the queue must live in the shared entry for the
|
|
424
|
-
* mount that hears the first post-recovery turn to drain it. Assigned
|
|
425
|
-
* with `shared` below. @type {Map<string, string>} */
|
|
426
|
-
let recoveryNotices
|
|
427
|
-
/** Managed write registrations outlive this mount: the tool is installed
|
|
428
|
-
* into the agent's OWN scope layer, which a preset rebind never disposes.
|
|
429
|
-
* Their disposers live in a host-lifetime store so any later generation can
|
|
430
|
-
* retire the stale layer before re-installing (design D3); entries are
|
|
431
|
-
* released at agent/disposed. Unmount deliberately leaves them in place —
|
|
432
|
-
* tearing the managed write down mid-gap would expose the stock writers
|
|
433
|
-
* until the next mount's per-agent re-install.
|
|
434
|
-
* @type {WeakMap<object, import('./write-scopes.js').WriteScopeEntry>} */
|
|
435
|
-
const writeScopes = writeScopesFor(host)
|
|
436
|
-
let closed = false
|
|
437
|
-
/** Remote channel agents on the publisher side → their notice sink.
|
|
438
|
-
* @type {WeakMap<object, (event: unknown) => void>} */
|
|
439
|
-
const sinks = new WeakMap()
|
|
440
|
-
/** Settled domain per bound agent, for synchronous gates. Shared across
|
|
441
|
-
* sibling mounts with the domain registry (design D3): whichever mount
|
|
442
|
-
* hears an event reads the same binding. Assigned with `shared` below.
|
|
443
|
-
* @type {WeakMap<object, any>} */
|
|
444
|
-
let settled
|
|
445
|
-
/** `wake` is used only for an ownership request addressed to an idle holder:
|
|
446
|
-
* inject alone would sit unread until the holder's next user message, which is
|
|
447
|
-
* how a request expired unanswered. Interrupted holders are never woken.
|
|
448
|
-
* @param {any} agent @param {string} text @param {boolean} [wake] */
|
|
449
|
-
const deliverLocal = (agent, text, wake = false) => {
|
|
450
|
-
const message = userTextMessage(text, 'weir-edit-lock')
|
|
451
|
-
if (wake && agent?.status === 'idle' && agent?.followup) agent.followup(message)
|
|
452
|
-
else agent.inject(message)
|
|
453
|
-
}
|
|
454
|
-
/** @param {any} agent @param {any} event */
|
|
455
|
-
function deliverEvent(agent, event) {
|
|
456
|
-
if (event?.kind === 'notice' && typeof event.text === 'string') deliverLocal(agent, event.text, event.wake === true)
|
|
457
|
-
if (event?.kind === 'pending' && Number.isSafeInteger(event.count)) syncReplyTool(agent, event.count)
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/** @param {string} root @param {string} directory
|
|
461
|
-
* @param {string} [noticeKey] registry key agents are bound to (differs from
|
|
462
|
-
* `root` in fixed-domain compositions: the registry key is the resolved
|
|
463
|
-
* management root of the session cwd) */
|
|
464
|
-
async function openDomain(root, directory, noticeKey = root) {
|
|
465
|
-
try {
|
|
466
|
-
if (!existsSync(directory)) mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
467
|
-
let runtime
|
|
468
|
-
try {
|
|
469
|
-
// The store mode is read only after the reservation is held.
|
|
470
|
-
runtime = await openReservedEditLockRuntime({ root, directory, domainId: root, mode: () => storeMode(directory), fs })
|
|
471
|
-
// Dead-process unknowns settled in the recovery commit (design D3):
|
|
472
|
-
// one shared audit per recovery, and at most one summary notice
|
|
473
|
-
// queued for the first agent that starts a turn in this domain.
|
|
474
|
-
const settledRecovery = runtime.automaticRecovery
|
|
475
|
-
if (settledRecovery?.records?.length) {
|
|
476
|
-
const settledOperations = settledRecovery.records.reduce((count, record) => count + record.operations.length, 0)
|
|
477
|
-
audit(null, AUDIT_TYPES.editLockMaintenance, {
|
|
478
|
-
kind: 'automatic-recovery', root, revision: settledRecovery.revision,
|
|
479
|
-
owners: settledRecovery.records.map(record => record.owner),
|
|
480
|
-
recoveryIds: settledRecovery.records.map(record => record.recoveryId),
|
|
481
|
-
operations: settledOperations,
|
|
482
|
-
}, { root })
|
|
483
|
-
recoveryNotices.set(noticeKey, `Edit Lock: ${settledOperations} unresolved publication(s) left behind by a previous, crashed process were settled automatically while opening this workspace. Their outcomes stay unknown in history; the blocked files are editable again. Details are in the administrative ledger and the maintenance audit.`)
|
|
484
|
-
}
|
|
485
|
-
} catch (error) {
|
|
486
|
-
if (/** @type {any} */ (error)?.code !== 'EEXIST') throw error
|
|
487
|
-
// Another cooperating host publishes: become its client, never a writer.
|
|
488
|
-
const domain = remoteDomain(createRemoteEditLockDomain(endpoint(directory), {
|
|
489
|
-
onNotice: deliverEvent,
|
|
490
|
-
onChannelOpen: registerBindingRuntime,
|
|
491
|
-
// The only way to reach here without a live publisher is a reservation left
|
|
492
|
-
// by a host that crashed or was killed. Say so, and say what clears it.
|
|
493
|
-
unreachableHint: `Another DeepSeek Harness reserved this project but is not answering. If no other Harness window is open on it, quit DeepSeek Harness, remove ${reservationPathFor(directory)}, and start it again.`,
|
|
494
|
-
}))
|
|
495
|
-
try { evidence?.recordDomain?.(root, { mode: domain.mode }) } catch { /* reporting only */ }
|
|
496
|
-
return domain
|
|
497
|
-
}
|
|
498
|
-
const lifecycle = createEditLockLifecycle(runtime, sessionOf, {
|
|
499
|
-
servingRuntime,
|
|
500
|
-
// Bounded failure logging for the sweep: one warning per failed row
|
|
501
|
-
// (skips stay quiet), routed to the host logger.
|
|
502
|
-
warn: message => ctx.logger?.warn?.(message),
|
|
503
|
-
deliver: (agent, text, wake) => { const sink = sinks.get(agent); if (sink) sink({ kind: 'notice', text, wake: wake === true }); else deliverLocal(agent, text, wake) },
|
|
504
|
-
onPending: (agent, count) => { const sink = sinks.get(agent); if (sink) sink({ kind: 'pending', count }); else syncReplyTool(agent, count) },
|
|
505
|
-
// Stale-lock sweep audit: publisher-side, after each durable release;
|
|
506
|
-
// the JSONL mirror anchors at the management root. Never a session
|
|
507
|
-
// event, never a conversation write (AGENTS §3.6).
|
|
508
|
-
onStaleRelease: (row, triggerSessionId) => audit(null, AUDIT_TYPES.editLockMaintenance, {
|
|
509
|
-
kind: 'stale-sweep', root, trigger: triggerSessionId ?? null, owner: row.owner, resourceId: row.resourceId, generation: row.generation,
|
|
510
|
-
}, { root }),
|
|
511
|
-
// Online administrative recovery audit (design D4): publisher-side,
|
|
512
|
-
// after the durable commit; the trigger session comes from the
|
|
513
|
-
// channel/local settings plane, never from the request payload.
|
|
514
|
-
onAdminRecovery: info => audit(null, AUDIT_TYPES.editLockMaintenance, {
|
|
515
|
-
kind: 'admin-override-online', root, trigger: info.trigger ?? null, owner: info.record.owner,
|
|
516
|
-
recoveryId: info.record.recoveryId, revision: info.revision, idempotent: info.idempotent,
|
|
517
|
-
operations: info.record.operations.length,
|
|
518
|
-
}, { root }),
|
|
519
|
-
// Revocation repair audit: publisher-side, after the durable resume
|
|
520
|
-
// commit advanced the owner's epoch past its revokedEpoch. The ledger
|
|
521
|
-
// record itself never changes; the repair is a new-epoch event.
|
|
522
|
-
onRevocationRepair: info => audit(null, AUDIT_TYPES.editLockMaintenance, {
|
|
523
|
-
kind: 'revocation-repaired', root, owner: info.owner, trigger: info.trigger,
|
|
524
|
-
fromEpoch: info.fromEpoch ?? null, toEpoch: info.toEpoch,
|
|
525
|
-
}, { root })
|
|
526
|
-
})
|
|
527
|
-
let server
|
|
528
|
-
try { server = await serveEditLockEndpoint(lifecycle, endpoint(directory), sinks, attestBinding,
|
|
529
|
-
(agent, requireProof, transfer = false) => {
|
|
530
|
-
if (closed || disposedAgents.has(agent)) return false
|
|
531
|
-
const agents = ctx.get?.('agents')
|
|
532
|
-
if (typeof agents?.get === 'function') {
|
|
533
|
-
if (transfer === true) {
|
|
534
|
-
// A transfer claimant is NEVER the registry's serving agent —
|
|
535
|
-
// that mismatch is the defect being repaired (change
|
|
536
|
-
// edit-lock-binding-transfer). Its liveness proof is listing
|
|
537
|
-
// among the live root agents; holder-side admission is the
|
|
538
|
-
// lifecycle's transferStart.
|
|
539
|
-
if (typeof agents.roots !== 'function' || !agents.roots().includes(agent)) return false
|
|
540
|
-
} else if (agents.get(agent.id) !== agent) return false
|
|
541
|
-
} else if (requireProof) return false
|
|
542
|
-
// Fresh creation keeps the established missing/throwing-registry
|
|
543
|
-
// fallback. A reclaim or retry never uses that fallback as proof.
|
|
544
|
-
// Currency proof (design D3): the agent's resolved service must be a
|
|
545
|
-
// LIVE service of this process — after a preset switch that is the
|
|
546
|
-
// sibling mount's service, never this mount's own necessarily.
|
|
547
|
-
if (typeof presets?.serviceFor === 'function') {
|
|
548
|
-
try { return liveServices.has(presets.serviceFor(agent, 'weirEditLock')) }
|
|
549
|
-
catch { return !requireProof }
|
|
550
|
-
}
|
|
551
|
-
return true
|
|
552
|
-
}) }
|
|
553
|
-
catch (error) { ctx.logger?.warn?.(`edit lock endpoint unavailable; other hosts fail closed: ${/** @type {any} */ (error)?.message ?? error}`) }
|
|
554
|
-
const domain = localDomain(lifecycle, server)
|
|
555
|
-
try { evidence?.recordDomain?.(root, { mode: domain.mode, bindings: () => [...lifecycle.bindings(), ...(server?.bindings() ?? [])] }) } catch { /* reporting only */ }
|
|
556
|
-
return domain
|
|
557
|
-
} catch (error) {
|
|
558
|
-
// A domain that never opened is maintenance-visible as initialization
|
|
559
|
-
// blocked; the manager behavior is unchanged.
|
|
560
|
-
try { evidence?.recordDomain?.(root, { error: String(/** @type {any} */ (error)?.message ?? error) }) } catch { /* reporting only */ }
|
|
561
|
-
throw error
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
// One domain registry per host + effective domain configuration, shared by
|
|
565
|
-
// every same-process preset mount of this bundle (design D1): a sibling
|
|
566
|
-
// mount never degrades to a same-process remote client, and the LAST
|
|
567
|
-
// disposer closes all domains through the established drain discipline. The
|
|
568
|
-
// first mount's `open` closure serves every sharing mount — mounts are
|
|
569
|
-
// process-persistent, so the capture is safe (design D1 trade-off).
|
|
570
|
-
shared = sharedDomainRegistryFor(host, options.fixed ? `${options.fixed.root}\n${options.fixed.directory}` : '', () => createDomainRegistry(root => {
|
|
571
|
-
const fixed = options.fixed
|
|
572
|
-
if (fixed) return openDomain(fixed.root, fixed.directory, root)
|
|
573
|
-
try { exclude(root) } catch (error) { ctx.logger?.warn?.(`edit lock: could not update .git/info/exclude: ${/** @type {any} */ (error)?.message ?? error}`) }
|
|
574
|
-
return openDomain(root, join(root, AUTHORITY_DIR), root)
|
|
575
|
-
}, resolveRoot))
|
|
576
|
-
const registry = shared.registry
|
|
577
|
-
settled = shared.settled
|
|
578
|
-
recoveryNotices = shared.recoveryNotices
|
|
579
|
-
/** @param {any} agent */
|
|
580
|
-
const domainOf = agent => registry.forAgent(agent)
|
|
581
|
-
/** Lazy re-bind (design D2): an own-preset main agent with no binding —
|
|
582
|
-
* orphaned by a preset switch in the blank window — gets ONE idempotent
|
|
583
|
-
* re-setup through the standard path before the call is refused. A recorded
|
|
584
|
-
* failure suppresses retries for the rest of this mount generation, and
|
|
585
|
-
* foreign-preset agents are never tried (the own-preset fence is unchanged).
|
|
586
|
-
* `ownAgent`/`setupAgent` are declared below and only dereferenced when a
|
|
587
|
-
* call actually arrives. @param {any} agent */
|
|
588
|
-
async function domainFor(agent) {
|
|
589
|
-
if (platformFailure) throw platformFailure
|
|
590
|
-
if (!registry.rootOf(agent) && !closed && !startFailures.has(agent) && (agent?.session?.header?.delegationDepth ?? 0) === 0 && ownAgent(agent)) await setupAgent(agent)
|
|
591
|
-
await setupInFlight.get(agent)
|
|
592
|
-
if (settled.has(agent)) return settled.get(agent)
|
|
593
|
-
const failure = startFailures.get(agent)
|
|
594
|
-
if (failure && bindingFailures.has(agent)) throw new Error(bindingUnavailableMessage(failure, bindingFailures.get(agent)))
|
|
595
|
-
if (failure) throw new Error(`this session has no Edit Lock domain: setup failed under the current preset generation (${failure}); the automatic re-bind was already attempted. Recovery: restart DeepSeek Harness or start a new conversation.`)
|
|
596
|
-
// No fence match (foreign preset) or unmount: keep the stock refusal.
|
|
597
|
-
return registry.forAgent(agent)
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
/** The holder's structured reply tool. It is registered on the first request
|
|
601
|
-
* and retired at a turn boundary once no request is pending: unregistering it
|
|
602
|
-
* mid-turn made an expired-but-retried reply report "unknown tool" instead of
|
|
603
|
-
* the real reason.
|
|
604
|
-
* @type {WeakMap<object, {dispose: () => void, stale: boolean}>} */
|
|
605
|
-
const replyTools = new WeakMap()
|
|
606
|
-
/** @param {any} agent @param {number} pending */
|
|
607
|
-
function syncReplyTool(agent, pending) {
|
|
608
|
-
const registered = replyTools.get(agent)
|
|
609
|
-
if (pending > 0 && !registered && agent?.ctx?.tools?.register) {
|
|
610
|
-
replyTools.set(agent, { dispose: agent.ctx.tools.register(replyToolDefinition(service)), stale: false })
|
|
611
|
-
} else if (registered) {
|
|
612
|
-
registered.stale = pending === 0
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
/** @param {any} agent */
|
|
616
|
-
function retireReplyTool(agent) {
|
|
617
|
-
const registered = replyTools.get(agent)
|
|
618
|
-
if (!registered?.stale) return
|
|
619
|
-
replyTools.delete(agent)
|
|
620
|
-
registered.dispose()
|
|
621
|
-
}
|
|
622
|
-
/** @param {string} method */
|
|
623
|
-
const route = method => async (/** @type {any} */ exec, /** @type {any} */ request) => /** @type {any} */ ((await domainFor(exec.agent)).service)[method](exec, request)
|
|
624
|
-
const recovery = createRecoveryDriver({
|
|
625
|
-
domainFor: agent => settled.get(agent),
|
|
626
|
-
followup: (agent, text) => { if (!disposedAgents.has(agent)) /** @type {any} */ (agent).followup(userTextMessage(text, 'weir-edit-lock-recovery')) },
|
|
627
|
-
notify: deliverLocal,
|
|
628
|
-
warn: message => ctx.logger?.warn?.(message),
|
|
629
|
-
})
|
|
630
|
-
/** Retention policy is resolved once per read from the settings section, so a
|
|
631
|
-
* committed change takes effect without a restart and the kernel stays free of
|
|
632
|
-
* configuration. @returns {any} */
|
|
633
|
-
const strictLimits = () => editLockLimits(ctx.get?.('weirSettings')?.get?.('editLock'))
|
|
634
|
-
/** An incoherent saved combination (each field is validated on its own, so e.g.
|
|
635
|
-
* a single cap below the default can be saved) must never switch off settling,
|
|
636
|
-
* status, release, stop or unlock. Those read the built-in defaults instead and
|
|
637
|
-
* warn; only a retention request is refused, with the named reason, because
|
|
638
|
-
* that is the one decision the bad setting is about. */
|
|
639
|
-
let warnedLimits = ''
|
|
640
|
-
const limits = () => {
|
|
641
|
-
try { return strictLimits() } catch (error) {
|
|
642
|
-
const message = String(/** @type {any} */ (error)?.message ?? error)
|
|
643
|
-
if (warnedLimits !== message) { warnedLimits = message; ctx.logger?.warn?.(`edit lock settings ignored, using defaults: ${message}`) }
|
|
644
|
-
// Fall back for the retention fields only. The notice count and the
|
|
645
|
-
// disposition keep their saved values: a bad cap must never turn a team's
|
|
646
|
-
// "flag for a human" choice into a silent release.
|
|
647
|
-
const section = /** @type {any} */ (ctx.get?.('weirSettings')?.get?.('editLock')) ?? {}
|
|
648
|
-
let nudge
|
|
649
|
-
try { nudge = editLockLimits({ nudgeAttempts: section.nudgeAttempts, nudgeFallback: section.nudgeFallback }) } catch { nudge = editLockLimits(undefined) }
|
|
650
|
-
return { ...editLockLimits(undefined), nudgeAttempts: nudge.nudgeAttempts, nudgeFallback: nudge.nudgeFallback, holdUnavailable: true }
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
/** Turn-end settling: a finished turn that left locks behind is continued once,
|
|
654
|
-
* asking for each file to be released or kept explicitly (design D2/D3). Only
|
|
655
|
-
* `completed` enters here; the error and stop paths keep their own handling. */
|
|
656
|
-
const settle = createSettlementDriver({
|
|
657
|
-
domainFor: agent => settled.get(agent),
|
|
658
|
-
followup: (agent, text) => { if (!disposedAgents.has(agent)) /** @type {any} */ (agent).followup(userTextMessage(text, 'weir-edit-lock-settle')) },
|
|
659
|
-
notify: deliverLocal,
|
|
660
|
-
limits,
|
|
661
|
-
warn: message => ctx.logger?.warn?.(message),
|
|
662
|
-
})
|
|
663
|
-
/** Expiry timers, keyed per agent. Read-time settlement is authoritative, so a
|
|
664
|
-
* lost timer (suspend, sleep) only delays the release, never the decision.
|
|
665
|
-
* @type {WeakMap<object, {timer?: any}>} */
|
|
666
|
-
const expiry = new WeakMap()
|
|
667
|
-
/** A genuine user message asked for the stopped session's automatic resume.
|
|
668
|
-
* Boolean, consumed once by the next turn's first pre-step (message-driven
|
|
669
|
-
* auto-resume D1/D6); WeakMap keys die with their agent. @type {WeakMap<object, boolean>} */
|
|
670
|
-
const pendingAutoResume = new WeakMap()
|
|
671
|
-
const delegateResumes = new Map()
|
|
672
|
-
const turnClassifications = new WeakMap()
|
|
673
|
-
// The per-agent binding state machine (change edit-lock-deepening, design
|
|
674
|
-
// D3): setup/retry/dispose/startup-diagnostic plus the binding stores,
|
|
675
|
-
// shared by every entry point below. The remaining wiring reads the same
|
|
676
|
-
// container objects through these destructured aliases.
|
|
677
|
-
const {
|
|
678
|
-
disposedAgents, startFailures, bindingFailures, setupInFlight,
|
|
679
|
-
boundAgents, dispositions, agentDispositions,
|
|
680
|
-
setupAgent, retryBinding, disposeAgent, startupFailure,
|
|
681
|
-
} = createBindingMachine({
|
|
682
|
-
ctx, sessionOf, GUARDED_TOOLS, options, registry, settled, writeScopes,
|
|
683
|
-
domainOf, servingRuntime,
|
|
684
|
-
// Late-bound wiring (ownAgent and the service object are declared below;
|
|
685
|
-
// the machine dereferences them only when an entry point actually calls).
|
|
686
|
-
ownAgent: (/** @type {any} */ agent) => ownAgent(agent),
|
|
687
|
-
installWriteScope: (/** @type {any} */ agent) => ({ owner: service, dispose: installEditLockWriteScope(agent, ctx, service, sandboxPolicyRef) }),
|
|
688
|
-
evidence, audit, deliverLocal,
|
|
689
|
-
pendingAutoResume, delegateResumes, turnClassifications, replyTools, expiry, settle,
|
|
690
|
-
isClosed: () => closed, platformFailure: () => platformFailure,
|
|
691
|
-
})
|
|
692
|
-
/** @param {any} agent @param {any} domain */
|
|
693
|
-
function armExpiry(agent, domain) {
|
|
694
|
-
const state = expiry.get(agent) ?? {}
|
|
695
|
-
expiry.set(agent, state)
|
|
696
|
-
if (state.timer) clearTimeout(state.timer)
|
|
697
|
-
state.timer = undefined
|
|
698
|
-
// Awaited: a cross-process client answers retention asynchronously, and a
|
|
699
|
-
// synchronous read there never armed the timer at all.
|
|
700
|
-
void Promise.resolve(domain.retention?.(agent)).then(retention => {
|
|
701
|
-
if (!retention?.held || !(retention.remainingMs > 0)) return
|
|
702
|
-
// Read the host timer through a guarded probe, not a bare member access: an
|
|
703
|
-
// unavailable cordis service throws on access rather than returning undefined.
|
|
704
|
-
const setTimer = safeSetTimer(ctx) ?? globalThis.setTimeout
|
|
705
|
-
state.timer = setTimer(() => {
|
|
706
|
-
state.timer = undefined
|
|
707
|
-
// Busy at expiry: the turn-end handler releases it once that turn ends.
|
|
708
|
-
void Promise.resolve(domain.settleExpired(agent)).then(result => {
|
|
709
|
-
if (!result?.released?.length) return
|
|
710
|
-
settle.settled(agent)
|
|
711
|
-
deliverLocal(agent, `Edit Lock: the reservation ended, so ${result.released.length} file(s) are available to other sessions.`)
|
|
712
|
-
}).catch((/** @type {any} */ error) => ctx.logger?.warn?.(`edit lock expiry release failed: ${error?.message ?? error}`))
|
|
713
|
-
}, Math.min(retention.remainingMs, 2 ** 31 - 1))
|
|
714
|
-
state.timer?.unref?.()
|
|
715
|
-
}).catch((/** @type {any} */ error) => ctx.logger?.warn?.(`edit lock expiry arm failed: ${error?.message ?? error}`))
|
|
716
|
-
}
|
|
717
|
-
const service = Object.freeze({
|
|
718
|
-
startupFailure,
|
|
719
|
-
publish: route('publish'),
|
|
720
|
-
publishBatch: route('publishBatch'),
|
|
721
|
-
acquire: route('acquire'),
|
|
722
|
-
release: route('release'),
|
|
723
|
-
/** @param {any} exec */
|
|
724
|
-
async locks(exec) { return (await domainFor(exec.agent)).service.locks(exec) },
|
|
725
|
-
trySteal: route('trySteal'),
|
|
726
|
-
reply: route('reply'),
|
|
727
|
-
/** @param {any} exec @param {number} minutes */
|
|
728
|
-
pause(exec, minutes) { return recovery.pause(exec.agent, minutes) },
|
|
729
|
-
/** Ask to keep this session's locks after the turn ends, for a bounded time.
|
|
730
|
-
* Retention only extends ownership; it grants nothing new (spec: Explicit
|
|
731
|
-
* bounded retention). @param {any} exec @param {number} ms */
|
|
732
|
-
async hold(exec, ms) {
|
|
733
|
-
const policy = strictLimits()
|
|
734
|
-
if (ms === undefined) ms = Math.round(policy.holdDefaultMinutes * 60_000)
|
|
735
|
-
const result = await (await domainFor(exec.agent)).hold(exec.agent, ms, {
|
|
736
|
-
singleMaxMs: policy.holdSingleMaxMinutes * 60_000,
|
|
737
|
-
cumulativeMaxMs: policy.holdCumulativeMaxMinutes * 60_000,
|
|
738
|
-
})
|
|
739
|
-
armExpiry(exec.agent, settled.get(exec.agent))
|
|
740
|
-
return { ...result, cumulativeMaxMinutes: policy.holdCumulativeMaxMinutes }
|
|
741
|
-
},
|
|
742
|
-
/** Retention state for this session, settled against now. Resolved through the
|
|
743
|
-
* domain registry — the same path the lock list takes — rather than through the
|
|
744
|
-
* per-agent binding, so a read-only observation works from any tool context.
|
|
745
|
-
* @param {any} exec */
|
|
746
|
-
async retention(exec) {
|
|
747
|
-
const domain = await domainFor(exec.agent)
|
|
748
|
-
return domain.retention(exec.agent)
|
|
749
|
-
},
|
|
750
|
-
/** Mount-time handshake from a managed editor. @param {object} definition */
|
|
751
|
-
claim(definition) {
|
|
752
|
-
const execute = /** @type {any} */ (definition)?.execute
|
|
753
|
-
if (typeof execute === 'function') managed.add(execute)
|
|
754
|
-
},
|
|
755
|
-
/** Continuation gate: an interrupted or recovering session is never
|
|
756
|
-
* re-armed implicitly. Unknown/unavailable state also blocks. @param {any} agent */
|
|
757
|
-
blocksContinuation(agent) {
|
|
758
|
-
const domain = settled.get(agent)
|
|
759
|
-
return !domain || domain.blocks(agent)
|
|
760
|
-
},
|
|
761
|
-
/** Trusted UI observation for one agent; grants nothing. @param {any} agent */
|
|
762
|
-
async describe(agent) {
|
|
763
|
-
const failure = startupFailure(agent)
|
|
764
|
-
if (failure) throw Object.assign(new Error(failure.reason), { bindingFailure: failure.bindingFailure })
|
|
765
|
-
const domain = await domainFor(agent)
|
|
766
|
-
return { root: registry.rootOf(agent), mode: domain.mode, status: await domain.status(agent), locks: await domain.locks(agent), recovery: recovery.state(agent) }
|
|
767
|
-
},
|
|
768
|
-
})
|
|
769
|
-
ctx.reflect.provide('weirEditLock', service)
|
|
770
|
-
// Any live sibling mount's service identity attests endpoint currency
|
|
771
|
-
// (design D3); unregister at dispose.
|
|
772
|
-
liveServices.add(service)
|
|
773
|
-
|
|
774
|
-
const offTools = registerLockTools(ctx, service)
|
|
775
|
-
|
|
776
|
-
ctx.on('tools/pre-execute', async (/** @type {any} */ exec, /** @type {() => Promise<any>} */ next) => {
|
|
777
|
-
if (!GUARDED_TOOLS.includes(exec?.name)) return next()
|
|
778
|
-
if (platformFailure) return { kind: 'deny', reason: platformFailure.message, info: { name: 'EditLockPlatformError', code: platformFailure.code } }
|
|
779
|
-
const admitted = () => {
|
|
780
|
-
const definition = ctx.tools.get(exec.name, exec.agent)
|
|
781
|
-
return typeof definition?.execute === 'function' && managed.has(definition.execute)
|
|
782
|
-
}
|
|
783
|
-
if (admitted()) return next()
|
|
784
|
-
// Lazy re-bind fallback (design D2): an own-preset agent whose binding was
|
|
785
|
-
// orphaned by a blank-window preset switch gets ONE idempotent re-setup
|
|
786
|
-
// before denial. A recorded failure suppresses further retries for the
|
|
787
|
-
// rest of this mount generation; foreign agents are never tried.
|
|
788
|
-
const agent = exec?.agent
|
|
789
|
-
if (!closed && agent && !registry.rootOf(agent) && !startFailures.has(agent) && (agent?.session?.header?.delegationDepth ?? 0) === 0 && ownAgent(agent)) {
|
|
790
|
-
await setupAgent(agent)
|
|
791
|
-
// The lazy re-bind is a driving ingress (design D1, change
|
|
792
|
-
// edit-lock-binding-transfer): when the re-setup just recorded a
|
|
793
|
-
// duplicate-binding failure, ONE transfer-capable retry runs here — an
|
|
794
|
-
// idle same-process holder hands the binding over mid-turn. The recorded
|
|
795
|
-
// failure then suppresses further attempts for the rest of this mount
|
|
796
|
-
// generation, so arbitrary write calls never loop retries.
|
|
797
|
-
if (bindingFailures.get(agent)?.code === 'binding-duplicate') {
|
|
798
|
-
try { await retryBinding(agent, 'write-guard') } catch { /* audited inside retryBinding; the denial below names the recorded cause */ }
|
|
799
|
-
}
|
|
800
|
-
if (admitted()) return next()
|
|
801
|
-
}
|
|
802
|
-
const failure = /** @type {any} */ (agent) ? startFailures.get(agent) : undefined
|
|
803
|
-
if (failure) return { kind: 'deny', reason: `${exec.name} refused: this session has no Edit Lock domain under the current preset generation (setup failed: ${failure}); an automatic re-bind was attempted and did not recover it. Recovery: restart DeepSeek Harness or start a new conversation.` }
|
|
804
|
-
return { kind: 'deny', reason: `${exec.name} is not routed through Edit Lock in this composition; unmanaged file mutation refused.` }
|
|
805
|
-
})
|
|
806
|
-
// The binding stores (boundAgents, dispositions, agentDispositions) and
|
|
807
|
-
// dispositionFor moved into binding-machine.js; delegateResumes and
|
|
808
|
-
// turnClassifications stay composition-owned and are declared above with it.
|
|
809
|
-
// setupAgentOnce / setupAgent / retryBinding moved into binding-machine.js
|
|
810
|
-
// (change edit-lock-deepening, design D3) — they arrive destructured above.
|
|
811
|
-
ctx.on('agent/created', (/** @type {any} */ { agent }) => setupAgent(agent))
|
|
812
|
-
|
|
813
|
-
// Late-mount catch-up (the same mount-order hazard the worktree-mode guard
|
|
814
|
-
// documents): after an application restart the preset mounts FOR the first
|
|
815
|
-
// session's main agent, so that agent's agent/created event is already past
|
|
816
|
-
// and the listener above never sees it. Bind every existing root agent now,
|
|
817
|
-
// through the identical setup; sub-agents are always created after mount and
|
|
818
|
-
// need no catch-up. Failures are recorded and surfaced exactly like
|
|
819
|
-
// creation-time ones, because it is the same code path.
|
|
820
|
-
//
|
|
821
|
-
// Cross-preset fence: the creation listener only ever hears agents whose
|
|
822
|
-
// event bubbles through THIS preset mount, but agents.roots() is a
|
|
823
|
-
// process-wide registry — a foreign agent bound here would get its edit
|
|
824
|
-
// tools denied (and a denial against a foreign tool catalog used to escape
|
|
825
|
-
// as a host-fatal error). Identity-compare the weirEditLock instance the
|
|
826
|
-
// agent's own retained preset mount resolves: only an agent of THIS mount
|
|
827
|
-
// sees this exact service object. Without a preset registry (host-level
|
|
828
|
-
// compositions, e.g. the integration harness) every root is eligible.
|
|
829
|
-
const presets = /** @type {any} */ (ctx.get?.('agentPresets'))
|
|
830
|
-
const ownAgent = typeof presets?.serviceFor === 'function'
|
|
831
|
-
? (/** @type {any} */ agent) => {
|
|
832
|
-
try { return presets.serviceFor(agent, 'weirEditLock') === service } catch { return false }
|
|
833
|
-
}
|
|
834
|
-
: () => true
|
|
835
|
-
const offDelegateLifecycle = registerDelegateEditLifecycle({
|
|
836
|
-
owns: ownAgent,
|
|
837
|
-
async resume(childId) {
|
|
838
|
-
const requestId = `supervised-resume:${crypto.randomUUID()}`
|
|
839
|
-
if (delegateResumes.has(childId)) throw new Error('supervised resume already in progress')
|
|
840
|
-
const resumed = new Map()
|
|
841
|
-
const pending = new Set()
|
|
842
|
-
let failure
|
|
843
|
-
const intent = {
|
|
844
|
-
resume(agent, domain) {
|
|
845
|
-
const task = (async () => {
|
|
846
|
-
if (!domain.blocks(agent)) return
|
|
847
|
-
await domain.resume(agent, requestId)
|
|
848
|
-
resumed.set(agent, domain)
|
|
849
|
-
})()
|
|
850
|
-
pending.add(task)
|
|
851
|
-
return task.catch(error => { failure = error; throw error })
|
|
852
|
-
},
|
|
853
|
-
}
|
|
854
|
-
delegateResumes.set(childId, intent)
|
|
855
|
-
const finish = async delivered => {
|
|
856
|
-
delegateResumes.delete(childId)
|
|
857
|
-
await Promise.allSettled(pending)
|
|
858
|
-
// Failed delivery must not leave authority armed, including a child
|
|
859
|
-
// activated inside sendMessage. Stop uses the existing epoch fence.
|
|
860
|
-
if (!delivered || failure) {
|
|
861
|
-
await Promise.all([...resumed].map(([agent, domain]) => domain.stop(agent)))
|
|
862
|
-
}
|
|
863
|
-
if (failure) throw failure
|
|
864
|
-
}
|
|
865
|
-
try {
|
|
866
|
-
const agent = boundAgents.get(childId)
|
|
867
|
-
const domain = agent && settled.get(agent)
|
|
868
|
-
if (domain) await intent.resume(agent, domain)
|
|
869
|
-
} catch (error) { await finish(false); throw error }
|
|
870
|
-
return finish
|
|
871
|
-
},
|
|
872
|
-
async settle(childId, agent) {
|
|
873
|
-
const record = agent ? agentDispositions.get(agent) : dispositions.get(childId)
|
|
874
|
-
await record?.promise
|
|
875
|
-
},
|
|
876
|
-
})
|
|
877
|
-
const endedRuns = new Set()
|
|
878
|
-
ctx.on('subagent/end', ({ id, runId, stopReason }) => {
|
|
879
|
-
// id is the exact child SessionId, not runId. Only published terminal
|
|
880
|
-
// outcomes enter here; ordinary continuable turn ends do not dispose.
|
|
881
|
-
if (!['completed', 'aborted', 'error', 'max-tokens', 'refusal'].includes(stopReason)) return
|
|
882
|
-
if (runId && endedRuns.has(runId)) return
|
|
883
|
-
if (runId) endedRuns.add(runId)
|
|
884
|
-
const agent = boundAgents.get(id)
|
|
885
|
-
if (agent) return disposeAgent(agent).catch(error => ctx.logger?.warn?.(`edit lock delegate settlement failed: ${error?.message ?? error}`))
|
|
886
|
-
})
|
|
887
|
-
try {
|
|
888
|
-
for (const agent of ctx.get?.('agents')?.roots?.() ?? []) {
|
|
889
|
-
if ((agent?.session?.header?.delegationDepth ?? 0) !== 0) continue
|
|
890
|
-
if (!ownAgent(agent)) continue
|
|
891
|
-
void setupAgent(agent)
|
|
892
|
-
}
|
|
893
|
-
} catch {
|
|
894
|
-
// No agent registry in this composition: only the creation path binds.
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
// Preset-switch re-binding (design D1): select → recompose rebinds the
|
|
898
|
-
// agent's scope chain to the new generation WITHOUT re-emitting
|
|
899
|
-
// agent/created; the preset registry re-emits this session event unscoped
|
|
900
|
-
// (dsh-agent-preset-registry: ctx.emit('agent-preset/selected', session.id,
|
|
901
|
-
// agentPreset)) AFTER the rebind has landed, so a re-setup here binds under
|
|
902
|
-
// THIS generation. The listener must return undefined (serial-bail
|
|
903
|
-
// discipline). A switch to a foreign preset fails the ownAgent fence and
|
|
904
|
-
// stays hard-refused, exactly as before; a setup failure lands in the
|
|
905
|
-
// fourfold record and the guard's lazy path does not retry it this
|
|
906
|
-
// generation.
|
|
907
|
-
ctx.on('agent-preset/selected', (/** @type {any} */ sessionId) => {
|
|
908
|
-
if (closed) return
|
|
909
|
-
const agent = typeof sessionId === 'string' ? ctx.get?.('agents')?.get?.(sessionId) : undefined
|
|
910
|
-
if (!agent || !ownAgent(agent)) return
|
|
911
|
-
void setupAgent(agent)
|
|
912
|
-
})
|
|
913
|
-
|
|
914
|
-
// Stock Stop aborts the active turn signal synchronously: close admission at
|
|
915
|
-
// that instant. Idle Stop has no signal; /edit-lock stop is the awaitable path.
|
|
916
|
-
/** Turn identity seen last per agent, so a new turn is detected exactly once.
|
|
917
|
-
* @type {WeakMap<object, unknown>} */
|
|
918
|
-
const lastTurn = new WeakMap()
|
|
919
|
-
// pendingAutoResume moved next to the binding machine (its retry path clears
|
|
920
|
-
// an armed auto-resume); it is declared above with it.
|
|
921
|
-
/** A genuine user message also arms ONE bounded binding retry when this agent
|
|
922
|
-
* has a recorded duplicate-binding failure (message-driven self-heal, design
|
|
923
|
-
* D5): consumed at the next turn's first pre-step by awaiting the existing
|
|
924
|
-
* trusted retryBinding — same attestation guards, same in-flight join,
|
|
925
|
-
* fail-closed while a holder serves. @type {WeakMap<object, boolean>} */
|
|
926
|
-
const pendingBindingRetry = new WeakMap()
|
|
927
|
-
/** @type {WeakSet<AbortSignal>} */
|
|
928
|
-
const watched = new WeakSet()
|
|
929
|
-
ctx.on('agent/pre-step', async (/** @type {any} */ event, /** @type {() => Promise<any>} */ next) => {
|
|
930
|
-
const { agent, signal, turn } = event ?? {}
|
|
931
|
-
// `pre-step` fires once per STEP, not per turn, so "a new turn started" has to
|
|
932
|
-
// be detected on turn identity. Doing it on every step would clear the
|
|
933
|
-
// reservation a holder just asked for, one step later. The detection runs
|
|
934
|
-
// even without a settled domain: a message-armed binding retry (D5) exists
|
|
935
|
-
// exactly while no domain is settled and must precede the turn's first step.
|
|
936
|
-
if (agent && turn && lastTurn.get(agent) !== turn) {
|
|
937
|
-
lastTurn.set(agent, turn)
|
|
938
|
-
if (pendingBindingRetry.get(agent) === true) {
|
|
939
|
-
pendingBindingRetry.delete(agent)
|
|
940
|
-
try {
|
|
941
|
-
await retryBinding(agent, 'message')
|
|
942
|
-
// The genuine message that armed this retry is also this turn's
|
|
943
|
-
// Continue receipt: re-arm what setupAgentOnce(retry) cleared, then
|
|
944
|
-
// fall through to the ordinary auto-resume consumption below. A
|
|
945
|
-
// transfer success (registry named an idle same-process holder)
|
|
946
|
-
// lands here identically: bound interrupted, then resumed in-turn.
|
|
947
|
-
pendingAutoResume.set(agent, true)
|
|
948
|
-
// The outcome audit (transferred / refused) was written inside
|
|
949
|
-
// retryBinding — success AND refusal alike; the catch below is not
|
|
950
|
-
// a silent swallow any more (design D3).
|
|
951
|
-
} catch { /* a safe refusal keeps the recorded cause; the turn proceeds with edits denied */ }
|
|
952
|
-
}
|
|
953
|
-
const domain = settled.get(agent)
|
|
954
|
-
if (domain) {
|
|
955
|
-
// A queued automatic-recovery summary (design D3) reaches exactly one
|
|
956
|
-
// agent as an ordinary mid-turn notice — the same proven delivery point
|
|
957
|
-
// as the settling and expiry notices.
|
|
958
|
-
const recoveryNotice = recoveryNotices.get(registry.rootOf(agent) ?? '')
|
|
959
|
-
if (recoveryNotice) {
|
|
960
|
-
recoveryNotices.delete(registry.rootOf(agent) ?? '')
|
|
961
|
-
try { deliverLocal(agent, recoveryNotice) } catch { /* the audit record is the durable account */ }
|
|
962
|
-
}
|
|
963
|
-
// A genuine user message arrived while this session was stopped: resume
|
|
964
|
-
// it and confirm its retained files BEFORE the turn's first step, so the
|
|
965
|
-
// edits the turn is about to request are admitted (message-driven
|
|
966
|
-
// auto-resume D1). A failure only warns and the turn proceeds denied —
|
|
967
|
-
// the panel keeps showing the stopped state (D6).
|
|
968
|
-
if (pendingAutoResume.get(agent) === true) {
|
|
969
|
-
pendingAutoResume.delete(agent)
|
|
970
|
-
if (domain.blocks(agent)) await autoResume(domain, agent, message => ctx.logger?.warn?.(message))
|
|
971
|
-
}
|
|
972
|
-
// The holder is working again: every lock it holds leaves the holding state at
|
|
973
|
-
// once, without waiting for the reservation to run out (design D4), and the
|
|
974
|
-
// notice budget for this batch restarts.
|
|
975
|
-
settle.turnStarted(agent)
|
|
976
|
-
void Promise.resolve(domain.turnStarted(agent)).catch((/** @type {any} */ error) => ctx.logger?.warn?.(`edit lock turn start failed: ${error?.message ?? error}`))
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
const domain = agent && settled.get(agent)
|
|
980
|
-
if (domain && signal && !watched.has(signal)) {
|
|
981
|
-
watched.add(signal)
|
|
982
|
-
const latch = () => { if (abortedByUser(signal)) domain.latch(agent) }
|
|
983
|
-
if (signal.aborted) latch()
|
|
984
|
-
else signal.addEventListener('abort', latch, { once: true })
|
|
985
|
-
}
|
|
986
|
-
return next()
|
|
987
|
-
})
|
|
988
|
-
|
|
989
|
-
// Message-driven auto-resume, phase 1 (D1): a genuine user message to the
|
|
990
|
-
// session arms the flag synchronously; runtime-injected (producer-tagged)
|
|
991
|
-
// messages never do. The arming hook is the inbox intake, NOT the durable
|
|
992
|
-
// user/message session event: that append only lands mid-turn in this
|
|
993
|
-
// runtime — past the new-turn pre-step the resume must precede (integration
|
|
994
|
-
// evidence: editlock-auto-resume). The setting is read per message, so a
|
|
995
|
-
// committed change applies without a restart (D5). The payload carries the
|
|
996
|
-
// agent, so no registry lookup is needed. Flag-set is synchronous-safe here
|
|
997
|
-
// (no followup, no session.append — AGENTS §3.4).
|
|
998
|
-
ctx.on('agent/inbox/inserted', (/** @type {any} */ { agent, message }) => {
|
|
999
|
-
if (!agent || !isGenuineUserMessage(message)) return
|
|
1000
|
-
// Stale-lock sweep: the SAME genuine-message hook as auto-resume, but the
|
|
1001
|
-
// sweep runs DETACHED — the turn never waits for it (unlike auto-resume,
|
|
1002
|
-
// which must precede the first step). Coalesced per management-domain root
|
|
1003
|
-
// (not per session) with a single in-flight sweep and a 60s cooldown; the
|
|
1004
|
-
// setting is read per message so a committed change applies at once
|
|
1005
|
-
// (default ON). Runtime-injected messages never reach this branch.
|
|
1006
|
-
if (ctx.get?.('weirSettings')?.get?.('editLock')?.staleSweep !== false) {
|
|
1007
|
-
const root = registry.rootOf(agent)
|
|
1008
|
-
if (root) sweeps.trigger(root, () => {
|
|
1009
|
-
// An agent disposed (or the plugin unmounted) before dispatch cancels
|
|
1010
|
-
// its undispatched work; an already-submitted authority transaction
|
|
1011
|
-
// is never cancelled or reinterpreted.
|
|
1012
|
-
if (closed || disposedAgents.has(agent) || registry.rootOf(agent) !== root) return undefined
|
|
1013
|
-
return registry.forRoot(root).then(domain => {
|
|
1014
|
-
// Re-check at fulfillment: a dispose landing while the domain-open
|
|
1015
|
-
// promise was pending must cancel here, before any maintenance
|
|
1016
|
-
// transaction is submitted for a disposed agent.
|
|
1017
|
-
if (closed || disposedAgents.has(agent)) return undefined
|
|
1018
|
-
return domain.sweepStale(agent, sessionOf(agent))
|
|
1019
|
-
})
|
|
1020
|
-
})
|
|
1021
|
-
}
|
|
1022
|
-
if (ctx.get?.('weirSettings')?.get?.('editLock')?.autoResume === false) return
|
|
1023
|
-
pendingAutoResume.set(agent, true)
|
|
1024
|
-
// Message-driven binding self-heal (D5): the same genuine message arms ONE
|
|
1025
|
-
// bounded retry of a recorded duplicate-binding failure, executed through
|
|
1026
|
-
// the trusted retryBinding path at the next turn's first pre-step. Injected
|
|
1027
|
-
// messages, timers, write calls and status reads never reach this branch.
|
|
1028
|
-
if (bindingFailures.get(agent)?.code === 'binding-duplicate') pendingBindingRetry.set(agent, true)
|
|
1029
|
-
})
|
|
1030
|
-
|
|
1031
|
-
// Classification and settling read only the durable turn/end reason
|
|
1032
|
-
// (AGENTS §3.5): `error` classifies the locks abnormal, `completed` settles the
|
|
1033
|
-
// locks the turn left behind, and `aborted` only latches. Nothing is inferred.
|
|
1034
|
-
ctx.on('session/event', (/** @type {any} */ session, /** @type {any} */ event) => {
|
|
1035
|
-
if (event?.type !== 'turn/end') return
|
|
1036
|
-
const agent = ctx.get?.('agents')?.get?.(session?.id)
|
|
1037
|
-
const domain = agent && settled.get(agent)
|
|
1038
|
-
if (!domain) return
|
|
1039
|
-
const classification = recovery.onTurnEnd(agent, event.data?.reason).catch((/** @type {any} */ error) => {
|
|
1040
|
-
ctx.logger?.warn?.(`edit lock recovery classification failed: ${error?.message ?? error}`)
|
|
1041
|
-
})
|
|
1042
|
-
turnClassifications.set(agent, classification)
|
|
1043
|
-
const reason = event.data?.reason
|
|
1044
|
-
if (reason?.kind !== 'completed') return
|
|
1045
|
-
// A reservation that ran out during the turn is released here (the turn is
|
|
1046
|
-
// over, so the session counts as idle). Settling starts in the same tick, not
|
|
1047
|
-
// after that release: a host that finishes when the agent goes idle would
|
|
1048
|
-
// otherwise end before the follow-up is queued. Its own status read happens
|
|
1049
|
-
// behind the release in the manager FIFO, so it sees the files already freed.
|
|
1050
|
-
void Promise.resolve(domain.settleExpired(agent, true)).then(result => {
|
|
1051
|
-
if (result?.released?.length) {
|
|
1052
|
-
settle.settled(agent)
|
|
1053
|
-
deliverLocal(agent, `Edit Lock: the reservation ended, so ${result.released.length} file(s) are available to other sessions.`)
|
|
1054
|
-
}
|
|
1055
|
-
}).catch((/** @type {any} */ error) => ctx.logger?.warn?.(`edit lock deferred release failed: ${error?.message ?? error}`))
|
|
1056
|
-
settle.onTurnEnd(agent, reason)
|
|
1057
|
-
})
|
|
1058
|
-
|
|
1059
|
-
// A reply tool may only disappear at a turn boundary, never mid-turn.
|
|
1060
|
-
ctx.on('agent/turn-stopping', (/** @type {any} */ { agent }) => { retireReplyTool(agent) })
|
|
1061
|
-
// disposeAgent and its disposalTasks moved into binding-machine.js (change
|
|
1062
|
-
// edit-lock-deepening, design D3) — the destructured alias is shared below.
|
|
1063
|
-
ctx.on('agent/disposed', ({ agent }) => disposeAgent(agent).catch(() => {}))
|
|
1064
|
-
|
|
1065
|
-
/** Cold-read fallback for the status view (design D1/D2): a session with no
|
|
1066
|
-
* live agent is observed cold through sessionQuery (never activated), its
|
|
1067
|
-
* management root derived from the observed cwd, and the authority image read
|
|
1068
|
-
* READ-ONLY with the maintenance inspector's discipline — no runtime open, no
|
|
1069
|
-
* reservation, not one byte mutated. Publisher AND client mode both answer
|
|
1070
|
-
* from this local read: the authority is a shared file on this filesystem.
|
|
1071
|
-
* Every failure degrades to an unavailable view with an explicit reason —
|
|
1072
|
-
* never a reasonless "starting" for a session that cannot be starting.
|
|
1073
|
-
* @param {string} sessionId */
|
|
1074
|
-
async function coldStatusView(sessionId) {
|
|
1075
|
-
if (platformFailure) return unavailableView(platformFailure.message)
|
|
1076
|
-
let observation
|
|
1077
|
-
try { observation = await ctx.get?.('sessionQuery')?.observeSession?.(sessionId) } catch { observation = undefined }
|
|
1078
|
-
if (!observation) return unavailableView('session is not live in this process and could not be cold-read')
|
|
1079
|
-
try {
|
|
1080
|
-
const fixed = options.fixed
|
|
1081
|
-
const cwd = observation.header?.cwd
|
|
1082
|
-
/** @type {string} */
|
|
1083
|
-
let root
|
|
1084
|
-
/** @type {string} */
|
|
1085
|
-
let directory
|
|
1086
|
-
/** @type {string} */
|
|
1087
|
-
let domainId
|
|
1088
|
-
if (fixed) {
|
|
1089
|
-
// A pinned composition serves exactly one authority; the observed cwd
|
|
1090
|
-
// only names the files in the view.
|
|
1091
|
-
root = fixed.root; directory = fixed.directory; domainId = fixed.root
|
|
1092
|
-
} else {
|
|
1093
|
-
try { root = resolveRoot(/** @type {string} */ (cwd)) } catch (error) { return unavailableView(`session working directory resolves no Edit Lock domain: ${errorMessage(error)}`) }
|
|
1094
|
-
directory = join(root, AUTHORITY_DIR); domainId = root
|
|
1095
|
-
}
|
|
1096
|
-
const read = readSnapshotBytes(directory)
|
|
1097
|
-
if (read.presence === 'no-authority') return unavailableView('workspace has no Edit Lock authority')
|
|
1098
|
-
if (read.presence === 'no-snapshot') return unavailableView('workspace authority has no committed snapshot')
|
|
1099
|
-
if (read.presence !== 'valid') return unavailableView(`authority snapshot unreadable: ${read.presence === 'unreadable' ? read.message : read.presence}`)
|
|
1100
|
-
let snapshot
|
|
1101
|
-
try { snapshot = parseSnapshot(read.bytes, domainId) } catch (error) { return unavailableView(`authority snapshot invalid: ${errorMessage(error)}`) }
|
|
1102
|
-
const autoResume = ctx.get?.('weirSettings')?.get?.('editLock')?.autoResume !== false
|
|
1103
|
-
return buildColdView({ image: snapshot.state, sessionId, cwd, root, autoResume, now: Date.now() })
|
|
1104
|
-
} finally {
|
|
1105
|
-
observation[Symbol.dispose]?.()
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
// Structured status for the panel (design D6). Reading the view grants nothing
|
|
1109
|
-
// and writes nothing to the conversation; every action still goes through an
|
|
1110
|
-
// explicit /edit-lock command so it stays on the record. `connection` is a
|
|
1111
|
-
// host-plane service, so the isolated realm does not hide it.
|
|
1112
|
-
/** @type {() => void} */
|
|
1113
|
-
let offView = () => {}
|
|
1114
|
-
ctx.inject?.(['connection'], (/** @type {any} */ scope) => {
|
|
1115
|
-
const connection = scope.connection
|
|
1116
|
-
if (!connection?.fetch?.register) return
|
|
1117
|
-
// Duplicate-route tolerance (design D2): with several preset mounts in one
|
|
1118
|
-
// process the first mount owns these process-wide routes, and the host
|
|
1119
|
-
// connection refuses a later mount's identical registration. Skipping only
|
|
1120
|
-
// that exact refusal keeps this mount's inject callback intact instead of
|
|
1121
|
-
// aborting it mid-mount; correctness never depends on mount order because
|
|
1122
|
-
// the surviving handler routes every agent to its owning mount (D1).
|
|
1123
|
-
/** @type {(() => void) | undefined} */
|
|
1124
|
-
let dispose
|
|
1125
|
-
try { dispose = connection.fetch.register({
|
|
1126
|
-
path: '/api/weir-edit-lock/view',
|
|
1127
|
-
methods: ['POST'],
|
|
1128
|
-
requestBody: 'buffered',
|
|
1129
|
-
fetch: async (/** @type {any} */ request) => {
|
|
1130
|
-
const reply = (/** @type {any} */ payload, status = 200) => new Response(JSON.stringify(payload), { status, headers: { 'content-type': 'application/json' } })
|
|
1131
|
-
let body
|
|
1132
|
-
try { body = await request.json() } catch { body = null }
|
|
1133
|
-
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
1134
|
-
if (!sessionId) return reply({ ok: false, error: { code: 'weir-edit-lock/invalid', message: 'body needs { sessionId }' } }, 400)
|
|
1135
|
-
const agent = ctx.get?.('agents')?.get?.(sessionId)
|
|
1136
|
-
// Multi-mount routing (design D1): this route belongs to whichever
|
|
1137
|
-
// mount happened to register first, but the answer must come from the
|
|
1138
|
-
// mount that owns the agent. Resolve the agent's own weirEditLock
|
|
1139
|
-
// instance through the preset registry (the own-preset fence's
|
|
1140
|
-
// mechanism) and let its describe build the view; an absent, throwing
|
|
1141
|
-
// or non-resolving registry falls back to this mount's local records
|
|
1142
|
-
// below (design D4).
|
|
1143
|
-
if (agent) {
|
|
1144
|
-
/** @type {any} */
|
|
1145
|
-
let owning = null
|
|
1146
|
-
try { owning = typeof presets?.serviceFor === 'function' ? presets.serviceFor(agent, 'weirEditLock') : null } catch { owning = null }
|
|
1147
|
-
if (owning && typeof owning.describe === 'function') {
|
|
1148
|
-
try {
|
|
1149
|
-
const described = await owning.describe(agent)
|
|
1150
|
-
return reply({ ok: true, value: buildView({ status: described.status, locks: described.locks, recoveryState: described.recovery, cwd: agent?.session?.header?.cwd, root: described.root, mode: described.mode, now: Date.now() }) })
|
|
1151
|
-
} catch (error) {
|
|
1152
|
-
// Failure loudening (design D3): a failed binding names its
|
|
1153
|
-
// reason; a reasonless "starting" is reserved for a genuinely
|
|
1154
|
-
// in-flight setup (describe's await absorbs that window).
|
|
1155
|
-
return reply({ ok: true, value: unavailableView(String(/** @type {any} */ (error)?.message ?? error), /** @type {any} */ (error)?.bindingFailure) })
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
const domain = agent && settled.get(agent)
|
|
1160
|
-
// A live agent whose domain is still starting (or failed to start)
|
|
1161
|
-
// keeps the old answer; only a session with NO live agent cold-reads.
|
|
1162
|
-
if (agent && !domain) return reply({ ok: true, value: unavailableView(platformFailure?.message ?? startFailures.get(agent) ?? null, startupFailure(agent)?.bindingFailure) })
|
|
1163
|
-
if (!domain) return reply({ ok: true, value: await coldStatusView(sessionId) })
|
|
1164
|
-
try {
|
|
1165
|
-
const [status, locks] = await Promise.all([domain.status(agent), domain.locks(agent)])
|
|
1166
|
-
return reply({ ok: true, value: buildView({ status, locks, recoveryState: recovery.state(agent), cwd: agent?.session?.header?.cwd, root: registry.rootOf(agent), mode: domain.mode, now: Date.now() }) })
|
|
1167
|
-
} catch (error) {
|
|
1168
|
-
return reply({ ok: false, error: { code: 'weir-edit-lock/internal', message: /** @type {any} */ (error)?.message ?? String(error) } }, 500)
|
|
1169
|
-
}
|
|
1170
|
-
},
|
|
1171
|
-
}) } catch (error) {
|
|
1172
|
-
if (!duplicateRouteRegistration(error, '/api/weir-edit-lock/view')) throw error
|
|
1173
|
-
}
|
|
1174
|
-
// Online administrative recovery (design D4): the maintenance panel's
|
|
1175
|
-
// one-click confirmation. Root membership is exact against the roots this
|
|
1176
|
-
// process actually serves (never an open, never a client path read); the
|
|
1177
|
-
// manager re-verifies every condition at its FIFO position. Refusals
|
|
1178
|
-
// expose no filesystem detail beyond what inspect already shows.
|
|
1179
|
-
/** @type {(() => void) | undefined} */
|
|
1180
|
-
let disposeRecovery
|
|
1181
|
-
try { disposeRecovery = connection.fetch.register({
|
|
1182
|
-
path: '/api/weir-edit-lock/maintenance/recover-online',
|
|
1183
|
-
methods: ['POST'],
|
|
1184
|
-
requestBody: 'buffered',
|
|
1185
|
-
fetch: async (/** @type {any} */ request) => {
|
|
1186
|
-
const reply = (/** @type {any} */ payload, /** @type {number} */ status = 200) => new Response(JSON.stringify(payload), { status, headers: { 'content-type': 'application/json' } })
|
|
1187
|
-
let body
|
|
1188
|
-
try { body = await request.json() } catch { return reply({ ok: false, error: { code: 'weir-edit-lock/invalid', message: 'Invalid recovery JSON' } }, 400) }
|
|
1189
|
-
const requested = body !== null && typeof body === 'object' && typeof body.root === 'string' ? body.root : ''
|
|
1190
|
-
let canonicalRoot = ''
|
|
1191
|
-
try { canonicalRoot = requested ? realpathSync.native(requested) : '' } catch { canonicalRoot = '' }
|
|
1192
|
-
if (!canonicalRoot || !registry.known(canonicalRoot)) {
|
|
1193
|
-
return reply({ ok: false, error: { code: 'weir-edit-lock/untrusted-root', message: 'not an Edit Lock domain root this server derived' } }, 403)
|
|
1194
|
-
}
|
|
1195
|
-
try {
|
|
1196
|
-
const domain = await registry.forRoot(canonicalRoot)
|
|
1197
|
-
// Server-derived trigger: one of THIS process's own agents bound to
|
|
1198
|
-
// the domain. A client-mode domain needs it to carry the channel;
|
|
1199
|
-
// a publisher-local domain uses it for the audit trigger only.
|
|
1200
|
-
const agents = ctx.get?.('agents')?.roots?.() ?? []
|
|
1201
|
-
const triggerAgent = agents.find((/** @type {any} */ agent) => registry.rootOf(agent) === canonicalRoot) ?? null
|
|
1202
|
-
if (domain.mode !== 'publisher' && !triggerAgent) {
|
|
1203
|
-
return reply({ ok: false, error: { code: 'weir-edit-lock/recovery-unavailable', message: 'no live session in this process can carry the recovery to the publisher', commitStatus: 'not-acknowledged' } }, 409)
|
|
1204
|
-
}
|
|
1205
|
-
const { root: _root, ...input } = body
|
|
1206
|
-
const value = await domain.adminRecover(triggerAgent, input, sessionOf(triggerAgent) ?? null)
|
|
1207
|
-
return reply({ ok: true, value })
|
|
1208
|
-
} catch (error) {
|
|
1209
|
-
const e = /** @type {any} */ (error)
|
|
1210
|
-
const code = typeof e?.code === 'string' && e.code.startsWith('weir-edit-lock/') ? e.code : 'weir-edit-lock/recovery-refused'
|
|
1211
|
-
return reply({ ok: false, error: { code, message: 'Recovery not acknowledged. Inspect authority and retry the same recovery ID after resolving the cause.',
|
|
1212
|
-
detail: e?.recoveryRefusal === true ? String(e.message) : undefined, commitStatus: 'not-acknowledged' } }, 409)
|
|
1213
|
-
}
|
|
1214
|
-
},
|
|
1215
|
-
}) } catch (error) {
|
|
1216
|
-
if (!duplicateRouteRegistration(error, '/api/weir-edit-lock/maintenance/recover-online')) throw error
|
|
1217
|
-
}
|
|
1218
|
-
offView = () => { dispose?.(); disposeRecovery?.() }
|
|
1219
|
-
return offView
|
|
1220
|
-
})
|
|
1221
|
-
const commands = ctx.get?.('commands')
|
|
1222
|
-
const offCommand = commands?.register({
|
|
1223
|
-
name: 'edit-lock',
|
|
1224
|
-
description: 'Edit Lock: status, retry (connection only; not Continue), locks, hold [minutes], release <path>, stop (durable revoke), resume (explicit Continue), confirm <path> | --all, unlock <path> <generation>.',
|
|
1225
|
-
input: { hint: 'status | retry | locks | hold [minutes] | release <path> | stop | resume | confirm <path> | unlock <path> <generation>' },
|
|
1226
|
-
handler: async (/** @type {any} */ invocation) => {
|
|
1227
|
-
const agent = invocation?.agent
|
|
1228
|
-
if (!agent) return { kind: 'error', text: 'edit-lock: requires an owning agent session' }
|
|
1229
|
-
try {
|
|
1230
|
-
const raw = String(invocation.rawInput ?? '')
|
|
1231
|
-
if (raw.trim() === 'retry') return { kind: 'success', text: await retryBinding(agent) }
|
|
1232
|
-
const domain = await domainFor(agent)
|
|
1233
|
-
// Only the hold verb depends on the retention settings being coherent.
|
|
1234
|
-
const policy = /^\s*hold\b/.test(raw) ? strictLimits() : limits()
|
|
1235
|
-
const text = await runCommand(domain, agent, raw, String(invocation.commandId), policy)
|
|
1236
|
-
return { kind: 'success', text: `${text}\nDomain: ${registry.rootOf(agent)} (${domain.mode})` }
|
|
1237
|
-
} catch (error) {
|
|
1238
|
-
return { kind: 'error', text: `edit-lock: ${/** @type {any} */ (error)?.message ?? error}` }
|
|
1239
|
-
}
|
|
1240
|
-
},
|
|
1241
|
-
})
|
|
1242
|
-
|
|
1243
|
-
if (!platformFailure) evidence?.installed()
|
|
1244
|
-
return async () => {
|
|
1245
|
-
if (closed) return
|
|
1246
|
-
closed = true
|
|
1247
|
-
liveServices.delete(service)
|
|
1248
|
-
offDelegateLifecycle()
|
|
1249
|
-
evidence?.disposing()
|
|
1250
|
-
try {
|
|
1251
|
-
// Cancel undispatched sweeps first; an already-running sweep's authority
|
|
1252
|
-
// transactions drain with the domains below, never cancelled.
|
|
1253
|
-
sweeps.close()
|
|
1254
|
-
recovery.close()
|
|
1255
|
-
settle.close()
|
|
1256
|
-
offView()
|
|
1257
|
-
offCommand?.()
|
|
1258
|
-
for (const off of offTools) off?.()
|
|
1259
|
-
// Retain uncertain evidence until every generation-owned domain is
|
|
1260
|
-
// drained; only the LAST sharing disposer closes them (design D1).
|
|
1261
|
-
await shared.release()
|
|
1262
|
-
evidence?.dispose()
|
|
1263
|
-
} catch (error) {
|
|
1264
|
-
evidence?.failed(error)
|
|
1265
|
-
ctx.logger?.warn?.(`edit lock shutdown incomplete; reservation retained: ${error?.message ?? error}`)
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
} catch (error) {
|
|
1269
|
-
evidence?.failed(error)
|
|
1270
|
-
// A mount that failed mid-apply must not stay counted in the shared
|
|
1271
|
-
// registry (design D1); domains are lazy, so this release closes nothing
|
|
1272
|
-
// unless a sibling mount already opened one — its count keeps it alive.
|
|
1273
|
-
if (shared) void shared.release().catch(() => {})
|
|
1274
|
-
throw error
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
1277
|
-
}
|
|
1278
|
-
const apply = createEditLockPlugin()
|
|
1279
|
-
|
|
1280
|
-
/** The host timer is an optional service: accessing an unavailable cordis service
|
|
1281
|
-
* throws rather than yielding undefined, so it is probed explicitly. Returning
|
|
1282
|
-
* undefined lets the caller fall back to the platform timer.
|
|
1283
|
-
* @param {any} ctx */
|
|
1284
|
-
function safeSetTimer(ctx) {
|
|
1285
|
-
try {
|
|
1286
|
-
const timer = ctx?.setTimeout
|
|
1287
|
-
return typeof timer === 'function' ? timer.bind(ctx) : undefined
|
|
1288
|
-
} catch { return undefined }
|
|
1289
|
-
}
|
|
1290
|
-
/** The host connection refuses a second registration of an exact route path
|
|
1291
|
-
* with this precise message (dsh-client-connection); only that refusal may be
|
|
1292
|
-
* skipped by a later mount — every other registration error still aborts the
|
|
1293
|
-
* mount. @param {any} error @param {string} path */
|
|
1294
|
-
function duplicateRouteRegistration(error, path) {
|
|
1295
|
-
return /** @type {any} */ (error)?.message === `connection: exact Fetch route ${JSON.stringify(path)} is already registered`
|
|
1296
|
-
}
|
|
1297
|
-
/** Any abort latches (denial is always safe) except disposal, which
|
|
1298
|
-
* agent/disposed revokes and forgets. @param {AbortSignal} signal */
|
|
1299
|
-
function abortedByUser(signal) {
|
|
1300
|
-
return /** @type {any} */ (signal.reason)?.kind !== 'disposed'
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
export { name, inject, apply }
|