weir-harness 1.1.7 → 1.2.1
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/lib/client.blackboard-model.js +172 -56
- package/lib/client.capability-manager.js +0 -1
- package/lib/client.capability-model.js +378 -31
- package/lib/client.chain-model.js +80 -0
- package/lib/client.disabled-categories-editor.js +22 -0
- package/lib/client.edit-lock-maintenance.js +239 -246
- package/lib/client.edit-lock-panel.js +110 -0
- package/lib/client.hash-edit-model.js +48 -0
- package/lib/client.js +267 -1549
- package/lib/client.lsp-model.js +92 -0
- package/lib/client.lsp-panel.js +11 -16
- package/lib/client.lsp-toggle.js +18 -0
- package/lib/client.notify-permissions.js +112 -20
- package/lib/client.notify-web.js +14 -11
- package/lib/client.robash-model.js +26 -0
- package/lib/client.settings-page.js +287 -17
- package/lib/client.worktree-model.js +225 -0
- package/package.json +1 -1
- package/src/blackboard/durable.js +12 -0
- package/src/blackboard/index.js +2 -2
- package/src/blackboard/kernel.js +23 -6
- package/src/blackboard/notify.js +10 -3
- package/src/blackboard/remote.js +31 -13
- package/src/blackboard/storage.js +13 -5
- package/src/capabilities/apply-engine.js +189 -101
- package/src/capabilities/defaults-transaction.js +136 -1
- package/src/capabilities/lifecycle-snapshot.js +83 -204
- package/src/capabilities/mcp-admission.js +1 -23
- package/src/capabilities/mcp-drain.js +30 -19
- package/src/capabilities/mcp-facade.js +23 -3
- package/src/capabilities/preset-library.js +247 -0
- package/src/capabilities/preset-package.js +212 -1
- package/src/capabilities/same-name-policy.js +3 -41
- package/src/capabilities/selection-notify.js +324 -21
- package/src/capabilities/skill-selection-plugin.js +315 -729
- package/src/capabilities/store/checked-contract.d.ts +5 -0
- package/src/capabilities/store/fs-adapter.js +52 -0
- package/src/capabilities/store/lock.js +86 -0
- package/src/capabilities/store/paths.js +5 -0
- package/src/capabilities/store/store.js +105 -5
- package/src/delegate/group-coordinator.js +1 -17
- package/src/delegate/index.js +4 -2
- package/src/delegate/rehydrate.js +2 -1
- package/src/delegate/robash-guard-core.js +2 -2
- package/src/delegate/supervision-mount.js +5 -4
- package/src/delegate/tool.js +26 -3
- package/src/{shared → delegate}/whitelist-defaults.js +2 -2
- package/src/edit-lock/admin-ledger.js +8 -11
- package/src/edit-lock/admin-recovery.js +2 -1
- package/src/edit-lock/admission.js +12 -2
- package/src/edit-lock/binding-guidance.js +28 -10
- package/src/edit-lock/binding-machine.js +312 -0
- package/src/edit-lock/derived-facts.js +67 -0
- package/src/edit-lock/domains.js +67 -0
- package/src/edit-lock/index.js +115 -261
- package/src/edit-lock/inspect.js +113 -1
- package/src/edit-lock/lifecycle.js +24 -7
- package/src/edit-lock/limits.js +61 -0
- package/src/edit-lock/maintenance.js +5 -2
- package/src/edit-lock/manager.js +6 -2
- package/src/edit-lock/operation-history.js +1 -1
- package/src/edit-lock/peer-client.js +9 -2
- package/src/edit-lock/peer-transport.js +7 -1
- package/src/edit-lock/remote.js +11 -4
- package/src/edit-lock/state.js +2 -4
- package/src/edit-lock/view.js +10 -11
- package/src/lsp/admin.js +2 -24
- package/src/lsp/index.js +1 -1
- package/src/lsp/manager.js +1 -1
- package/src/notify/permissions-admin.js +1 -1
- package/src/{lsp/child-process.js → process/index.js} +12 -4
- package/src/settings/env-admin.js +1 -1
- package/src/settings/index.js +1 -1
- package/src/settings/sections.js +0 -54
- package/src/shared/audit.js +5 -0
- package/src/shared/http-json.js +28 -0
- package/src/{delegate/audit-readers.js → shared/terminal-evidence.js} +34 -4
- package/src/worktree/errors.js +3 -0
- package/src/worktree/index.js +1 -2
- package/src/worktree/lane-core.js +641 -0
- package/src/worktree/lane-views.js +147 -0
- package/src/worktree/lane-workflows.js +853 -0
- package/src/worktree/lanes.js +24 -1513
- package/src/worktree/runner.js +5 -4
- package/src/capabilities/content-pin.js +0 -88
- package/src/capabilities/content-refresh.js +0 -331
- package/src/capabilities/distribution-manifest.js +0 -78
- package/src/capabilities/isolated-exec.js +0 -106
- package/src/capabilities/publisher.js +0 -129
- package/src/capabilities/skill-admission.js +0 -181
- package/src/capabilities/update-lifecycle.js +0 -106
- package/src/edit-lock/remote-service.js +0 -28
- package/src/worktree/contract-guidance.test.js +0 -100
|
@@ -195,11 +195,25 @@ export function createEditLockLifecycle(runtime, sessionForAgent, options = {})
|
|
|
195
195
|
}
|
|
196
196
|
/** Register once before publishing an agent's editing tools. A session the
|
|
197
197
|
* manager already knows (restart, re-created agent) is never re-armed: it
|
|
198
|
-
* starts interrupted and needs trusted resume.
|
|
198
|
+
* starts interrupted and needs trusted resume. Presenting the SAME agent
|
|
199
|
+
* object again adopts the established entry instead of registering twice
|
|
200
|
+
* (change edit-lock-shared-domain-mounts, design D2). @param {object} agent
|
|
199
201
|
* @returns {Promise<'active'|'interrupted'>} */
|
|
200
202
|
function start(agent) {
|
|
201
203
|
if (closed) return Promise.reject(new Error('edit lifecycle closed'))
|
|
202
|
-
|
|
204
|
+
const existing = entries.get(agent)
|
|
205
|
+
if (existing) {
|
|
206
|
+
// Same-agent binding adoption: a sibling mount's idempotent re-setup
|
|
207
|
+
// presents the same agent object and takes the established start result
|
|
208
|
+
// verbatim — no openSession, no revocation, no epoch advance, no state
|
|
209
|
+
// change. A stopped/revoked entry keeps its state: adoption returns its
|
|
210
|
+
// established result, and business ingress stays gated by
|
|
211
|
+
// denyStopped/business until a trusted resume. Identity continuity is
|
|
212
|
+
// required — a host that reused the agent object for a different
|
|
213
|
+
// session keeps the duplicate-registration refusal.
|
|
214
|
+
if (sessionForAgent(agent) === existing.sessionId) return existing.ready
|
|
215
|
+
return Promise.reject(new Error('agent already registered'))
|
|
216
|
+
}
|
|
203
217
|
const sessionId = sessionForAgent(agent)
|
|
204
218
|
if (!sessionId) return Promise.reject(new Error('unregistered lifecycle agent'))
|
|
205
219
|
const prior = [...entries].find(([, entry]) => entry.sessionId === sessionId)
|
|
@@ -213,10 +227,12 @@ export function createEditLockLifecycle(runtime, sessionForAgent, options = {})
|
|
|
213
227
|
? (/** @type {any} */ (prior[0]).remote !== true && holderStatus(prior[0]) === 'idle' ? 'serving-idle' : 'serving')
|
|
214
228
|
: 'unknown'
|
|
215
229
|
const refusal = () => {
|
|
216
|
-
const
|
|
230
|
+
const pid = /** @type {any} */ (prior[0]).remote === true ? undefined : process.pid
|
|
231
|
+
const reason = bindingRefusalReason(pid, attestation)
|
|
232
|
+
const failure = bindingFailure(pid, attestation)
|
|
217
233
|
// Remote callers render after their retry budget; local callers have no wire.
|
|
218
|
-
return Object.assign(new Error(/** @type {any} */ (agent).remote === true ? reason : `${reason}: ${bindingGuidance(sessionId,
|
|
219
|
-
{ bindingFailure:
|
|
234
|
+
return Object.assign(new Error(/** @type {any} */ (agent).remote === true ? reason : `${reason}: ${bindingGuidance(sessionId, failure)}`),
|
|
235
|
+
{ bindingFailure: failure })
|
|
220
236
|
}
|
|
221
237
|
// Remote entries are channel proxies, NOT runtime identities. Only the
|
|
222
238
|
// endpoint's nonce attestation may reclaim them (including same-process).
|
|
@@ -280,8 +296,9 @@ export function createEditLockLifecycle(runtime, sessionForAgent, options = {})
|
|
|
280
296
|
// re-attempts the transfer.
|
|
281
297
|
return forget(prior[0]).then(() => start(agent), () => {
|
|
282
298
|
const reason = bindingRefusalReason(process.pid, 'serving-idle')
|
|
283
|
-
|
|
284
|
-
|
|
299
|
+
const failure = bindingFailure(process.pid, 'serving-idle')
|
|
300
|
+
throw Object.assign(new Error(/** @type {any} */ (agent).remote === true ? reason : `${reason}: ${bindingGuidance(sessionId, failure)}`),
|
|
301
|
+
{ bindingFailure: failure })
|
|
285
302
|
})
|
|
286
303
|
}
|
|
287
304
|
return start(agent)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Edit Lock retention policy: the defaults and the overlay resolver the
|
|
2
|
+
// lifecycle (edit-lock/index.js) consumes. Moved verbatim out of
|
|
3
|
+
// src/settings/sections.js (subsystem-seam-hygiene, D4) — the owner subsystem
|
|
4
|
+
// now holds the policy; settings' field-default parity consumes this module
|
|
5
|
+
// directly (the temporary sections.js re-export was removed by dead-export-cleanup).
|
|
6
|
+
|
|
7
|
+
/** Resolved Edit Lock retention policy. The flat keys are declared in the
|
|
8
|
+
* settings sections table; this is the ONE place that turns them into the
|
|
9
|
+
* values the lifecycle consumes, so defaults and validation cannot drift
|
|
10
|
+
* apart. */
|
|
11
|
+
export const EDIT_LOCK_DEFAULTS = Object.freeze({
|
|
12
|
+
holdDefaultMinutes: 30,
|
|
13
|
+
holdSingleMaxMinutes: 30,
|
|
14
|
+
holdCumulativeMaxMinutes: 120,
|
|
15
|
+
nudgeAttempts: 2,
|
|
16
|
+
nudgeFallback: 'release',
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
/** Overlay one Edit Lock section onto the defaults. A key the user never set
|
|
20
|
+
* falls back to its default; a key that is present but unusable (alone or in
|
|
21
|
+
* combination) throws with the flat settings key named, because silently clamping
|
|
22
|
+
* a retention cap would change how long other sessions stay blocked from those
|
|
23
|
+
* files. The Edit Lock composition refuses retention requests with that error and
|
|
24
|
+
* runs every other path (settling, status, release, stop, unlock) on defaults.
|
|
25
|
+
* @param {any} section */
|
|
26
|
+
export function editLockLimits(section) {
|
|
27
|
+
const value = section && typeof section === 'object' ? section : {}
|
|
28
|
+
/** @type {{holdDefaultMinutes: number, holdSingleMaxMinutes: number, holdCumulativeMaxMinutes: number, nudgeAttempts: number, nudgeFallback: 'release'|'abnormal'}} */
|
|
29
|
+
const limits = { ...EDIT_LOCK_DEFAULTS }
|
|
30
|
+
/** @param {'holdDefaultMinutes'|'holdSingleMaxMinutes'|'holdCumulativeMaxMinutes'} key @param {string} name */
|
|
31
|
+
const take = (key, name) => {
|
|
32
|
+
const input = value[key]
|
|
33
|
+
if (input === undefined) return
|
|
34
|
+
if (typeof input !== 'number' || !Number.isFinite(input) || input <= 0) {
|
|
35
|
+
throw new Error(`weir-settings: ${name} must be a positive number`)
|
|
36
|
+
}
|
|
37
|
+
limits[key] = input
|
|
38
|
+
}
|
|
39
|
+
take('holdDefaultMinutes', 'editLockHoldDefaultMinutes')
|
|
40
|
+
take('holdSingleMaxMinutes', 'editLockHoldSingleMaxMinutes')
|
|
41
|
+
take('holdCumulativeMaxMinutes', 'editLockHoldCumulativeMaxMinutes')
|
|
42
|
+
if (limits.holdDefaultMinutes > limits.holdSingleMaxMinutes) {
|
|
43
|
+
throw new Error('weir-settings: editLockHoldDefaultMinutes must not exceed editLockHoldSingleMaxMinutes')
|
|
44
|
+
}
|
|
45
|
+
if (limits.holdCumulativeMaxMinutes < limits.holdSingleMaxMinutes) {
|
|
46
|
+
throw new Error('weir-settings: editLockHoldCumulativeMaxMinutes must be at least editLockHoldSingleMaxMinutes')
|
|
47
|
+
}
|
|
48
|
+
if (value.nudgeAttempts !== undefined) {
|
|
49
|
+
if (!Number.isSafeInteger(value.nudgeAttempts) || value.nudgeAttempts < 0) {
|
|
50
|
+
throw new Error('weir-settings: editLockNudgeAttempts must be a non-negative integer')
|
|
51
|
+
}
|
|
52
|
+
limits.nudgeAttempts = value.nudgeAttempts
|
|
53
|
+
}
|
|
54
|
+
if (value.nudgeFallback !== undefined) {
|
|
55
|
+
if (value.nudgeFallback !== 'release' && value.nudgeFallback !== 'abnormal') {
|
|
56
|
+
throw new Error('weir-settings: editLockNudgeFallback must be "release" or "abnormal"')
|
|
57
|
+
}
|
|
58
|
+
limits.nudgeFallback = value.nudgeFallback
|
|
59
|
+
}
|
|
60
|
+
return Object.freeze(limits)
|
|
61
|
+
}
|
|
@@ -6,7 +6,7 @@ import { join } from 'node:path'
|
|
|
6
6
|
import { AUTHORITY_DIR } from './domains.js'
|
|
7
7
|
import { reservationPathFor } from './reservation.js'
|
|
8
8
|
import { parseSnapshot } from './snapshot.js'
|
|
9
|
-
import { maintenanceState, summarizeAuthorityImage } from './inspect.js'
|
|
9
|
+
import { maintenanceState, summarizeAuthorityImage, recoveryProposals } from './inspect.js'
|
|
10
10
|
import { readSnapshotBytes, checkedPath, verifyPins, missing, errorMessage, MAX_SNAPSHOT_BYTES } from './read-authority.js'
|
|
11
11
|
import { recoverAuthority } from './admin-recovery.js'
|
|
12
12
|
import { createAudit, AUDIT_TYPES } from '../shared/audit.js'
|
|
@@ -141,7 +141,10 @@ export function inspectAuthority(root) {
|
|
|
141
141
|
if (read.presence === 'not-a-file') return { ...base, presence: 'not-a-file' }
|
|
142
142
|
if (read.presence !== 'valid') return { ...base, presence: 'unreadable', message: read.message }
|
|
143
143
|
try {
|
|
144
|
-
|
|
144
|
+
const parsed = parseSnapshot(read.bytes, root)
|
|
145
|
+
// Both projections derive from the SAME parsed image: the snapshot summary
|
|
146
|
+
// and the server-computed recovery proposals (additive, schemaVersion 1).
|
|
147
|
+
return { ...base, presence: 'valid', snapshot: summarizeAuthorityImage(parsed), recoveryProposals: recoveryProposals(root, parsed) }
|
|
145
148
|
} catch (error) {
|
|
146
149
|
return { ...base, presence: 'corrupt', message: errorMessage(error) }
|
|
147
150
|
}
|
package/src/edit-lock/manager.js
CHANGED
|
@@ -4,7 +4,8 @@ import { bindRequest } from './request-binding.js'
|
|
|
4
4
|
import { lookupOperation } from './operation-history.js'
|
|
5
5
|
import { canonicalRequestData } from './request-data.js'
|
|
6
6
|
import { admitMutation as admitHistory, publicationCandidate } from './admission.js'
|
|
7
|
-
import { administrativeState, AUTOMATIC_RECOVERY_ACTOR, digest,
|
|
7
|
+
import { administrativeState, AUTOMATIC_RECOVERY_ACTOR, digest, LATE_WRITER_RISK, ONLINE_RECOVERY_ACTOR } from './admin-ledger.js'
|
|
8
|
+
import { dispositionFor, revokedOwner } from './derived-facts.js'
|
|
8
9
|
import { canonical } from './snapshot.js'
|
|
9
10
|
import { incarnationProcess, withIncarnation } from './incarnations.js'
|
|
10
11
|
|
|
@@ -395,8 +396,11 @@ function managerCore(store, kernel, { processIdentity = null, root = null, write
|
|
|
395
396
|
if (cancelled.has(captured.sessionId)) throw new Error('session cancelled')
|
|
396
397
|
// A revoked owner is NOT refused here: the resume commits the epoch
|
|
397
398
|
// advance that atomically ends the revocation (no ledger change).
|
|
399
|
+
// Admission runs in the resume mode: the epoch advance publishes nothing,
|
|
400
|
+
// so unresolved resource-level fences never block the trusted Continue;
|
|
401
|
+
// the fenced resources stay barred to every other ingress.
|
|
398
402
|
return draft.operations.resume(captured, requestId, receipt)
|
|
399
|
-
}, () => sessionResources(captured.sessionId)).then(async resumed => {
|
|
403
|
+
}, () => sessionResources(captured.sessionId), 'resume').then(async resumed => {
|
|
400
404
|
const cancellation = cancelled.get(captured.sessionId)
|
|
401
405
|
if (cancellation) {
|
|
402
406
|
await cancellation
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isAbsolute, relative } from 'node:path'
|
|
2
|
-
import { dispositionFor } from './
|
|
2
|
+
import { dispositionFor } from './derived-facts.js'
|
|
3
3
|
|
|
4
4
|
/** Historical assertions only: no authentication, live permission or FS publication.
|
|
5
5
|
* Digests MUST be computed by a future trusted publisher over canonical requests,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { asBindingFailure } from './binding-guidance.js'
|
|
1
2
|
import { canonicalRequestData } from './request-data.js'
|
|
2
3
|
import { PEER_KINDS } from './peer.js'
|
|
3
4
|
|
|
@@ -39,15 +40,21 @@ export function createEditLockPeerClient(stream, hooks = {}) {
|
|
|
39
40
|
try { hooks.onEvent?.(message.event) } catch { /* notices never imply consent */ }
|
|
40
41
|
continue
|
|
41
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).
|
|
42
46
|
if (!message || typeof message.callId !== 'string' || typeof message.ok !== 'boolean'
|
|
43
|
-
||
|
|
47
|
+
|| (message.ok ? frameKeys !== 'callId,ok,outcome' : frameKeys !== 'callId,error,ok' && frameKeys !== 'bindingFailure,callId,error,ok')
|
|
44
48
|
|| (!message.ok && typeof message.error !== 'string')) { close(); return }
|
|
45
49
|
const entry = pending.get(message.callId)
|
|
46
50
|
if (!entry) { close(); return }
|
|
47
51
|
pending.delete(message.callId)
|
|
48
52
|
entry.cleanup()
|
|
49
53
|
if (message.ok) entry.resolve(message.outcome)
|
|
50
|
-
else
|
|
54
|
+
else {
|
|
55
|
+
const failure = asBindingFailure(message.bindingFailure)
|
|
56
|
+
entry.reject(Object.assign(new Error(message.error), failure ? { bindingFailure: failure } : {}))
|
|
57
|
+
}
|
|
51
58
|
}
|
|
52
59
|
}
|
|
53
60
|
stream.on('data', receive)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { asBindingFailure } from './binding-guidance.js'
|
|
1
2
|
import { canonicalRequestData } from './request-data.js'
|
|
2
3
|
|
|
3
4
|
/** Attach ONLY an authenticated, host-owned byte stream. Never accepts public
|
|
@@ -54,7 +55,12 @@ export function serveEditLockPeer(stream, peer) {
|
|
|
54
55
|
const callId = request.callId
|
|
55
56
|
void peer.receive(request).then(
|
|
56
57
|
outcome => { try { send({callId, ok:true, outcome:outcome ?? null}) } catch { close() } },
|
|
57
|
-
error => {
|
|
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
|
+
},
|
|
58
64
|
)
|
|
59
65
|
}
|
|
60
66
|
}
|
package/src/edit-lock/remote.js
CHANGED
|
@@ -3,7 +3,7 @@ import { lstatSync, realpathSync, unlinkSync } from 'node:fs'
|
|
|
3
3
|
import { createConnection, createServer } from 'node:net'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
|
-
import { bindingGuidance, bindingRefusal } from './binding-guidance.js'
|
|
6
|
+
import { asBindingFailure, bindingGuidance, bindingRefusal } from './binding-guidance.js'
|
|
7
7
|
import { createEditLockPeer } from './peer.js'
|
|
8
8
|
import { serveEditLockPeer } from './peer-transport.js'
|
|
9
9
|
import { createEditLockPeerClient } from './peer-client.js'
|
|
@@ -275,12 +275,16 @@ export function createRemoteEditLockDomain(endpoint, hooks) {
|
|
|
275
275
|
for (let attempt = 0; ; attempt++) {
|
|
276
276
|
try { return await openChannel(agent, retry) } catch (error) {
|
|
277
277
|
if (/** @type {any} */ (error)?.answeredRefusal !== true) throw error
|
|
278
|
-
|
|
278
|
+
// The retry budget classifies on the STRUCTURED refusal field, never on
|
|
279
|
+
// the display text (change edit-lock-deepening, design D2): only an
|
|
280
|
+
// answered, publisher-classified duplicate binding is retried — the
|
|
281
|
+
// publisher may not yet have processed the earlier channel's death.
|
|
282
|
+
if (!retry && /** @type {any} */ (error)?.bindingFailure?.code === 'binding-duplicate' && attempt < 20) {
|
|
279
283
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
280
284
|
continue
|
|
281
285
|
}
|
|
282
286
|
const reason = String(/** @type {any} */ (error)?.message ?? error)
|
|
283
|
-
const guidance = bindingGuidance(agent.id,
|
|
287
|
+
const guidance = bindingGuidance(agent.id, /** @type {any} */ (error)?.bindingFailure)
|
|
284
288
|
throw Object.assign(new Error(`edit lock duplicate session binding: ${guidance} (publisher answered: ${reason})`), { bindingFailure: /** @type {any} */ (error)?.bindingFailure })
|
|
285
289
|
}
|
|
286
290
|
}
|
|
@@ -317,7 +321,10 @@ export function createRemoteEditLockDomain(endpoint, hooks) {
|
|
|
317
321
|
let opened
|
|
318
322
|
try {
|
|
319
323
|
opened = await channel.client.request('open', 'open', { sessionId: agent.id, nonce, pid: process.pid, bindingFailures: true, retry }, never)
|
|
320
|
-
|
|
324
|
+
// The answered refusal's structured field is validated on receipt;
|
|
325
|
+
// only the exact minted shape becomes decision input.
|
|
326
|
+
const refused = opened?.state === 'unavailable' ? asBindingFailure(opened.bindingFailure) : undefined
|
|
327
|
+
if (refused) throw Object.assign(new Error(opened.reason), { bindingFailure: refused })
|
|
321
328
|
} catch (error) {
|
|
322
329
|
const message = String(/** @type {any} */ (error)?.message ?? error)
|
|
323
330
|
// No answer ever arrived — the channel died mid-open or the request
|
package/src/edit-lock/state.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* @typedef {{ now?: number, singleMaxMs: number, cumulativeMaxMs: number }} HoldCaps
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import { settleHoldFacts } from './derived-facts.js'
|
|
27
28
|
/** Private transfer seam; no raw hydration is exported. */
|
|
28
29
|
const cores = new WeakMap()
|
|
29
30
|
/** @param {string} managerIncarnation */
|
|
@@ -165,10 +166,7 @@ export function createEditLockState(managerIncarnation) {
|
|
|
165
166
|
const held = holds.get(sessionId)
|
|
166
167
|
if (!held) return Object.freeze({ sessionId, held: false, expired: false, holdUntil: null, remainingMs: 0, holdCumulativeMs: 0 })
|
|
167
168
|
const owns = [...locks.values()].some(lock => lock.owner === sessionId)
|
|
168
|
-
|
|
169
|
-
const holding = held.holding && owns
|
|
170
|
-
return Object.freeze({ sessionId, held: holding, expired: holding && held.holdUntil !== null && remainingMs === 0,
|
|
171
|
-
holdUntil: held.holdUntil, remainingMs, holdCumulativeMs: held.holdCumulativeMs })
|
|
169
|
+
return Object.freeze(settleHoldFacts(sessionId, held, owns, now))
|
|
172
170
|
},
|
|
173
171
|
}
|
|
174
172
|
|
package/src/edit-lock/view.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* in `technical` and per-lock `detail`, for the expandable detail only.
|
|
11
11
|
*/
|
|
12
12
|
import { isAbsolute, relative, sep } from 'node:path'
|
|
13
|
+
import { revokedOwner, settleHoldFacts } from './derived-facts.js'
|
|
13
14
|
|
|
14
15
|
/** Session states the panel renders, in precedence order. */
|
|
15
16
|
export const VIEW_STATES = Object.freeze(['revoked', 'unavailable', 'stopped', 'attention', 'confirm', 'holding', 'editing', 'idle'])
|
|
@@ -140,21 +141,19 @@ export function buildColdView({ image, sessionId, cwd, root, autoResume = true,
|
|
|
140
141
|
const locks = (Array.isArray(image?.locks) ? image.locks : []).filter(lock => lock !== null && typeof lock === 'object')
|
|
141
142
|
const holds = Array.isArray(image?.holds) ? image.holds : []
|
|
142
143
|
const recoveries = Array.isArray(image?.recovery) ? image.recovery : []
|
|
143
|
-
const adminRecoveries = Array.isArray(image?.adminRecoveries) ? image.adminRecoveries : []
|
|
144
144
|
const session = sessions.find(row => row?.sessionId === sessionId) ?? null
|
|
145
145
|
// The manager derives the revoked flag from the administrative ledger at
|
|
146
|
-
// status time (revokedOwner); the cold read applies the
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
146
|
+
// status time (revokedOwner); the cold read applies the SAME shared rule in
|
|
147
|
+
// its deliberately tolerant partial mode (design D1).
|
|
148
|
+
const revoked = revokedOwner(image, sessionId, { partial: true })
|
|
149
|
+
// Read-time retention settlement through the shared derived fact (design D5,
|
|
150
|
+
// partial mode — byte-equivalent to the kernel's settleHold): a hold counts
|
|
151
|
+
// only while the session owns a lock, and the remaining budget is computed
|
|
152
|
+
// against the read instant, so a missed expiry timer can never leave stale
|
|
153
|
+
// ownership behind a status read. No hold row settles to no retention (null).
|
|
152
154
|
const held = holds.find(row => row?.sessionId === sessionId) ?? null
|
|
153
155
|
const owns = locks.some(lock => lock.owner === sessionId)
|
|
154
|
-
const
|
|
155
|
-
const holding = held?.holding === true && owns
|
|
156
|
-
const retention = held ? { sessionId, held: holding, expired: holding && held.holdUntil !== null && remainingMs === 0,
|
|
157
|
-
holdUntil: held.holdUntil, remainingMs, holdCumulativeMs: held.holdCumulativeMs } : null
|
|
156
|
+
const retention = held ? settleHoldFacts(sessionId, held, owns, now, { partial: true }) : null
|
|
158
157
|
const status = {
|
|
159
158
|
sessionId,
|
|
160
159
|
// Interrupted records a stopped session (a restarted host interrupts every
|
package/src/lsp/admin.js
CHANGED
|
@@ -4,40 +4,18 @@
|
|
|
4
4
|
// resolve through ctx.get so the module mounts harmlessly in compositions
|
|
5
5
|
// without them (headless).
|
|
6
6
|
import { buildRegistry, DEFAULT_SERVERS, displayInstallCommand, installSpecFor, languageIdsForFamily } from './registry.js'
|
|
7
|
-
import { npmGlobalPrefix, resolveExecutable as extendedResolveExecutable, runBounded, spawnArgv } from '
|
|
7
|
+
import { npmGlobalPrefix, resolveExecutable as extendedResolveExecutable, runBounded, spawnArgv } from '../process/index.js'
|
|
8
|
+
import { jsonResponse, readJsonBody } from '../shared/http-json.js'
|
|
8
9
|
|
|
9
10
|
const name = 'weir-lsp-admin'
|
|
10
11
|
const inject = []
|
|
11
12
|
const DEFAULT_INSTALL_TIMEOUT_MS = 600_000
|
|
12
13
|
const VERSION_PROBE_TIMEOUT_MS = 8_000
|
|
13
14
|
|
|
14
|
-
export function jsonResponse(payload, status = 200) {
|
|
15
|
-
return new Response(JSON.stringify(payload), {
|
|
16
|
-
status,
|
|
17
|
-
headers: { 'content-type': 'application/json' },
|
|
18
|
-
})
|
|
19
|
-
}
|
|
20
|
-
|
|
21
15
|
export function errorResponse(message, status = 400) {
|
|
22
16
|
return jsonResponse({ ok: false, error: { code: `weir-lsp/${status === 400 ? 'invalid' : 'internal'}`, message } }, status)
|
|
23
17
|
}
|
|
24
18
|
|
|
25
|
-
/** Read the JSON body of a connection request (Request-like or stream). */
|
|
26
|
-
export async function readJsonBody(request) {
|
|
27
|
-
try {
|
|
28
|
-
if (typeof request?.json === 'function') return await request.json()
|
|
29
|
-
} catch {
|
|
30
|
-
return null
|
|
31
|
-
}
|
|
32
|
-
try {
|
|
33
|
-
const chunks = []
|
|
34
|
-
for await (const chunk of request?.body ?? []) chunks.push(Buffer.from(chunk))
|
|
35
|
-
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
36
|
-
} catch {
|
|
37
|
-
return null
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
19
|
/** Accept only lines that plausibly carry a version (stack traces and
|
|
42
20
|
* paths from servers without --version support are dropped). */
|
|
43
21
|
export function looksLikeVersion(line) {
|
package/src/lsp/index.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// ESM, ctx-only.
|
|
9
9
|
import { createLspManager, LSP_DEFAULTS } from './manager.js'
|
|
10
10
|
import { buildRegistry } from './registry.js'
|
|
11
|
-
import { resolveExecutable as extendedResolveExecutable } from '
|
|
11
|
+
import { resolveExecutable as extendedResolveExecutable } from '../process/index.js'
|
|
12
12
|
import { createLspTools } from './tools.js'
|
|
13
13
|
|
|
14
14
|
const name = 'weir-lsp'
|
package/src/lsp/manager.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// lazy start, full-document sync, idle shutdown, per-session holder refcounts.
|
|
3
3
|
import { createLspClient, handshake, shutdownClient } from './client.js'
|
|
4
4
|
import { familyForLanguageId } from './registry.js'
|
|
5
|
-
import { childEnvironment, spawnArgv } from '
|
|
5
|
+
import { childEnvironment, spawnArgv } from '../process/index.js'
|
|
6
6
|
import { pathToUri } from './uri.js'
|
|
7
7
|
|
|
8
8
|
export const LSP_DEFAULTS = {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// connection service resolves from that scope (S20). The platform and the
|
|
5
5
|
// process spawner are injected for tests.
|
|
6
6
|
import { execFile as nodeExecFile } from 'node:child_process'
|
|
7
|
-
import { jsonResponse, readJsonBody } from '../
|
|
7
|
+
import { jsonResponse, readJsonBody } from '../shared/http-json.js'
|
|
8
8
|
import { ACTIONS, readNotificationPermission, runPermissionAction, SENDER } from './permissions.js'
|
|
9
9
|
|
|
10
10
|
const STATUS_PATH = '/api/weir-notify/permissions/status'
|
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Generic process machinery: executable resolution, launch-shape
|
|
2
|
+
// adjudication, and deadline-bounded runs — one concept, three stages
|
|
3
|
+
// (resolve → launch shape → bounded run). The desktop app is
|
|
2
4
|
// a GUI process launched by LaunchServices, so its PATH is the minimal
|
|
3
5
|
// `/usr/bin:/bin:/usr/sbin:/sbin` — Homebrew (/opt/homebrew, /usr/local),
|
|
4
6
|
// nvm, cargo, go, pipx and npm-global bins are all missing, and
|
|
5
7
|
// ctx.subprocess.resolveExecutable() fails for them (S21). This helper
|
|
6
|
-
// falls back to scanning well-known installation directories so
|
|
7
|
-
//
|
|
8
|
+
// falls back to scanning well-known installation directories so spawned
|
|
9
|
+
// tools resolve the same way they do in the user's shell.
|
|
8
10
|
//
|
|
9
11
|
// Windows resolves through the same helper with a different dialect: npm puts
|
|
10
12
|
// its global shims in `%APPDATA%\npm` (not `~/.npm-global/bin`), bare names
|
|
11
13
|
// carry a PATHEXT extension, and the PATH separator is `;`.
|
|
12
|
-
//
|
|
14
|
+
//
|
|
15
|
+
// No LSP-specific semantics live here: the LSP subsystem (server lifecycle,
|
|
16
|
+
// version probe, panel installer) and the worktree git runner are peer
|
|
17
|
+
// consumers. Moved verbatim out of src/lsp/child-process.js
|
|
18
|
+
// (subsystem-seam-hygiene, D1); npm install POLICY stays in src/lsp/.
|
|
13
19
|
import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
14
20
|
import { homedir } from 'node:os'
|
|
15
21
|
import { dirname, join, join as nodeJoin } from 'node:path'
|
|
@@ -304,6 +310,7 @@ function expandShimToken(token, text, shimDirectory, join) {
|
|
|
304
310
|
function tokenizeShimLine(line) {
|
|
305
311
|
const tokens = []
|
|
306
312
|
let current = ''
|
|
313
|
+
/** @type {string | null} */
|
|
307
314
|
let quote = null
|
|
308
315
|
let escaped = false
|
|
309
316
|
for (const character of line) {
|
|
@@ -495,6 +502,7 @@ export async function runBounded(subprocess, { argv, timeoutMs, unref = false })
|
|
|
495
502
|
})
|
|
496
503
|
let timedOut = false
|
|
497
504
|
let timer
|
|
505
|
+
/** @type {Promise<void>} */
|
|
498
506
|
const deadline = new Promise((resolve) => {
|
|
499
507
|
timer = setTimeout(() => {
|
|
500
508
|
timedOut = true
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// settings row through ctx.inject so no new package subpath is needed (S19),
|
|
5
5
|
// same pattern as wireLspAdmin / wireNotifyPermissions. The platform is
|
|
6
6
|
// injected for tests.
|
|
7
|
-
import { jsonResponse } from '../
|
|
7
|
+
import { jsonResponse } from '../shared/http-json.js'
|
|
8
8
|
|
|
9
9
|
const ENV_PATH = '/api/weir-settings/env'
|
|
10
10
|
|
package/src/settings/index.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// config contains only the values the user actually set, so the settings
|
|
10
10
|
// service can overlay them over preset-row config without clobbering
|
|
11
11
|
// bundle-shipped defaults (layering: module defaults ← row config ← service).
|
|
12
|
-
import { createWhitelistDefaultsCache, DEFAULT_WHITELIST_PATH } from '../
|
|
12
|
+
import { createWhitelistDefaultsCache, DEFAULT_WHITELIST_PATH } from '../delegate/whitelist-defaults.js'
|
|
13
13
|
import { configValue } from './volatile.js'
|
|
14
14
|
import { computeSections } from './sections.js'
|
|
15
15
|
import { wireLspAdmin } from '../lsp/admin.js'
|
package/src/settings/sections.js
CHANGED
|
@@ -149,60 +149,6 @@ export const FIELD_DEFAULTS = Object.freeze({
|
|
|
149
149
|
blackboardWriteTokenTtlMinutes: 60,
|
|
150
150
|
})
|
|
151
151
|
|
|
152
|
-
/** Resolved Edit Lock retention policy. The keys are declared above; this is the
|
|
153
|
-
* ONE place that turns them into the values the lifecycle consumes, so defaults
|
|
154
|
-
* and validation cannot drift apart. */
|
|
155
|
-
export const EDIT_LOCK_DEFAULTS = Object.freeze({
|
|
156
|
-
holdDefaultMinutes: 30,
|
|
157
|
-
holdSingleMaxMinutes: 30,
|
|
158
|
-
holdCumulativeMaxMinutes: 120,
|
|
159
|
-
nudgeAttempts: 2,
|
|
160
|
-
nudgeFallback: 'release',
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
/** Overlay one Edit Lock section onto the defaults. A key the user never set
|
|
164
|
-
* falls back to its default; a key that is present but unusable (alone or in
|
|
165
|
-
* combination) throws with the flat settings key named, because silently clamping
|
|
166
|
-
* a retention cap would change how long other sessions stay blocked from those
|
|
167
|
-
* files. The Edit Lock composition refuses retention requests with that error and
|
|
168
|
-
* runs every other path (settling, status, release, stop, unlock) on defaults.
|
|
169
|
-
* @param {any} section */
|
|
170
|
-
export function editLockLimits(section) {
|
|
171
|
-
const value = section && typeof section === 'object' ? section : {}
|
|
172
|
-
/** @type {{holdDefaultMinutes: number, holdSingleMaxMinutes: number, holdCumulativeMaxMinutes: number, nudgeAttempts: number, nudgeFallback: 'release'|'abnormal'}} */
|
|
173
|
-
const limits = { ...EDIT_LOCK_DEFAULTS }
|
|
174
|
-
/** @param {'holdDefaultMinutes'|'holdSingleMaxMinutes'|'holdCumulativeMaxMinutes'} key @param {string} name */
|
|
175
|
-
const take = (key, name) => {
|
|
176
|
-
const input = value[key]
|
|
177
|
-
if (input === undefined) return
|
|
178
|
-
if (typeof input !== 'number' || !Number.isFinite(input) || input <= 0) {
|
|
179
|
-
throw new Error(`weir-settings: ${name} must be a positive number`)
|
|
180
|
-
}
|
|
181
|
-
limits[key] = input
|
|
182
|
-
}
|
|
183
|
-
take('holdDefaultMinutes', 'editLockHoldDefaultMinutes')
|
|
184
|
-
take('holdSingleMaxMinutes', 'editLockHoldSingleMaxMinutes')
|
|
185
|
-
take('holdCumulativeMaxMinutes', 'editLockHoldCumulativeMaxMinutes')
|
|
186
|
-
if (limits.holdDefaultMinutes > limits.holdSingleMaxMinutes) {
|
|
187
|
-
throw new Error('weir-settings: editLockHoldDefaultMinutes must not exceed editLockHoldSingleMaxMinutes')
|
|
188
|
-
}
|
|
189
|
-
if (limits.holdCumulativeMaxMinutes < limits.holdSingleMaxMinutes) {
|
|
190
|
-
throw new Error('weir-settings: editLockHoldCumulativeMaxMinutes must be at least editLockHoldSingleMaxMinutes')
|
|
191
|
-
}
|
|
192
|
-
if (value.nudgeAttempts !== undefined) {
|
|
193
|
-
if (!Number.isSafeInteger(value.nudgeAttempts) || value.nudgeAttempts < 0) {
|
|
194
|
-
throw new Error('weir-settings: editLockNudgeAttempts must be a non-negative integer')
|
|
195
|
-
}
|
|
196
|
-
limits.nudgeAttempts = value.nudgeAttempts
|
|
197
|
-
}
|
|
198
|
-
if (value.nudgeFallback !== undefined) {
|
|
199
|
-
if (value.nudgeFallback !== 'release' && value.nudgeFallback !== 'abnormal') {
|
|
200
|
-
throw new Error('weir-settings: editLockNudgeFallback must be "release" or "abnormal"')
|
|
201
|
-
}
|
|
202
|
-
limits.nudgeFallback = value.nudgeFallback
|
|
203
|
-
}
|
|
204
|
-
return Object.freeze(limits)
|
|
205
|
-
}
|
|
206
152
|
const zs = z
|
|
207
153
|
|
|
208
154
|
function fieldSchema(type) {
|
package/src/shared/audit.js
CHANGED
|
@@ -35,6 +35,11 @@ export const AUDIT_TYPES = Object.freeze({
|
|
|
35
35
|
// identity, feed census and provider status, never skill names; the receipt
|
|
36
36
|
// itself stays the only effective-state authority.
|
|
37
37
|
capabilityRead: 'capability-read',
|
|
38
|
+
// The pending model-facing capability notice (durable-pending-notice D7):
|
|
39
|
+
// queue / degrade / deliver / clear outcomes of the durable notice unit.
|
|
40
|
+
// Counts only, never names — the same rule as capability-read above; the
|
|
41
|
+
// accepted selection record stays the only effective-state authority.
|
|
42
|
+
capabilityNotice: 'capability-notice',
|
|
38
43
|
// Session blackboard release facts (design D3): one record per key release
|
|
39
44
|
// that had waiting subscribers; the emission lives in the blackboard plugin
|
|
40
45
|
// and never touches a session log.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Generic HTTP JSON helpers for the host-side admin endpoints: Response
|
|
2
|
+
// shaping and request-body reading shared by the lsp, settings, and notify
|
|
3
|
+
// admin modules. Sunk verbatim out of src/lsp/admin.js
|
|
4
|
+
// (subsystem-seam-hygiene, D2); the LSP-specific error namespace
|
|
5
|
+
// (errorResponse) and endpoint assembly (wireLspAdmin) stay in src/lsp/.
|
|
6
|
+
|
|
7
|
+
export function jsonResponse(payload, status = 200) {
|
|
8
|
+
return new Response(JSON.stringify(payload), {
|
|
9
|
+
status,
|
|
10
|
+
headers: { 'content-type': 'application/json' },
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Read the JSON body of a connection request (Request-like or stream). */
|
|
15
|
+
export async function readJsonBody(request) {
|
|
16
|
+
try {
|
|
17
|
+
if (typeof request?.json === 'function') return await request.json()
|
|
18
|
+
} catch {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const chunks = []
|
|
23
|
+
for await (const chunk of request?.body ?? []) chunks.push(Buffer.from(chunk))
|
|
24
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
25
|
+
} catch {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -1,9 +1,39 @@
|
|
|
1
|
-
//
|
|
2
|
-
// child
|
|
3
|
-
//
|
|
1
|
+
// Terminal evidence — the single definition site for what proves a supervised
|
|
2
|
+
// child reached a terminal state: the STATUS/REPORT parser, the terminal-status
|
|
3
|
+
// predicate (its JSDoc carries the one blocked-vs-terminal rule every consumer
|
|
4
|
+
// refers to), and the two best-effort cold readers (the bounded audit-JSONL
|
|
5
|
+
// tail; a child log's final assistant text). Delegate supervision and worktree
|
|
6
|
+
// zombie-lane reconciliation both import from here, so neither subsystem
|
|
7
|
+
// reaches into the other for this concept.
|
|
8
|
+
//
|
|
9
|
+
// Load-bearing tolerance semantics (zombie-lane safety): readAuditTail's 256KB
|
|
10
|
+
// bounded window and error tolerance (missing/unreadable → [], a partial first
|
|
11
|
+
// line dropped, malformed lines skipped) make an aged-out fact degrade to "no
|
|
12
|
+
// evidence", never to a wrong release. Keep them byte-exact.
|
|
13
|
+
//
|
|
14
|
+
// Ports & adapters: the readers take their services as parameters (never ctx).
|
|
15
|
+
// node: imports are the src/shared precedent (audit.js);
|
|
16
|
+
// the node:-free constraint guards the delegate composition root, not shared.
|
|
4
17
|
import { openSync, readSync, closeSync, statSync } from 'node:fs'
|
|
5
18
|
import { join } from 'node:path'
|
|
6
|
-
import { contentText } from '
|
|
19
|
+
import { contentText } from './content-text.js'
|
|
20
|
+
|
|
21
|
+
/** Parse a child's final assistant text into a terminal status, or null. */
|
|
22
|
+
export function parseTerminalStatus(text) {
|
|
23
|
+
if (typeof text !== 'string' || text.length === 0) return null
|
|
24
|
+
const match = /^STATUS:[ \t]*(completed|blocked)[ \t]*$/m.exec(text)
|
|
25
|
+
if (!match) return null
|
|
26
|
+
const reportMatch = /^REPORT:[ \t]*([\s\S]*)$/m.exec(text.slice(match.index))
|
|
27
|
+
return { status: match[1], report: (reportMatch?.[1] ?? '').trim() }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Single exit for the terminal-status predicate: statuses that count toward
|
|
32
|
+
* group completion (blocked waits for a resume, so it is NOT terminal here).
|
|
33
|
+
*/
|
|
34
|
+
export function isTerminalStatus(status) {
|
|
35
|
+
return status === 'completed' || status === 'terminated'
|
|
36
|
+
}
|
|
7
37
|
|
|
8
38
|
/** Audit JSONL location for one session (cold-safe channel; may not exist). */
|
|
9
39
|
export function auditFilePathOf(session) {
|
package/src/worktree/errors.js
CHANGED
|
@@ -18,6 +18,9 @@ export const WORKTREE_CODES = Object.freeze({
|
|
|
18
18
|
UNKNOWN_LANE: 'UNKNOWN_LANE',
|
|
19
19
|
ILLEGAL_TRANSITION: 'ILLEGAL_TRANSITION',
|
|
20
20
|
LANE_NOT_DISPATCHABLE: 'LANE_NOT_DISPATCHABLE',
|
|
21
|
+
// The binding commit's reservation is gone (force-reclaim / reconcile
|
|
22
|
+
// --rebuild) or the lane left `working` before the commit landed.
|
|
23
|
+
LANE_RESERVATION_LOST: 'LANE_RESERVATION_LOST',
|
|
21
24
|
LANE_BUSY: 'LANE_BUSY',
|
|
22
25
|
NOT_LANDABLE: 'NOT_LANDABLE',
|
|
23
26
|
STALE_LANDABLE: 'STALE_LANDABLE',
|