weir-harness 1.2.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cordis.patch.yml +7 -18
- package/lib/client.file-lease-badge.js +307 -0
- package/lib/client.js +20 -30
- package/lib/client.settings-page.js +10 -34
- package/package.json +2 -2
- package/src/capabilities/store/checked-contract.d.ts +2 -2
- package/src/capabilities/store/fs-adapter.js +2 -3
- package/src/context-guard/index.js +2 -2
- package/src/delegate/supervision-mount.js +5 -5
- package/src/delegate/tool.js +2 -10
- package/src/file-lease/client.js +1066 -0
- package/src/file-lease/index.js +30 -0
- package/src/file-lease/lineage.js +207 -0
- package/src/file-lease/node-contracts.d.ts +42 -0
- package/src/file-lease/plugin.js +789 -0
- package/src/file-lease/singleton.js +155 -0
- package/src/file-lease/spawn.js +62 -0
- package/src/file-lease/tools.js +459 -0
- package/src/file-lease/view.js +144 -0
- package/src/hashline-edit/index.js +76 -42
- package/src/hashline-edit/write-tool.js +90 -0
- package/src/intent-gate/matcher.js +27 -0
- package/src/lease-broker/entry.js +98 -0
- package/src/lease-broker/node-contracts.d.ts +63 -0
- package/src/lease-broker/protocol.js +236 -0
- package/src/lease-broker/server.js +399 -0
- package/src/lease-broker/state.js +425 -0
- package/src/lsp/admin.js +24 -13
- package/src/lsp/index.js +5 -2
- package/src/lsp/registry.js +83 -17
- package/src/lsp/tools.js +137 -27
- package/src/notify/commands.js +15 -2
- package/src/notify/index.js +48 -10
- package/src/notify/web-channel.js +26 -3
- package/src/process/index.js +11 -0
- package/src/settings/index.js +2 -44
- package/src/settings/sections.js +29 -28
- package/src/shared/audit.js +6 -1
- package/src/todo-driver/index.js +1 -6
- package/src/worktree/cards.js +6 -21
- package/src/worktree/lane-core.js +1 -19
- package/src/worktree/lane-workflows.js +11 -32
- package/lib/client.edit-lock-maintenance.js +0 -592
- package/lib/client.edit-lock-panel.js +0 -405
- package/src/edit-lock/adapter.js +0 -57
- package/src/edit-lock/admin-ledger.js +0 -149
- package/src/edit-lock/admin-recovery.js +0 -151
- package/src/edit-lock/admission.js +0 -82
- package/src/edit-lock/binding-attestation.js +0 -29
- package/src/edit-lock/binding-guidance.js +0 -72
- package/src/edit-lock/binding-machine.js +0 -312
- package/src/edit-lock/call-context.js +0 -65
- package/src/edit-lock/derived-facts.js +0 -67
- package/src/edit-lock/domain.js +0 -165
- package/src/edit-lock/domains.js +0 -170
- package/src/edit-lock/host.js +0 -149
- package/src/edit-lock/incarnations.js +0 -150
- package/src/edit-lock/index.js +0 -1303
- package/src/edit-lock/inspect.js +0 -219
- package/src/edit-lock/lifecycle.js +0 -579
- package/src/edit-lock/limits.js +0 -61
- package/src/edit-lock/maintenance.js +0 -310
- package/src/edit-lock/manager.js +0 -875
- package/src/edit-lock/negotiation.js +0 -132
- package/src/edit-lock/operation-history.js +0 -214
- package/src/edit-lock/peer-client.js +0 -86
- package/src/edit-lock/peer-transport.js +0 -76
- package/src/edit-lock/peer.js +0 -87
- package/src/edit-lock/publisher.js +0 -191
- package/src/edit-lock/read-authority.js +0 -101
- package/src/edit-lock/recovery.js +0 -139
- package/src/edit-lock/remote.js +0 -362
- package/src/edit-lock/request-binding.js +0 -42
- package/src/edit-lock/request-data.js +0 -40
- package/src/edit-lock/reservation.js +0 -301
- package/src/edit-lock/reserved-runtime.js +0 -45
- package/src/edit-lock/resource-identity.js +0 -130
- package/src/edit-lock/runtime.js +0 -145
- package/src/edit-lock/settle.js +0 -144
- package/src/edit-lock/snapshot.js +0 -175
- package/src/edit-lock/stale-sweep.js +0 -120
- package/src/edit-lock/state.js +0 -462
- package/src/edit-lock/store.js +0 -327
- package/src/edit-lock/tool-scope.js +0 -29
- package/src/edit-lock/view.js +0 -170
- package/src/edit-lock/write-scopes.js +0 -48
- package/src/edit-lock/write-tool.js +0 -45
- package/src/shared/delegate-edit-lifecycle.js +0 -36
- package/src/worktree/authority.js +0 -56
package/src/edit-lock/domains.js
DELETED
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process'
|
|
2
|
-
import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
3
|
-
import { dirname, isAbsolute, join, resolve, relative, sep } from 'node:path'
|
|
4
|
-
|
|
5
|
-
/** Authority location inside a management root. */
|
|
6
|
-
export const AUTHORITY_DIR = join('.weir', 'edit-lock')
|
|
7
|
-
const EXCLUDE_LINES = ['/.weir/edit-lock/', '/.weir/.edit-lock.publisher-reservation/']
|
|
8
|
-
|
|
9
|
-
/** @param {string} cwd @param {string[]} args @returns {string | undefined} */
|
|
10
|
-
function git(cwd, args) {
|
|
11
|
-
try {
|
|
12
|
-
return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }).trim() || undefined
|
|
13
|
-
} catch { return undefined }
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** Management root for a session working directory: the git top level when
|
|
17
|
-
* inside a repository, else the directory itself; an enclosing directory that
|
|
18
|
-
* already hosts an Edit Lock authority wins so nested sessions share one domain.
|
|
19
|
-
* Optional discovery ceiling and Git adapter are for isolated embeddings;
|
|
20
|
-
* omitted dependencies retain the normal ancestor-authority discovery.
|
|
21
|
-
* @param {string} cwd
|
|
22
|
-
* @param {{ ceiling?: string, runGit?: typeof git }} [dependencies] */
|
|
23
|
-
export function managementRootFor(cwd, { ceiling, runGit = git } = {}) {
|
|
24
|
-
if (typeof cwd !== 'string' || !isAbsolute(cwd)) throw new Error('edit lock: absolute session cwd required')
|
|
25
|
-
const start = realpathSync.native(cwd)
|
|
26
|
-
const limit = ceiling === undefined ? undefined : realpathSync.native(ceiling)
|
|
27
|
-
const within = path => { const rel = relative(limit ?? path, path); return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`)) }
|
|
28
|
-
if (!within(start)) throw new Error('edit lock: cwd outside discovery ceiling')
|
|
29
|
-
const top = runGit(start, ['rev-parse', '--show-toplevel'])
|
|
30
|
-
let root = top ? realpathSync.native(top) : start
|
|
31
|
-
if (!within(root)) throw new Error('edit lock: git root outside discovery ceiling')
|
|
32
|
-
for (let candidate = dirname(root); candidate !== dirname(candidate); candidate = dirname(candidate)) {
|
|
33
|
-
if (root === limit || !within(candidate)) break
|
|
34
|
-
if (existsSync(join(candidate, AUTHORITY_DIR))) { root = candidate; break }
|
|
35
|
-
}
|
|
36
|
-
return root
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Keep authority state out of version control without touching tracked files:
|
|
40
|
-
* append to the repository's own info/exclude (worktree aware). Best effort.
|
|
41
|
-
* @param {string} root
|
|
42
|
-
* @param {typeof git} [runGit] */
|
|
43
|
-
export function excludeFromGit(root, runGit = git) {
|
|
44
|
-
const relativePath = runGit(root, ['rev-parse', '--git-path', 'info/exclude'])
|
|
45
|
-
if (!relativePath) return false
|
|
46
|
-
const file = resolve(root, relativePath)
|
|
47
|
-
mkdirSync(dirname(file), { recursive: true })
|
|
48
|
-
const current = existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
49
|
-
const lines = new Set(current.split('\n').map(line => line.trim()))
|
|
50
|
-
const missing = EXCLUDE_LINES.filter(line => !lines.has(line))
|
|
51
|
-
if (missing.length === 0) return true
|
|
52
|
-
appendFileSync(file, `${current && !current.endsWith('\n') ? '\n' : ''}# Weir Edit Lock authority state\n${missing.join('\n')}\n`)
|
|
53
|
-
return true
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Per-root lazy domain registry; a failed open is retried on next demand.
|
|
57
|
-
* @template T
|
|
58
|
-
* @param {(root: string) => Promise<T>} open
|
|
59
|
-
* @param {(cwd: string) => string} [resolveRoot] */
|
|
60
|
-
export function createDomainRegistry(open, resolveRoot = managementRootFor) {
|
|
61
|
-
/** @type {Map<string, Promise<T>>} */
|
|
62
|
-
const domains = new Map()
|
|
63
|
-
/** @type {WeakMap<object, string>} */
|
|
64
|
-
const roots = new WeakMap()
|
|
65
|
-
return Object.freeze({
|
|
66
|
-
/** Bind once at agent creation; the session cwd never re-binds later.
|
|
67
|
-
* @param {object} agent @param {string} cwd */
|
|
68
|
-
bind(agent, cwd) {
|
|
69
|
-
const root = resolveRoot(cwd)
|
|
70
|
-
roots.set(agent, root)
|
|
71
|
-
return root
|
|
72
|
-
},
|
|
73
|
-
/** @param {object} agent */
|
|
74
|
-
rootOf(agent) { return roots.get(agent) },
|
|
75
|
-
/** @param {object} agent @returns {Promise<T>} */
|
|
76
|
-
forAgent(agent) {
|
|
77
|
-
const root = roots.get(agent)
|
|
78
|
-
if (!root) return Promise.reject(new Error('agent has no Edit Lock domain'))
|
|
79
|
-
return this.forRoot(root)
|
|
80
|
-
},
|
|
81
|
-
/** @param {string} root @returns {Promise<T>} */
|
|
82
|
-
forRoot(root) {
|
|
83
|
-
let domain = domains.get(root)
|
|
84
|
-
if (!domain) {
|
|
85
|
-
domain = open(root)
|
|
86
|
-
domains.set(root, domain)
|
|
87
|
-
domain.catch(() => { if (domains.get(root) === domain) domains.delete(root) })
|
|
88
|
-
}
|
|
89
|
-
return domain
|
|
90
|
-
},
|
|
91
|
-
/** Membership only, never an open: the settings-plane online recovery
|
|
92
|
-
* endpoint accepts only roots this registry already serves.
|
|
93
|
-
* @param {string} root */
|
|
94
|
-
known(root) { return domains.has(root) },
|
|
95
|
-
/** @returns {Promise<T>[]} */
|
|
96
|
-
all() { return [...domains.values()] },
|
|
97
|
-
})
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** @type {WeakMap<object, Map<string, {registry: any, settled: WeakMap<object, any>, recoveryNotices: Map<string, string>, mounts: number}>>} */
|
|
101
|
-
const sharedRegistriesByHost = new WeakMap()
|
|
102
|
-
|
|
103
|
-
/** Process-shared domain registry container (change
|
|
104
|
-
* edit-lock-shared-domain-mounts, design D1): several preset mounts of this
|
|
105
|
-
* bundle coexist for the whole process, and every mount of one management
|
|
106
|
-
* root must resolve the SAME domain — one runtime, one publisher reservation,
|
|
107
|
-
* one lifecycle and one endpoint — instead of the second mount degrading to a
|
|
108
|
-
* same-process remote client of the first.
|
|
109
|
-
*
|
|
110
|
-
* The container is keyed by host (`ctx.root ?? ctx`, the same key expression
|
|
111
|
-
* the write-scope store uses — proven to be shared across preset mounts) plus
|
|
112
|
-
* a caller-supplied configuration discriminator: mounts whose effective
|
|
113
|
-
* domain configuration differs (a fixed root/authority row) NEVER share; they
|
|
114
|
-
* keep today's independent-registry semantics exactly.
|
|
115
|
-
*
|
|
116
|
-
* Reference-counted by mount: each apply takes one count, each disposer
|
|
117
|
-
* releases exactly one (idempotently); a factory that throws enters no count.
|
|
118
|
-
* Only the LAST release closes every domain through the established drain
|
|
119
|
-
* discipline (`registry.all() → domain.close()`), revoking sessions and
|
|
120
|
-
* releasing reservations; the entry is then dropped, so a later generation
|
|
121
|
-
* reopens fresh. The per-entry `settled` store and the per-root
|
|
122
|
-
* automatic-recovery notice queue share the entry's lifetime: they outlive
|
|
123
|
-
* any single mount (a preset switch lets a sibling mount's listeners read
|
|
124
|
-
* the binding the first mount settled, and the mount that hears the first
|
|
125
|
-
* post-recovery turn drains the summary), and they die with the last
|
|
126
|
-
* release so a closed domain is never mistaken for a live binding.
|
|
127
|
-
*
|
|
128
|
-
* @template T
|
|
129
|
-
* @param {object} host
|
|
130
|
-
* @param {string} discriminator
|
|
131
|
-
* @param {() => ReturnType<typeof createDomainRegistry<T>>} makeRegistry
|
|
132
|
-
* @returns {{registry: ReturnType<typeof createDomainRegistry<T>>, settled: WeakMap<object, T>, recoveryNotices: Map<string, string>, release: () => Promise<void>}} */
|
|
133
|
-
export function sharedDomainRegistryFor(host, discriminator, makeRegistry) {
|
|
134
|
-
let entries = sharedRegistriesByHost.get(host)
|
|
135
|
-
if (!entries?.has(discriminator)) {
|
|
136
|
-
// A throwing factory mutates nothing and enters no count.
|
|
137
|
-
const created = makeRegistry()
|
|
138
|
-
if (!entries) {
|
|
139
|
-
entries = new Map()
|
|
140
|
-
sharedRegistriesByHost.set(host, entries)
|
|
141
|
-
}
|
|
142
|
-
entries.set(discriminator, { registry: created, settled: new WeakMap(), recoveryNotices: new Map(), mounts: 0 })
|
|
143
|
-
}
|
|
144
|
-
const entry = /** @type {{registry: any, settled: WeakMap<object, any>, recoveryNotices: Map<string, string>, mounts: number}} */ (entries.get(discriminator))
|
|
145
|
-
entry.mounts += 1
|
|
146
|
-
let released = false
|
|
147
|
-
return Object.freeze({
|
|
148
|
-
registry: entry.registry,
|
|
149
|
-
settled: entry.settled,
|
|
150
|
-
/** One queued automatic-recovery summary per root, shared so the mount
|
|
151
|
-
* that hears the first post-recovery turn drains it (design D3). */
|
|
152
|
-
recoveryNotices: entry.recoveryNotices,
|
|
153
|
-
/** Idempotent: only the first call counts; only the last call closes. */
|
|
154
|
-
async release() {
|
|
155
|
-
if (released) return
|
|
156
|
-
released = true
|
|
157
|
-
entry.mounts -= 1
|
|
158
|
-
if (entry.mounts > 0 || entries.get(discriminator) !== entry) return
|
|
159
|
-
entries.delete(discriminator)
|
|
160
|
-
const results = await Promise.allSettled(entry.registry.all().map(async domain => (await domain).close()))
|
|
161
|
-
const failure = results.find(result => result.status === 'rejected')
|
|
162
|
-
if (failure?.status === 'rejected') throw failure.reason
|
|
163
|
-
},
|
|
164
|
-
})
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** @param {string} path */
|
|
168
|
-
export function isDirectory(path) {
|
|
169
|
-
try { return lstatSync(path).isDirectory() } catch { return false }
|
|
170
|
-
}
|
package/src/edit-lock/host.js
DELETED
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
import { createEditLockAdapter } from './adapter.js'
|
|
2
|
-
import { randomUUID } from 'node:crypto'
|
|
3
|
-
import { canonicalRequestData } from './request-data.js'
|
|
4
|
-
|
|
5
|
-
/** Trusted host binding. Registration is an explicit lifecycle action, never a
|
|
6
|
-
* tool argument or inference from a user-message source tag. Not auto-mounted.
|
|
7
|
-
* @param {Awaited<ReturnType<typeof import('./runtime.js').openEditLockRuntime>>} runtime
|
|
8
|
-
* @param {(agent: object) => boolean} isRegisteredAgent */
|
|
9
|
-
export function createEditLockHost(runtime, isRegisteredAgent) {
|
|
10
|
-
// Provider tool-call ids are not unique across turns (some reuse call_0):
|
|
11
|
-
// one operation identity per actual execution object, stable within it.
|
|
12
|
-
/** @type {WeakMap<object, string>} */
|
|
13
|
-
const operationIds = new WeakMap()
|
|
14
|
-
/** @param {{callId: string}} exec */
|
|
15
|
-
function operationIdFor(exec) {
|
|
16
|
-
let id = operationIds.get(exec)
|
|
17
|
-
if (!id) { id = `${exec.callId}@${randomUUID()}`; operationIds.set(exec, id) }
|
|
18
|
-
return id
|
|
19
|
-
}
|
|
20
|
-
const adapter = createEditLockAdapter(runtime)
|
|
21
|
-
/** @type {WeakMap<object, import('./state.js').Execution>} */
|
|
22
|
-
const executions = new WeakMap()
|
|
23
|
-
const activeBatches = new Set()
|
|
24
|
-
let closed = false
|
|
25
|
-
function live() { if (closed) throw new Error('edit lock host closed') }
|
|
26
|
-
return Object.freeze({
|
|
27
|
-
/** Called by the authenticated host lifecycle after durable registration or resume.
|
|
28
|
-
* @param {object} agent @param {import('./state.js').Execution} execution */
|
|
29
|
-
attach(agent, execution) {
|
|
30
|
-
live()
|
|
31
|
-
if (!isRegisteredAgent(agent)) throw new Error('unregistered host agent')
|
|
32
|
-
executions.set(agent, Object.freeze({ ...execution }))
|
|
33
|
-
},
|
|
34
|
-
/** Revoke admission before waiting for durable cancellation.
|
|
35
|
-
* @param {object} agent */
|
|
36
|
-
detach(agent) {
|
|
37
|
-
live()
|
|
38
|
-
const execution = executions.get(agent)
|
|
39
|
-
executions.delete(agent)
|
|
40
|
-
if (!execution) throw new Error('agent has no edit execution')
|
|
41
|
-
return runtime.control.cancelSession(execution.sessionId)
|
|
42
|
-
},
|
|
43
|
-
/** Trusted lifecycle lookup of the current admitted execution only.
|
|
44
|
-
* @param {object} agent */
|
|
45
|
-
executionFor(agent) {
|
|
46
|
-
live()
|
|
47
|
-
const execution = executions.get(agent)
|
|
48
|
-
if (!execution || !isRegisteredAgent(agent)) throw new Error('agent has no authenticated edit execution')
|
|
49
|
-
return execution
|
|
50
|
-
},
|
|
51
|
-
/** Tool implementation ingress AFTER effective policy resolution.
|
|
52
|
-
* @param {{agent: object, signal: AbortSignal, callId: string}} exec
|
|
53
|
-
* @param {Parameters<typeof adapter.publish>[1] & {cwd: string, effectivePolicy: unknown}} request */
|
|
54
|
-
async publish(exec, request) {
|
|
55
|
-
live()
|
|
56
|
-
const execution = executions.get(exec.agent)
|
|
57
|
-
if (!execution || !isRegisteredAgent(exec.agent)) throw new Error('agent has no authenticated edit execution')
|
|
58
|
-
const call = adapter.bind(execution, { cwd: request.cwd, effectivePolicy: request.effectivePolicy,
|
|
59
|
-
callId: operationIdFor(exec), signal: exec.signal })
|
|
60
|
-
const operation = await adapter.publish(call, request)
|
|
61
|
-
return operation.outcome
|
|
62
|
-
},
|
|
63
|
-
/** Existing-file batch only. Failed batches retain every temporary lock.
|
|
64
|
-
* @param {{agent: object, signal: AbortSignal, callId: string}} exec
|
|
65
|
-
* @param {{cwd: string, effectivePolicy: any, args: unknown, plans: Array<{filePath: string, content: string, version: string}>}} request */
|
|
66
|
-
async publishBatch(exec, request) {
|
|
67
|
-
live()
|
|
68
|
-
request = JSON.parse(canonicalRequestData(request))
|
|
69
|
-
if (typeof exec.callId !== 'string' || !exec.callId.trim()) throw new Error('call id required')
|
|
70
|
-
const execution = executions.get(exec.agent)
|
|
71
|
-
if (!execution || !isRegisteredAgent(exec.agent)) throw new Error('agent has no authenticated edit execution')
|
|
72
|
-
if (exec.signal.aborted) throw new Error('call aborted')
|
|
73
|
-
if (!['workspace-write', 'danger-full-access'].includes(request.effectivePolicy?.mode)) throw new Error('writable effective policy required')
|
|
74
|
-
const operation = operationIdFor(exec)
|
|
75
|
-
const ids = request.plans.map((_, index) => JSON.stringify(['lsp_rename', operation, index]))
|
|
76
|
-
// Never restart a partially applied batch by reacquiring released ownership.
|
|
77
|
-
for (const id of ids) if (runtime.control.history(execution.sessionId, id)) throw new Error('rename batch has history; inspect retained outcomes before retry')
|
|
78
|
-
const batchKey = JSON.stringify([execution.sessionId, exec.callId])
|
|
79
|
-
if (activeBatches.has(batchKey)) throw new Error('rename batch already in progress')
|
|
80
|
-
activeBatches.add(batchKey)
|
|
81
|
-
const abort = () => { try { void runtime.control.cancel(execution).catch(() => {}) } catch {} }
|
|
82
|
-
exec.signal.addEventListener('abort', abort, {once: true})
|
|
83
|
-
const written = []
|
|
84
|
-
let index = 0
|
|
85
|
-
try {
|
|
86
|
-
const ownership = await runtime.requests.acquireBatch(execution, request.plans.map(plan => plan.filePath), request.cwd)
|
|
87
|
-
for (const plan of request.plans) {
|
|
88
|
-
live()
|
|
89
|
-
const batchOwnership = ownership.ordered[index]
|
|
90
|
-
if (!batchOwnership) throw new Error('missing batch ownership')
|
|
91
|
-
const call = adapter.bind(execution, {cwd: request.cwd, effectivePolicy: request.effectivePolicy, callId: ids[index], signal: exec.signal})
|
|
92
|
-
await adapter.publish(call, {tool: 'lsp_rename', filePath: plan.filePath, content: plan.content,
|
|
93
|
-
batchOwnership, args: request.args, expected: {kind: 'replaceIfVersion', version: plan.version}})
|
|
94
|
-
written.push(plan.filePath)
|
|
95
|
-
index++
|
|
96
|
-
}
|
|
97
|
-
if (exec.signal.aborted) throw new Error('call aborted before temporary ownership cleanup')
|
|
98
|
-
await runtime.control.releaseMany(ownership.temporary)
|
|
99
|
-
return { written }
|
|
100
|
-
} catch (error) {
|
|
101
|
-
/** @type {string[]} */
|
|
102
|
-
let uncertain = []
|
|
103
|
-
if (index < request.plans.length) {
|
|
104
|
-
try {
|
|
105
|
-
const operation = runtime.control.history(execution.sessionId, ids[index])
|
|
106
|
-
if (operation && ['publishing', 'unknown', 'created', 'updated'].includes(operation.phase)) uncertain = [request.plans[index].filePath]
|
|
107
|
-
} catch { uncertain = [request.plans[index].filePath] }
|
|
108
|
-
}
|
|
109
|
-
const notWritten = request.plans.slice(index).map(plan => plan.filePath).filter(path => !uncertain.includes(path))
|
|
110
|
-
throw new Error(`rename stopped: ${String(error)};\nfilesAlreadyWritten: ${JSON.stringify(written)};\nfilesNotWritten: ${JSON.stringify(notWritten)};\nfilesUncertain: ${JSON.stringify(uncertain)};\nownership retained (no rollback)`, {cause: error})
|
|
111
|
-
} finally {
|
|
112
|
-
activeBatches.delete(batchKey)
|
|
113
|
-
exec.signal.removeEventListener('abort', abort)
|
|
114
|
-
}
|
|
115
|
-
},
|
|
116
|
-
/** Ordinary owner tool: acquire one existing file. Activates this
|
|
117
|
-
* session's own pending-confirmation lock; never steals another owner's.
|
|
118
|
-
* @param {{agent: object}} exec @param {{filePath: string, cwd: string}} request */
|
|
119
|
-
async acquire(exec, request) {
|
|
120
|
-
live()
|
|
121
|
-
const execution = executions.get(exec.agent)
|
|
122
|
-
if (!execution || !isRegisteredAgent(exec.agent)) throw new Error('agent has no authenticated edit execution')
|
|
123
|
-
const resourceId = runtime.requests.resource(request.filePath, request.cwd)
|
|
124
|
-
const token = await runtime.control.acquire(execution, resourceId)
|
|
125
|
-
return { resourceId, generation: token.generation }
|
|
126
|
-
},
|
|
127
|
-
/** Ordinary owner tool: release one owned lock. Release never asserts
|
|
128
|
-
* content correctness. @param {{agent: object}} exec @param {{filePath: string, cwd: string}} request */
|
|
129
|
-
async release(exec, request) {
|
|
130
|
-
live()
|
|
131
|
-
const execution = executions.get(exec.agent)
|
|
132
|
-
if (!execution || !isRegisteredAgent(exec.agent)) throw new Error('agent has no authenticated edit execution')
|
|
133
|
-
const { resourceId, observation } = runtime.requests.releaseResource(request.filePath, request.cwd)
|
|
134
|
-
const lock = runtime.control.status().locks.find(/** @param {any} item */ item => item.resourceId === resourceId)
|
|
135
|
-
if (!lock || lock.owner !== execution.sessionId) throw new Error('lock not owned by this session')
|
|
136
|
-
await runtime.requests.release({ ...execution, resourceId, generation: lock.generation }, observation)
|
|
137
|
-
return { resourceId, released: true }
|
|
138
|
-
},
|
|
139
|
-
/** Effective lock observation for one admitted agent; grants nothing.
|
|
140
|
-
* @param {{agent: object}} exec */
|
|
141
|
-
locks(exec) {
|
|
142
|
-
live()
|
|
143
|
-
const execution = executions.get(exec.agent)
|
|
144
|
-
if (!execution || !isRegisteredAgent(exec.agent)) throw new Error('agent has no authenticated edit execution')
|
|
145
|
-
return runtime.control.status().locks.map(/** @param {any} lock */ lock => ({ ...lock, mine: lock.owner === execution.sessionId }))
|
|
146
|
-
},
|
|
147
|
-
close() { closed = true; return adapter.close() },
|
|
148
|
-
})
|
|
149
|
-
}
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
// Manager incarnation process identity (design D2, openspec
|
|
2
|
-
// edit-lock-autonomous-recovery). The v6 authority image carries a registry of
|
|
3
|
-
// every manager incarnation it references with the process identity of that
|
|
4
|
-
// incarnation ({pid, host, osStart, bootNonce} from the shared Liveness
|
|
5
|
-
// adapter) or null for incarnations that predate the registry. Null
|
|
6
|
-
// identities NEVER participate in automatic settlement.
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* @typedef {{ pid: number, host: string, osStart: string|null, bootNonce: string }} ProcessIdentity
|
|
10
|
-
* @typedef {{ incarnation: string, process: ProcessIdentity|null }} IncarnationEntry
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { canonical } from './snapshot.js'
|
|
14
|
-
|
|
15
|
-
/** @param {unknown} condition @param {string} message @returns {asserts condition} */
|
|
16
|
-
function valid(condition, message) {
|
|
17
|
-
if (!condition) throw new Error(`invalid incarnation ${message}`)
|
|
18
|
-
}
|
|
19
|
-
/** @param {unknown} value @returns {value is string} */
|
|
20
|
-
const id = value => typeof value === 'string' && value.length > 0
|
|
21
|
-
/** @param {unknown} value @param {string[]} keys @returns {asserts value is Record<string, unknown>} */
|
|
22
|
-
function shape(value, keys) {
|
|
23
|
-
valid(value !== null && typeof value === 'object' && !Array.isArray(value), 'object')
|
|
24
|
-
valid(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null, 'object prototype')
|
|
25
|
-
valid(Reflect.ownKeys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)), 'schema keys')
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Every incarnation the image references: the current manager incarnation
|
|
29
|
-
* plus every operation origin. @param {any} state @returns {Set<string>} */
|
|
30
|
-
export function referencedIncarnations(state) {
|
|
31
|
-
/** @type {Set<string>} */
|
|
32
|
-
const ids = new Set()
|
|
33
|
-
if (id(state?.managerIncarnation)) ids.add(state.managerIncarnation)
|
|
34
|
-
for (const operation of state?.operations ?? []) ids.add(operation.origin.managerIncarnation)
|
|
35
|
-
return ids
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** The lossless seed for an image that predates the registry: one null-identity
|
|
39
|
-
* entry per referenced incarnation. @param {any} state @returns {IncarnationEntry[]} */
|
|
40
|
-
export function seedIncarnations(state) {
|
|
41
|
-
return [...referencedIncarnations(state)].map(incarnation => ({ incarnation, process: null }))
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* The exact registry a transition to (or within) v6 must carry: every entry of
|
|
46
|
-
* the previous registry (or the lossless null seed for a pre-v6 image)
|
|
47
|
-
* identically, plus at most one new entry — for the CURRENT manager
|
|
48
|
-
* incarnation — which is taken from `after` and so may carry the process
|
|
49
|
-
* identity of the manager that is committing. Any other registry (reordered,
|
|
50
|
-
* rewritten, backfilled identities, extra entries) is rejected.
|
|
51
|
-
* @param {any} before @param {any} after @returns {IncarnationEntry[]}
|
|
52
|
-
*/
|
|
53
|
-
export function expectedRegistry(before, after) {
|
|
54
|
-
const seeded = before.version === 6 ? before.incarnations : seedIncarnations(before)
|
|
55
|
-
const known = new Set(seeded.map(/** @param {IncarnationEntry} entry */ entry => entry.incarnation))
|
|
56
|
-
/** @type {IncarnationEntry[]} */
|
|
57
|
-
const registry = [...seeded]
|
|
58
|
-
if (id(after.managerIncarnation) && !known.has(after.managerIncarnation)) {
|
|
59
|
-
const entry = (after.incarnations ?? []).find(/** @param {IncarnationEntry} candidate */ candidate => candidate.incarnation === after.managerIncarnation)
|
|
60
|
-
if (entry) registry.push(entry)
|
|
61
|
-
}
|
|
62
|
-
return registry
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* The only version transition an ordinary (non-administrative) record may
|
|
67
|
-
* perform: the v6 registry upgrade. The image gains the exact expected
|
|
68
|
-
* registry and an admin ledger (empty when the pre-v6 image had none);
|
|
69
|
-
* everything else is covered by the ordinary transition validators.
|
|
70
|
-
* @param {any} before @param {any} after @returns {boolean}
|
|
71
|
-
*/
|
|
72
|
-
export function isRegistryUpgrade(before, after) {
|
|
73
|
-
if (!(after?.version === 6 && (before?.version === 4 || before?.version === 5))) return false
|
|
74
|
-
// The committing incarnation must be covered: a registry that drops it is
|
|
75
|
-
// never the upgrade (coverage is also enforced by validateIncarnations).
|
|
76
|
-
if (id(after.managerIncarnation) && !(after.incarnations ?? []).some(entry => entry.incarnation === after.managerIncarnation)) return false
|
|
77
|
-
return canonical(after.incarnations) === canonical(expectedRegistry(before, after)) &&
|
|
78
|
-
canonical(after.adminRecoveries ?? []) === canonical(before.adminRecoveries ?? [])
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Register the committing manager's incarnation in the durable image. No-op
|
|
83
|
-
* when no process identity is supplied (the registry exists only for images
|
|
84
|
-
* written by identity-aware managers) or when the incarnation is already
|
|
85
|
-
* registered (entries are immutable). Otherwise upgrades the image to v6:
|
|
86
|
-
* null seeds for every pre-existing incarnation (including the previous
|
|
87
|
-
* manager incarnation, which the new state no longer references) plus one
|
|
88
|
-
* entry carrying this process identity.
|
|
89
|
-
* @param {any} state @param {string} incarnation @param {ProcessIdentity|null} process
|
|
90
|
-
* @param {string|null} [previousIncarnation] @returns {any}
|
|
91
|
-
*/
|
|
92
|
-
export function withIncarnation(state, incarnation, process, previousIncarnation = null) {
|
|
93
|
-
if (!process) return state
|
|
94
|
-
if ((state.incarnations ?? []).some(/** @param {IncarnationEntry} entry */ entry => entry.incarnation === incarnation)) return state
|
|
95
|
-
// Pre-v6 migration seed, byte-identical to the one the transition validator
|
|
96
|
-
// derives from the UNCHANGED pre-image (expectedRegistry →
|
|
97
|
-
// seedIncarnations): the pre-image's current manager incarnation FIRST,
|
|
98
|
-
// then every operation origin in encounter order. The candidate already
|
|
99
|
-
// carries the NEW manager incarnation (recovery replaced it), so the
|
|
100
|
-
// pre-image manager is the explicitly supplied previous incarnation; the
|
|
101
|
-
// committing incarnation itself is never seeded — it is appended below with
|
|
102
|
-
// its process identity.
|
|
103
|
-
/** @type {IncarnationEntry[]} */
|
|
104
|
-
const seeded = []
|
|
105
|
-
const known = new Set()
|
|
106
|
-
/** @param {unknown} value */
|
|
107
|
-
const seedNull = value => {
|
|
108
|
-
if (id(value) && value !== incarnation && !known.has(value)) {
|
|
109
|
-
known.add(value)
|
|
110
|
-
seeded.push({ incarnation: value, process: null })
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
if (state.version !== 6) {
|
|
114
|
-
seedNull(id(previousIncarnation) ? previousIncarnation : state.managerIncarnation)
|
|
115
|
-
for (const operation of state.operations ?? []) seedNull(operation.origin.managerIncarnation)
|
|
116
|
-
}
|
|
117
|
-
const base = state.version === 6 ? state.incarnations : seeded
|
|
118
|
-
return { ...state, version: 6, adminRecoveries: state.adminRecoveries ?? [], incarnations: [...base, { incarnation, process }] }
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** The recorded process identity of one incarnation, or null (no identity or
|
|
122
|
-
* no entry): null is never eligible for automatic settlement.
|
|
123
|
-
* @param {any} state @param {string} incarnation @returns {ProcessIdentity|null} */
|
|
124
|
-
export function incarnationProcess(state, incarnation) {
|
|
125
|
-
const entry = (state.incarnations ?? []).find(/** @param {IncarnationEntry} candidate */ candidate => candidate.incarnation === incarnation)
|
|
126
|
-
return entry?.process ?? null
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* The durable registry invariant for a v6 image: exact entry shapes, unique
|
|
131
|
-
* incarnations, valid process identities, and coverage of every incarnation
|
|
132
|
-
* the image references. Called from validateImage; throws on any deviation.
|
|
133
|
-
* @param {any} state
|
|
134
|
-
*/
|
|
135
|
-
export function validateIncarnations(state) {
|
|
136
|
-
valid(Array.isArray(state.incarnations), 'registry')
|
|
137
|
-
const known = new Set()
|
|
138
|
-
for (const entry of state.incarnations) {
|
|
139
|
-
shape(entry, ['incarnation', 'process'])
|
|
140
|
-
valid(id(entry.incarnation) && !known.has(entry.incarnation), 'entry')
|
|
141
|
-
known.add(entry.incarnation)
|
|
142
|
-
if (entry.process === null) continue
|
|
143
|
-
shape(entry.process, ['pid', 'host', 'osStart', 'bootNonce'])
|
|
144
|
-
const process = /** @type {Record<string, any>} */ (entry.process)
|
|
145
|
-
valid(Number.isSafeInteger(process.pid) && process.pid > 0, 'pid')
|
|
146
|
-
valid(id(process.host) && id(process.bootNonce), 'identity')
|
|
147
|
-
valid(process.osStart === null || typeof process.osStart === 'string', 'start time')
|
|
148
|
-
}
|
|
149
|
-
for (const incarnation of referencedIncarnations(state)) valid(known.has(incarnation), 'coverage')
|
|
150
|
-
}
|