weir-harness 1.2.2 → 2.0.0

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