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.
Files changed (117) hide show
  1. package/cordis.patch.yml +7 -18
  2. package/lib/client.capability-badge.js +2 -0
  3. package/lib/client.capability-manager.js +46 -179
  4. package/lib/client.capability-model.js +428 -3
  5. package/lib/client.file-lease-badge.js +307 -0
  6. package/lib/client.file-lease-tool-model.js +189 -0
  7. package/lib/client.file-lease-tool-view.js +434 -0
  8. package/lib/client.js +124 -36
  9. package/lib/client.mcp-manager.js +563 -0
  10. package/lib/client.settings-page.js +10 -34
  11. package/package.json +2 -2
  12. package/skills/mcp-server-setup/SKILL.md +124 -0
  13. package/src/capabilities/apply-engine.js +18 -4
  14. package/src/capabilities/capability-remote.js +296 -45
  15. package/src/capabilities/initial-selection.js +3 -2
  16. package/src/capabilities/lifecycle-preload.js +69 -11
  17. package/src/capabilities/lifecycle-snapshot.js +127 -39
  18. package/src/capabilities/mcp-admission.js +2 -2
  19. package/src/capabilities/mcp-config.js +344 -0
  20. package/src/capabilities/mcp-credentials.js +180 -0
  21. package/src/capabilities/mcp-diagnostics.js +169 -0
  22. package/src/capabilities/mcp-drain.js +18 -4
  23. package/src/capabilities/mcp-facade.js +3 -3
  24. package/src/capabilities/mcp-gate.js +51 -52
  25. package/src/capabilities/mcp-install-tool.js +276 -0
  26. package/src/capabilities/mcp-manager.js +368 -142
  27. package/src/capabilities/mcp-mount.js +219 -79
  28. package/src/capabilities/mcp-operations.js +359 -0
  29. package/src/capabilities/mcp-process.js +43 -0
  30. package/src/capabilities/mcp-registry.js +74 -60
  31. package/src/capabilities/mcp-runtime.js +103 -0
  32. package/src/capabilities/read-payloads.js +8 -3
  33. package/src/capabilities/skill-applicability.js +185 -0
  34. package/src/capabilities/skill-builtin-migration.js +2 -1
  35. package/src/capabilities/skill-selection-plugin.js +161 -56
  36. package/src/capabilities/store/checked-contract.d.ts +2 -2
  37. package/src/capabilities/store/fs-adapter.js +2 -3
  38. package/src/context-guard/index.js +2 -2
  39. package/src/delegate/supervision-mount.js +5 -5
  40. package/src/delegate/tool.js +2 -10
  41. package/src/file-lease/client.js +1299 -0
  42. package/src/file-lease/index.js +34 -0
  43. package/src/file-lease/lineage.js +207 -0
  44. package/src/file-lease/node-contracts.d.ts +42 -0
  45. package/src/file-lease/plugin.js +1013 -0
  46. package/src/file-lease/singleton.js +155 -0
  47. package/src/file-lease/spawn.js +62 -0
  48. package/src/file-lease/tools.js +847 -0
  49. package/src/file-lease/view.js +144 -0
  50. package/src/file-lease/watch.js +473 -0
  51. package/src/hashline-edit/index.js +76 -42
  52. package/src/hashline-edit/write-tool.js +90 -0
  53. package/src/intent-gate/index.js +29 -12
  54. package/src/intent-gate/matcher.js +36 -2
  55. package/src/lease-broker/entry.js +98 -0
  56. package/src/lease-broker/node-contracts.d.ts +63 -0
  57. package/src/lease-broker/protocol.js +237 -0
  58. package/src/lease-broker/server.js +402 -0
  59. package/src/lease-broker/state.js +482 -0
  60. package/src/lsp/tools.js +137 -27
  61. package/src/process/index.js +4 -2
  62. package/src/settings/index.js +2 -44
  63. package/src/settings/sections.js +2 -24
  64. package/src/shared/audit.js +9 -1
  65. package/src/shared/child-scope.js +2 -0
  66. package/src/todo-driver/index.js +1 -6
  67. package/src/worktree/cards.js +6 -21
  68. package/src/worktree/lane-core.js +1 -19
  69. package/src/worktree/lane-workflows.js +11 -32
  70. package/src/worktree/rules.js +1 -1
  71. package/lib/client.edit-lock-maintenance.js +0 -592
  72. package/lib/client.edit-lock-panel.js +0 -405
  73. package/src/edit-lock/adapter.js +0 -57
  74. package/src/edit-lock/admin-ledger.js +0 -149
  75. package/src/edit-lock/admin-recovery.js +0 -151
  76. package/src/edit-lock/admission.js +0 -82
  77. package/src/edit-lock/binding-attestation.js +0 -29
  78. package/src/edit-lock/binding-guidance.js +0 -72
  79. package/src/edit-lock/binding-machine.js +0 -312
  80. package/src/edit-lock/call-context.js +0 -65
  81. package/src/edit-lock/derived-facts.js +0 -67
  82. package/src/edit-lock/domain.js +0 -165
  83. package/src/edit-lock/domains.js +0 -170
  84. package/src/edit-lock/host.js +0 -149
  85. package/src/edit-lock/incarnations.js +0 -150
  86. package/src/edit-lock/index.js +0 -1303
  87. package/src/edit-lock/inspect.js +0 -219
  88. package/src/edit-lock/lifecycle.js +0 -579
  89. package/src/edit-lock/limits.js +0 -61
  90. package/src/edit-lock/maintenance.js +0 -310
  91. package/src/edit-lock/manager.js +0 -875
  92. package/src/edit-lock/negotiation.js +0 -132
  93. package/src/edit-lock/operation-history.js +0 -214
  94. package/src/edit-lock/peer-client.js +0 -86
  95. package/src/edit-lock/peer-transport.js +0 -76
  96. package/src/edit-lock/peer.js +0 -87
  97. package/src/edit-lock/publisher.js +0 -191
  98. package/src/edit-lock/read-authority.js +0 -101
  99. package/src/edit-lock/recovery.js +0 -139
  100. package/src/edit-lock/remote.js +0 -362
  101. package/src/edit-lock/request-binding.js +0 -42
  102. package/src/edit-lock/request-data.js +0 -40
  103. package/src/edit-lock/reservation.js +0 -301
  104. package/src/edit-lock/reserved-runtime.js +0 -45
  105. package/src/edit-lock/resource-identity.js +0 -130
  106. package/src/edit-lock/runtime.js +0 -145
  107. package/src/edit-lock/settle.js +0 -144
  108. package/src/edit-lock/snapshot.js +0 -175
  109. package/src/edit-lock/stale-sweep.js +0 -120
  110. package/src/edit-lock/state.js +0 -462
  111. package/src/edit-lock/store.js +0 -327
  112. package/src/edit-lock/tool-scope.js +0 -29
  113. package/src/edit-lock/view.js +0 -170
  114. package/src/edit-lock/write-scopes.js +0 -48
  115. package/src/edit-lock/write-tool.js +0 -45
  116. package/src/shared/delegate-edit-lifecycle.js +0 -36
  117. package/src/worktree/authority.js +0 -56
@@ -1,132 +0,0 @@
1
- import { randomUUID } from 'node:crypto'
2
-
3
- /** Request-bound holder negotiation (try_steal → holder reply).
4
- *
5
- * Requests live in memory on purpose: a manager restart advances every
6
- * session epoch and the incarnation, so a request from before a restart could
7
- * only ever be stale. Timeout, delivery failure, silence and an interrupted
8
- * holder all retain ownership; only a current reply from the exact bound
9
- * holder execution transfers, and the transfer itself is one durable manager
10
- * transaction. Waiting holds no manager queue slot.
11
- * @param {{
12
- * control: {status: () => any, transfer: (holder: any, requester: any) => Promise<any>},
13
- * executionFor: (agent: object) => import('./state.js').Execution,
14
- * agentFor: (sessionId: string) => object | undefined,
15
- * deliver: (agent: object, text: string, wake?: boolean) => void,
16
- * onPending?: (agent: object, pending: number) => void,
17
- * timeoutMs?: number, now?: () => number,
18
- * }} options */
19
- export function createNegotiation(options) {
20
- const { control, executionFor, agentFor, deliver } = options
21
- const timeoutMs = options.timeoutMs ?? 120_000
22
- const now = options.now ?? Date.now
23
- /** @typedef {{requestId: string, resourceId: string, generation: number,
24
- * holder: {sessionId: string, executionEpoch: number}, requester: import('./state.js').Execution,
25
- * state: 'pending'|'transferring'|'transferred'|'declined'|'expired'|'stale'|'failed', expiresAt: number,
26
- * timer?: ReturnType<typeof setTimeout>}} Request */
27
- /** @type {Map<string, Request>} */
28
- const requests = new Map()
29
- let closed = false
30
-
31
- /** `wake` opens an idle turn, which a holder needs in order to answer at all;
32
- * it is never used for interrupted holders or for outcome notices.
33
- * @param {string} sessionId @param {string} text @param {boolean} [wake] */
34
- function notify(sessionId, text, wake = false) {
35
- const agent = agentFor(sessionId)
36
- if (!agent) return
37
- try { deliver(agent, text, wake) } catch { /* delivery failure never implies consent */ }
38
- }
39
- /** @param {string} sessionId */
40
- function pendingFor(sessionId) {
41
- return [...requests.values()].filter(request => request.holder.sessionId === sessionId && request.state === 'pending')
42
- }
43
- /** @param {string} sessionId */
44
- function refresh(sessionId) {
45
- const agent = agentFor(sessionId)
46
- if (agent) options.onPending?.(agent, pendingFor(sessionId).length)
47
- }
48
- /** @param {Request} request @param {Request['state']} state @param {string} text */
49
- function settle(request, state, text) {
50
- if (request.state !== 'pending' && request.state !== 'transferring') return
51
- request.state = state
52
- if (request.timer) clearTimeout(request.timer)
53
- notify(request.requester.sessionId, text)
54
- refresh(request.holder.sessionId)
55
- }
56
- /** @param {string} resourceId */
57
- function lockOf(resourceId) {
58
- return control.status().locks.find(/** @param {any} lock */ lock => lock.resourceId === resourceId)
59
- }
60
-
61
- return Object.freeze({
62
- /** Requester side; never waits for the holder.
63
- * @param {object} agent @param {string} resourceId */
64
- request(agent, resourceId) {
65
- if (closed) throw new Error('negotiation closed')
66
- const requester = executionFor(agent)
67
- const lock = lockOf(resourceId)
68
- if (!lock) throw new Error('file is not locked; acquire it instead')
69
- if (lock.owner === requester.sessionId) throw new Error('file is already owned by this session')
70
- const existing = [...requests.values()].find(request => request.state === 'pending'
71
- && request.resourceId === resourceId && request.requester.sessionId === requester.sessionId)
72
- if (existing) return { requestId: existing.requestId, state: 'pending', holder: lock.owner, holderStatus: lock.status }
73
- const session = control.status().sessions.find(/** @param {any} item */ item => item.sessionId === lock.owner)
74
- /** @type {Request} */
75
- const request = { requestId: `steal-${randomUUID()}`, resourceId, generation: lock.generation,
76
- holder: { sessionId: lock.owner, executionEpoch: session?.executionEpoch ?? 0 },
77
- requester: { ...requester }, state: 'pending', expiresAt: now() + timeoutMs }
78
- requests.set(request.requestId, request)
79
- request.timer = setTimeout(() => settle(request, 'expired',
80
- `Edit Lock request ${request.requestId} for ${resourceId} expired without a holder reply; ownership is unchanged. ` +
81
- `If the holder cannot reply, the Edit Lock maintenance panel (Settings → Edit Lock maintenance) offers the administrative force release for the stuck lock.`), timeoutMs)
82
- request.timer.unref?.()
83
- // An interrupted holder is never woken; its request simply expires.
84
- if (session && !session.interrupted) {
85
- refresh(lock.owner)
86
- notify(lock.owner, `Edit Lock: session ${requester.sessionId} requests ownership of ${resourceId} (request ${request.requestId}). ` +
87
- `Call edit_lock_reply with this request_id and decision "release" to hand the file over, or "keep" to retain it. No reply keeps your ownership.`, true)
88
- }
89
- return { requestId: request.requestId, state: 'pending', holder: lock.owner, holderStatus: lock.status }
90
- },
91
- /** Holder side; the reply binds request, generation, holder and epoch.
92
- * @param {object} agent @param {string} requestId @param {'release'|'keep'} decision */
93
- async reply(agent, requestId, decision) {
94
- if (closed) throw new Error('negotiation closed')
95
- const request = requests.get(requestId)
96
- if (!request || request.state !== 'pending') throw new Error('request is not pending')
97
- const holder = executionFor(agent)
98
- if (holder.sessionId !== request.holder.sessionId || holder.executionEpoch !== request.holder.executionEpoch) {
99
- throw new Error('reply is not from the bound holder execution')
100
- }
101
- if (now() >= request.expiresAt) {
102
- settle(request, 'expired', `Edit Lock request ${requestId} expired; ownership is unchanged.`)
103
- throw new Error('request expired')
104
- }
105
- const lock = lockOf(request.resourceId)
106
- if (!lock || lock.owner !== holder.sessionId || lock.generation !== request.generation) {
107
- settle(request, 'stale', `Edit Lock request ${requestId} is stale: ownership of ${request.resourceId} changed.`)
108
- throw new Error('ownership changed since the request')
109
- }
110
- if (decision === 'keep') {
111
- settle(request, 'declined', `Edit Lock request ${requestId} declined: the holder keeps ${request.resourceId}.`)
112
- return { requestId, state: 'declined' }
113
- }
114
- if (decision !== 'release') throw new Error('decision must be "release" or "keep"')
115
- request.state = 'transferring'
116
- try {
117
- const token = await control.transfer({ ...holder, resourceId: request.resourceId, generation: request.generation }, request.requester)
118
- settle(request, 'transferred', `Edit Lock request ${requestId} granted: this session now owns ${request.resourceId} (generation ${token.generation}).`)
119
- return { requestId, state: 'transferred', generation: token.generation }
120
- } catch (error) {
121
- settle(request, 'failed', `Edit Lock request ${requestId} failed; ownership is unchanged: ${/** @type {any} */ (error)?.message ?? error}`)
122
- throw error
123
- }
124
- },
125
- /** @param {string} sessionId */
126
- pending(sessionId) { return pendingFor(sessionId).map(({ requestId, resourceId, requester, expiresAt }) => ({ requestId, resourceId, requester: requester.sessionId, expiresAt })) },
127
- close() {
128
- closed = true
129
- for (const request of requests.values()) if (request.timer) clearTimeout(request.timer)
130
- },
131
- })
132
- }
@@ -1,214 +0,0 @@
1
- import { isAbsolute, relative } from 'node:path'
2
- import { dispositionFor } from './derived-facts.js'
3
-
4
- /** Historical assertions only: no authentication, live permission or FS publication.
5
- * Digests MUST be computed by a future trusted publisher over canonical requests,
6
- * original arguments and actual payload bytes. Strings supplied here are not proof.
7
- * @typedef {{ kind: 'create', ancestor: string, suffix: string, policy: { kind: 'createIfAbsent' } } | { kind: 'update', resourceId: string, generation: number, policy: { kind: 'replaceIfVersion', version: string } }} Target
8
- * @typedef {{ tool: 'write'|'hash_edit'|'edit'|'lsp_rename', filePath: string, cwd: string, requestDigest: string, argsDigest: string, payloadDigest: string, target: Target }} Binding
9
- * @typedef {{ kind: 'resource', resourceId: string } | { kind: 'subtree', ancestor: string, basis: 'observed-ancestor'|'conservative-ancestor' } | { kind: 'domain', basis: 'containment-unproved' }} Fence
10
- * @typedef {{ kind: 'created'|'updated', resourceId: string, generation: number, version: string } | { kind: 'unknown' } | { kind: 'not-published', reason: 'cancelled-before-dispatch'|'rejected-before-dispatch' }} Outcome
11
- * @typedef {{ kind: 'abandoned-unknown'|'not-published-evidence', assertionId: string }} Closeout
12
- * @typedef {{ sessionId: string, operationId: string, origin: { executionEpoch: number, managerIncarnation: string }, binding: Binding, phase: 'prepared'|'publishing'|'created'|'updated'|'unknown'|'not-published', fence: Fence|null, outcome: Outcome|null, closeouts: Closeout[] }} Operation
13
- */
14
-
15
- /** @param {unknown} condition @param {string} message @returns {asserts condition} */
16
- function valid(condition, message) {
17
- if (!condition) throw new Error(`invalid operation ${message}`)
18
- }
19
- /** @param {unknown} value @returns {value is string} */
20
- function id(value) { return typeof value === 'string' && value.length > 0 && !value.includes('\0') }
21
- /** @param {unknown} value @returns {value is number} */
22
- function positive(value) { return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 }
23
- /** @param {unknown} value @param {string[]} keys @returns {asserts value is Record<string, unknown>} */
24
- function shape(value, keys) {
25
- valid(value !== null && typeof value === 'object' && !Array.isArray(value), 'object')
26
- valid(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null, 'prototype')
27
- valid(Reflect.ownKeys(value).length === keys.length && keys.every(k => Object.hasOwn(value, k)), 'schema keys')
28
- for (const key of keys) {
29
- const d = Object.getOwnPropertyDescriptor(value, key)
30
- valid(d && 'value' in d && d.enumerable, 'data property')
31
- }
32
- }
33
- /** @param {unknown} value @returns {asserts value is unknown[]} */
34
- function array(value) {
35
- valid(Array.isArray(value) && Object.getPrototypeOf(value) === Array.prototype, 'array')
36
- valid(Reflect.ownKeys(value).length === value.length + 1, 'array keys')
37
- for (let i = 0; i < value.length; i++) {
38
- const d = Object.getOwnPropertyDescriptor(value, i)
39
- valid(d && 'value' in d && d.enumerable, 'array data')
40
- }
41
- }
42
- /** @param {Binding} binding */
43
- export function validateBinding(binding) {
44
- shape(binding, ['tool', 'filePath', 'cwd', 'requestDigest', 'argsDigest', 'payloadDigest', 'target'])
45
- valid(['write', 'hash_edit', 'edit', 'lsp_rename'].includes(binding.tool), 'tool')
46
- valid(id(binding.filePath) && id(binding.cwd) && isAbsolute(binding.cwd), 'literal path/cwd')
47
- for (const digest of [binding.requestDigest, binding.argsDigest, binding.payloadDigest]) {
48
- valid(typeof digest === 'string' && /^[a-f0-9]{64}$/.test(digest), 'digest')
49
- }
50
- const target = binding.target
51
- if (target.kind === 'create') {
52
- shape(target, ['kind', 'ancestor', 'suffix', 'policy'])
53
- valid(binding.tool === 'write', 'creation is write-only')
54
- valid(id(target.ancestor) && isAbsolute(target.ancestor) && id(target.suffix) && !isAbsolute(target.suffix) && !target.suffix.split('/').includes('..'), 'create descriptor')
55
- shape(target.policy, ['kind'])
56
- valid(target.policy.kind === 'createIfAbsent', 'frozen create policy')
57
- } else {
58
- shape(target, ['kind', 'resourceId', 'generation', 'policy'])
59
- valid(target.kind === 'update' && id(target.resourceId) && isAbsolute(target.resourceId) && positive(target.generation), 'update descriptor')
60
- shape(target.policy, ['kind', 'version'])
61
- valid(target.policy.kind === 'replaceIfVersion' && typeof target.policy.version === 'string', 'guarded update policy')
62
- }
63
- }
64
- /** Store-internal validation; collections are never a second authority map.
65
- * @param {import('./store.js').AuthorityImage} state */
66
- export function validateOperations(state) {
67
- array(state.operations)
68
- const keys = new Set()
69
- const generations = new Map(state.generations.map(g => [g.resourceId, g.generation]))
70
- for (const op of state.operations) {
71
- shape(op, ['sessionId', 'operationId', 'origin', 'binding', 'phase', 'fence', 'outcome', 'closeouts'])
72
- valid(id(op.sessionId) && id(op.operationId), 'key')
73
- const key = JSON.stringify([op.sessionId, op.operationId])
74
- valid(!keys.has(key), 'duplicate key'); keys.add(key)
75
- const session = state.sessions.find(s => s.sessionId === op.sessionId)
76
- shape(op.origin, ['executionEpoch', 'managerIncarnation'])
77
- valid(positive(op.origin.executionEpoch) && id(op.origin.managerIncarnation) && session && session.executionEpoch >= op.origin.executionEpoch, 'origin')
78
- validateBinding(op.binding)
79
- valid(['prepared', 'publishing', 'created', 'updated', 'unknown', 'not-published'].includes(op.phase), 'phase')
80
- if (op.phase === 'prepared') valid(op.fence === null && op.outcome === null, 'prepared phase')
81
- else if (op.phase === 'not-published') {
82
- valid(op.fence === null, 'undispatched fence')
83
- shape(op.outcome, ['kind', 'reason'])
84
- valid(op.outcome.kind === 'not-published' && ['cancelled-before-dispatch', 'rejected-before-dispatch'].includes(op.outcome.reason), 'undispatched outcome')
85
- }
86
- else {
87
- validateFence(op)
88
- if (op.phase === 'publishing') valid(op.outcome === null, 'publishing outcome')
89
- else if (op.phase === 'unknown') {
90
- shape(op.outcome, ['kind'])
91
- valid(op.outcome.kind === 'unknown', 'unknown outcome')
92
- }
93
- else {
94
- shape(op.outcome, ['kind', 'resourceId', 'generation', 'version'])
95
- valid(op.outcome.kind === 'created' || op.outcome.kind === 'updated', 'success outcome')
96
- valid(op.outcome.kind === op.phase && id(op.outcome.resourceId) && isAbsolute(op.outcome.resourceId) && positive(op.outcome.generation) && typeof op.outcome.version === 'string', 'outcome')
97
- // Lifetime references survive release/reownership; current locks are irrelevant.
98
- valid((generations.get(op.outcome.resourceId) ?? 0) >= op.outcome.generation, 'success generation history')
99
- valid(op.phase === (op.binding.target.kind === 'create' ? 'created' : 'updated'), 'outcome channel')
100
- if (op.binding.target.kind === 'update') valid(op.outcome.resourceId === op.binding.target.resourceId && op.outcome.generation === op.binding.target.generation, 'update outcome binding')
101
- if (op.phase === 'created' && op.fence?.kind === 'subtree') {
102
- const suffix = relative(op.fence.ancestor, op.outcome.resourceId)
103
- valid(suffix.length > 0 && suffix !== '..' && !suffix.startsWith('../') && !suffix.startsWith('..\\') && !isAbsolute(suffix), 'created containment')
104
- }
105
- }
106
- }
107
- if ((op.phase === 'publishing' || op.phase === 'unknown') && op.binding.target.kind === 'update' && !dispositionFor(state, op)) {
108
- const target = op.binding.target
109
- const lock = state.locks.find(l => l.resourceId === target.resourceId)
110
- valid(lock && lock.owner === op.sessionId && lock.generation === target.generation, 'retained unresolved ownership')
111
- }
112
- array(op.closeouts)
113
- valid(op.phase === 'unknown' || op.closeouts.length === 0, 'unknown-only closeout')
114
- const assertions = new Set()
115
- for (const closeout of op.closeouts) {
116
- shape(closeout, ['kind', 'assertionId'])
117
- valid(['abandoned-unknown', 'not-published-evidence'].includes(closeout.kind) && id(closeout.assertionId) && !assertions.has(closeout.assertionId), 'closeout assertion')
118
- assertions.add(closeout.assertionId)
119
- }
120
- }
121
- }
122
-
123
- /** Containment here is a historical assertion, NOT filesystem/alias proof. The
124
- * future publisher must establish the observed ancestor and scope before dispatch.
125
- * @param {Operation} op */
126
- function validateFence(op) {
127
- const fence = op.fence
128
- valid(fence, 'durable fence required')
129
- const target = op.binding.target
130
- if (target.kind === 'update') {
131
- shape(fence, ['kind', 'resourceId'])
132
- valid(fence.kind === 'resource' && fence.resourceId === target.resourceId, 'resource fence')
133
- } else if (fence.kind === 'subtree') {
134
- shape(fence, ['kind', 'ancestor', 'basis'])
135
- valid(id(fence.ancestor) && isAbsolute(fence.ancestor), 'subtree scope')
136
- if (fence.basis === 'observed-ancestor') valid(fence.ancestor === target.ancestor, 'minimal ancestor')
137
- else {
138
- valid(fence.basis === 'conservative-ancestor', 'scope basis')
139
- const suffix = relative(fence.ancestor, target.ancestor)
140
- valid(suffix !== '..' && !suffix.startsWith('../') && !suffix.startsWith('..\\') && !isAbsolute(suffix), 'conservative containment')
141
- }
142
- } else {
143
- shape(fence, ['kind', 'basis'])
144
- valid(fence.kind === 'domain' && fence.basis === 'containment-unproved', 'domain fallback')
145
- }
146
- }
147
-
148
- /** Structural comparison independent of object insertion order.
149
- * @param {unknown} value @returns {string} */
150
- function canonical(value) {
151
- if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
152
- if (value !== null && typeof value === 'object') {
153
- const object = /** @type {Record<string, unknown>} */ (value)
154
- return `{${Object.keys(object).sort().map(k => `${JSON.stringify(k)}:${canonical(object[k])}`).join(',')}}`
155
- }
156
- return JSON.stringify(value)
157
- }
158
- /** No current authority checks: authenticate the querying session outside this
159
- * historical seam. Original epoch/incarnation are NOT fields of a retry query.
160
- * A returned prepared/publishing record is never permission to redispatch.
161
- * @param {import('./store.js').AuthorityImage} state
162
- * @param {{sessionId: string, operationId: string, binding: Binding}} query
163
- * @returns {Operation|null} */
164
- export function lookupOperation(state, query) {
165
- const op = state.operations.find(o => o.sessionId === query.sessionId && o.operationId === query.operationId)
166
- if (!op) return null
167
- if (canonical(op.binding) !== canonical(query.binding)) throw new Error('ID_REUSE: operation binding differs')
168
- return structuredClone(op)
169
- }
170
- /** Always called by raw store.record too.
171
- * @param {import('./store.js').AuthorityImage} before
172
- * @param {import('./store.js').AuthorityImage} after */
173
- export function validateOperationTransitions(before, after) {
174
- for (const next of after.operations) {
175
- const old = lookupOperation(before, next)
176
- if (!old) {
177
- valid(next.phase === 'prepared', 'new history must be prepared')
178
- valid(next.origin.managerIncarnation === after.managerIncarnation && next.origin.executionEpoch === after.sessions.find(s => s.sessionId === next.sessionId)?.executionEpoch, 'initial origin')
179
- }
180
- }
181
- // One before/after ownership transition can credit only one new creation.
182
- // Do not count unchanged successful history from earlier generations.
183
- const createdResources = new Set()
184
- for (const old of before.operations) {
185
- const next = lookupOperation(after, old)
186
- valid(next, 'history removal')
187
- valid(canonical(next.origin) === canonical(old.origin), 'immutable origin')
188
- const previousSession = before.sessions.find(s => s.sessionId === old.sessionId)
189
- const session = after.sessions.find(s => s.sessionId === old.sessionId)
190
- valid(previousSession && session, 'operation session')
191
- if (canonical(old) !== canonical(next) && previousSession.interrupted) valid(session.interrupted && session.executionEpoch === previousSession.executionEpoch, 'operation transition cannot rearm session')
192
- if (old.phase === 'prepared') valid(['prepared', 'publishing', 'not-published'].includes(next.phase), 'prepared transition')
193
- else {
194
- valid(canonical(old.fence) === canonical(next.fence), 'immutable fence')
195
- if (old.phase === 'publishing') valid(['publishing', 'created', 'updated', 'unknown'].includes(next.phase), 'publishing transition')
196
- else {
197
- valid(old.phase === next.phase && canonical(old.outcome) === canonical(next.outcome), 'immutable terminal history')
198
- valid(next.closeouts.length >= old.closeouts.length && old.closeouts.every((c, i) => canonical(c) === canonical(next.closeouts[i])), 'append-only closeout')
199
- }
200
- }
201
- if (old.phase === 'publishing' && next.outcome && (next.outcome.kind === 'created' || next.outcome.kind === 'updated')) {
202
- const outcome = next.outcome
203
- const lock = after.locks.find(l => l.resourceId === outcome.resourceId)
204
- valid(lock && lock.owner === next.sessionId && lock.generation === outcome.generation, 'atomic outcome ownership')
205
- if (next.outcome.kind === 'created') {
206
- valid(!before.locks.some(l => l.resourceId === outcome.resourceId), 'created requires new ownership')
207
- valid(!createdResources.has(outcome.resourceId), 'duplicate created resource attribution')
208
- createdResources.add(outcome.resourceId)
209
- }
210
- if (session.interrupted) valid(lock.status === 'user-interrupted' || lock.status === 'abnormal', 'retained interrupted classification')
211
- if (session.executionEpoch !== old.origin.executionEpoch || after.managerIncarnation !== old.origin.managerIncarnation) valid(lock.status !== 'active', 'retained stale execution classification')
212
- }
213
- }
214
- }
@@ -1,86 +0,0 @@
1
- import { asBindingFailure } from './binding-guidance.js'
2
- import { canonicalRequestData } from './request-data.js'
3
- import { PEER_KINDS } from './peer.js'
4
-
5
- /** Client for a pre-authenticated, single-session host channel. A disconnect or
6
- * abort rejects with UNKNOWN, not not-published; never retry under a new callId.
7
- * @param {import('node:stream').Duplex} stream
8
- * @param {{onEvent?: (event: any) => void, onClose?: () => void}} [hooks] */
9
- export function createEditLockPeerClient(stream, hooks = {}) {
10
- const maxBytes = 8 * 1024 * 1024
11
- /** @type {Map<string, {resolve:(value:any)=>void, reject:(error:Error)=>void, cleanup:()=>void}>} */
12
- const pending = new Map()
13
- let buffer = Buffer.alloc(0)
14
- let closed = false
15
- function close() {
16
- if (closed) return
17
- closed = true
18
- stream.destroy()
19
- for (const entry of pending.values()) {
20
- entry.cleanup()
21
- entry.reject(new Error('edit publication outcome UNKNOWN: channel closed; inspect original call history'))
22
- }
23
- pending.clear()
24
- try { hooks.onClose?.() } catch { /* observer failure cannot reopen */ }
25
- }
26
- /** @param {Buffer} chunk */
27
- function receive(chunk) {
28
- if (closed) return
29
- if (!Buffer.isBuffer(chunk) || buffer.length + chunk.length > maxBytes + 4) { close(); return }
30
- buffer = Buffer.concat([buffer, chunk])
31
- while (!closed && buffer.length >= 4) {
32
- const size = buffer.readUInt32BE(0)
33
- if (size === 0 || size > maxBytes) { close(); return }
34
- if (buffer.length < size + 4) return
35
- let message
36
- try { message = JSON.parse(new TextDecoder('utf-8', {fatal:true}).decode(buffer.subarray(4,size+4))) }
37
- catch { close(); return }
38
- buffer = buffer.subarray(size+4)
39
- if (message && typeof message === 'object' && Object.keys(message).join(',') === 'event') {
40
- try { hooks.onEvent?.(message.event) } catch { /* notices never imply consent */ }
41
- continue
42
- }
43
- const frameKeys = !message || typeof message !== 'object' ? '' : Object.keys(message).sort().join(',')
44
- // The error frame carries the structured binding refusal as an additive
45
- // optional field (change edit-lock-deepening, design D2).
46
- if (!message || typeof message.callId !== 'string' || typeof message.ok !== 'boolean'
47
- || (message.ok ? frameKeys !== 'callId,ok,outcome' : frameKeys !== 'callId,error,ok' && frameKeys !== 'bindingFailure,callId,error,ok')
48
- || (!message.ok && typeof message.error !== 'string')) { close(); return }
49
- const entry = pending.get(message.callId)
50
- if (!entry) { close(); return }
51
- pending.delete(message.callId)
52
- entry.cleanup()
53
- if (message.ok) entry.resolve(message.outcome)
54
- else {
55
- const failure = asBindingFailure(message.bindingFailure)
56
- entry.reject(Object.assign(new Error(message.error), failure ? { bindingFailure: failure } : {}))
57
- }
58
- }
59
- }
60
- stream.on('data', receive)
61
- stream.once('end', close)
62
- stream.once('error', close)
63
- stream.once('close', close)
64
- return Object.freeze({
65
- /** Cancellation closes the entire session channel (conservative revocation).
66
- * Local rejection is NOT a manager revocation acknowledgment.
67
- * @param {string} kind @param {string} callId @param {unknown} request @param {AbortSignal} signal */
68
- request(kind, callId, request, signal) {
69
- if (closed || signal.aborted) return Promise.reject(new Error('edit peer unavailable before dispatch'))
70
- if (!(kind === 'open' || PEER_KINDS.includes(kind)) || typeof callId !== 'string' || !callId.trim() || callId.length > 512) return Promise.reject(new Error('invalid edit peer call'))
71
- if (pending.has(callId) || pending.size >= 32) return Promise.reject(new Error('edit peer call admission rejected'))
72
- const bytes = Buffer.from(canonicalRequestData({kind,callId,request}))
73
- if (bytes.length > maxBytes || stream.writableLength + bytes.length + 4 > 2*maxBytes) return Promise.reject(new Error('edit peer frame admission rejected'))
74
- const header = Buffer.alloc(4)
75
- header.writeUInt32BE(bytes.length)
76
- return new Promise((resolve,reject) => {
77
- pending.set(callId, {resolve,reject,cleanup:() => signal.removeEventListener('abort',close)})
78
- signal.addEventListener('abort',close,{once:true})
79
- if (signal.aborted) { close(); return }
80
- try { stream.write(Buffer.concat([header,bytes]), error => {if (error) close()}) }
81
- catch { close() }
82
- })
83
- },
84
- close,
85
- })
86
- }
@@ -1,76 +0,0 @@
1
- import { asBindingFailure } from './binding-guidance.js'
2
- import { canonicalRequestData } from './request-data.js'
3
-
4
- /** Attach ONLY an authenticated, host-owned byte stream. Never accepts public
5
- * sockets or chooses a session from message fields. Length-prefixed JSON, 8 MiB
6
- * maximum; transport loss revokes the fixed peer and never elects a publisher.
7
- * @param {import('node:stream').Duplex} stream
8
- * @param {{receive: (input: unknown) => Promise<unknown>, disconnect: () => Promise<unknown>}} peer */
9
- export function serveEditLockPeer(stream, peer) {
10
- const maxBytes = 8 * 1024 * 1024
11
- let buffer = Buffer.alloc(0)
12
- let ended = false
13
- /** @type {Promise<unknown> | undefined} */
14
- let closing
15
- function close() {
16
- if (closing) return closing
17
- ended = true
18
- stream.pause()
19
- stream.destroy()
20
- const disconnecting = Promise.resolve(peer.disconnect())
21
- closing = disconnecting
22
- // Event callbacks cannot propagate an async rejection. Keep the original
23
- // rejected promise available to the owner, which MUST await close().
24
- void disconnecting.catch(() => {})
25
- return disconnecting
26
- }
27
- /** @param {unknown} response */
28
- function send(response) {
29
- if (ended) return
30
- const bytes = Buffer.from(canonicalRequestData(response))
31
- if (bytes.length > maxBytes || stream.writableLength + bytes.length + 4 > 2 * maxBytes) {
32
- close()
33
- return
34
- }
35
- const header = Buffer.alloc(4)
36
- header.writeUInt32BE(bytes.length)
37
- stream.write(Buffer.concat([header, bytes]), error => { if (error) close() })
38
- }
39
- /** @param {Buffer} chunk */
40
- function receive(chunk) {
41
- if (ended) return
42
- // Bound allocation before concatenation; a cooperating client sends one
43
- // bounded frame at a time. Oversized coalesced batches fail closed too.
44
- if (!Buffer.isBuffer(chunk) || buffer.length + chunk.length > maxBytes + 4) { close(); return }
45
- buffer = Buffer.concat([buffer, chunk])
46
- while (!ended && buffer.length >= 4) {
47
- const size = buffer.readUInt32BE(0)
48
- if (size === 0 || size > maxBytes) { close(); return }
49
- if (buffer.length < size + 4) return
50
- let request
51
- try { request = JSON.parse(new TextDecoder('utf-8', {fatal:true}).decode(buffer.subarray(4, size + 4))) }
52
- catch { close(); return }
53
- buffer = buffer.subarray(size + 4)
54
- if (typeof request?.callId !== 'string' || request.callId.length > 512) { close(); return }
55
- const callId = request.callId
56
- void peer.receive(request).then(
57
- outcome => { try { send({callId, ok:true, outcome:outcome ?? null}) } catch { close() } },
58
- error => {
59
- // The wire carries the structured binding refusal additively (change
60
- // edit-lock-deepening, design D2); the text stays for display.
61
- const failure = asBindingFailure(/** @type {any} */ (error)?.bindingFailure)
62
- try { send({ callId, ok: false, error: String(/** @type {any} */ (error)?.message ?? error).slice(0, 4096), ...(failure ? { bindingFailure: failure } : {}) }) } catch { close() }
63
- },
64
- )
65
- }
66
- }
67
- stream.on('data', receive)
68
- stream.once('end', close)
69
- stream.once('error', close)
70
- stream.once('close', close)
71
- return Object.freeze({close,
72
- /** Server-initiated notice frame; carries text/counters, never authority.
73
- * @param {unknown} event */
74
- notify(event) { try { send({event}) } catch { close() } },
75
- })
76
- }
@@ -1,87 +0,0 @@
1
- import { canonicalRequestData } from './request-data.js'
2
-
3
- /** Model-facing tool kinds and trusted human-ingress kinds. The connecting
4
- * host process is the trusted ingress for the latter (same trust base). */
5
- export const PEER_KINDS = Object.freeze(['publish', 'batch', 'acquire', 'release', 'locks', 'trySteal', 'reply', 'pending',
6
- 'status', 'stop', 'resume', 'confirm', 'unlock', 'allLocks', 'classifyAbnormal', 'recoveryUsage', 'chargeRecovery', 'pause',
7
- 'hold', 'turnStarted', 'releaseHeld', 'settleExpired', 'retention', 'dispose', 'staleSweep', 'adminRecover'])
8
-
9
- /** Bind an already authenticated, host-owned channel to ONE registered agent.
10
- * No identity or lifecycle capability is accepted in a wire message. The caller
11
- * process is trusted to resolve policy; this is not remote attestation.
12
- * @param {ReturnType<typeof import('./lifecycle.js').createEditLockLifecycle>} lifecycle
13
- * @param {object} agent */
14
- export function createEditLockPeer(lifecycle, agent) {
15
- const pending = new Set()
16
- let closed = false
17
- /** @type {Promise<unknown> | undefined} */
18
- let closing
19
- function disconnect() {
20
- if (closing) return closing
21
- closed = true
22
- // stop synchronously fences the registry before waiting for any publication;
23
- // Connection loss may be a crash: revoke and forget, never release. A
24
- // clean client end sends 'dispose' first.
25
- closing = lifecycle.forget ? lifecycle.forget(agent) : lifecycle.stop(agent)
26
- const attempt = closing
27
- void attempt.catch(() => { if (closing === attempt) closing = undefined })
28
- return /** @type {Promise<void>} */ (closing)
29
- }
30
- return Object.freeze({
31
- /** Transport must bound frame bytes before JSON parsing and call disconnect
32
- * on EOF/error. Responses carry outcomes, never reusable write permissions.
33
- * @param {unknown} input */
34
- async receive(input) {
35
- if (closed) throw new Error('edit peer disconnected')
36
- const message = JSON.parse(canonicalRequestData(input))
37
- if (!message || typeof message !== 'object' || Array.isArray(message)
38
- || Object.keys(message).sort().join(',') !== 'callId,kind,request'
39
- || !PEER_KINDS.includes(message.kind)
40
- || typeof message.callId !== 'string' || !message.callId.trim()) throw new Error('invalid edit peer message')
41
- if (pending.has(message.callId)) throw new Error('edit peer call already pending')
42
- if (pending.size >= 32) throw new Error('edit peer admission limit')
43
- pending.add(message.callId)
44
- try {
45
- const exec = {agent,callId:message.callId,signal:new AbortController().signal}
46
- const request = /** @type {any} */ (message.request)
47
- const service = /** @type {any} */ (lifecycle.service)
48
- switch (message.kind) {
49
- case 'publish': return await service.publish(exec, request)
50
- case 'batch': return await service.publishBatch(exec, request)
51
- case 'acquire': return await service.acquire(exec, request)
52
- case 'release': return await service.release(exec, request)
53
- case 'locks': return await service.locks(exec)
54
- case 'trySteal': return await service.trySteal(exec, request)
55
- case 'reply': return await service.reply(exec, request)
56
- case 'pending': return await service.pendingRequests(exec)
57
- case 'status': return lifecycle.status(agent)
58
- case 'stop': await lifecycle.stop(agent); return lifecycle.status(agent)
59
- case 'resume': return await lifecycle.resume(agent, `remote:${String(request?.requestId)}`)
60
- case 'confirm': await lifecycle.confirm(agent, request?.resourceId); return lifecycle.status(agent)
61
- case 'unlock': return await lifecycle.unlock(request?.resourceId, request?.generation)
62
- case 'allLocks': return lifecycle.locks()
63
- case 'classifyAbnormal': return await lifecycle.classifyAbnormal(agent, String(request?.reason ?? 'abnormal'))
64
- case 'recoveryUsage': return lifecycle.recoveryUsage(agent)
65
- case 'chargeRecovery': return await lifecycle.chargeRecovery(agent, request ?? {})
66
- case 'pause': return await lifecycle.pause(agent, Number(request?.minutes))
67
- case 'hold': return await lifecycle.hold(agent, Number(request?.ms), { singleMaxMs: Number(request?.singleMaxMs), cumulativeMaxMs: Number(request?.cumulativeMaxMs) })
68
- case 'turnStarted': return await lifecycle.turnStarted(agent)
69
- case 'releaseHeld': return await lifecycle.settleExpired(agent, true)
70
- case 'settleExpired': return await lifecycle.settleExpired(agent, false, request?.idle === true)
71
- case 'retention': return lifecycle.retention(agent)
72
- case 'dispose': { closed = true; await lifecycle.dispose(agent); return { disposed: true } }
73
- // The triggering session is the channel's own session: the publisher
74
- // performs the scan itself (it shares the filesystem).
75
- case 'staleSweep': return await lifecycle.sweepStale(agent.id)
76
- // Online administrative recovery (design D4): the SAME transaction
77
- // runs publisher-side; the trigger identity is the channel's own
78
- // bound agent, never a payload field.
79
- case 'adminRecover': return await lifecycle.adminRecoverOnline(request, agent.id)
80
- default: throw new Error('invalid edit peer message')
81
- }
82
- } finally { pending.delete(message.callId) }
83
- },
84
- // Trusted channel lifecycle only, not a model-controlled request method.
85
- disconnect,
86
- })
87
- }