weir-harness 1.2.0 → 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/index.js +44 -244
- package/src/edit-lock/inspect.js +113 -1
- package/src/edit-lock/lifecycle.js +8 -5
- 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
|
@@ -8,24 +8,26 @@
|
|
|
8
8
|
// no permission publication, no external installs, no notifications.
|
|
9
9
|
// 4. Enter the session admission fence; close the gate and start draining
|
|
10
10
|
// removed managed MCP servers (drain is a stub seam until group 8.6).
|
|
11
|
-
// 5. CAS-commit the new selection and receipt through the group-2 store,
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// 5. CAS-commit the new selection and receipt through the group-2 store, which
|
|
12
|
+
// reports the payload its CAS replaced — the authority for the net-change
|
|
13
|
+
// notice and for the gate/drain set — then in ONE non-async segment: swap
|
|
14
|
+
// the in-memory snapshot, call the provider's control.invalidate() and
|
|
15
|
+
// release the fence. invalidate()
|
|
14
16
|
// happens before the fence release and before the step-6 response.
|
|
15
17
|
// 6. Send the response: accepted revision, effective sets, unresolved
|
|
16
18
|
// warnings and a drain snapshot taken at send time (never awaited).
|
|
17
19
|
// Audit and notification are scheduled best-effort after the response; their
|
|
18
20
|
// failure never rolls back accepted policy. Commands and audit logs are not
|
|
19
21
|
// commit evidence: durable acceptance is only the store record.
|
|
20
|
-
// Tasks 4.
|
|
21
|
-
//
|
|
22
|
-
// fence and the authority snapshot exposed here.
|
|
22
|
+
// Tasks 4.6 gate capability calls on the fence and the authority snapshot
|
|
23
|
+
// exposed here.
|
|
23
24
|
import { digestOf } from './store/record.js'
|
|
24
25
|
import { isSegment } from './store/paths.js'
|
|
25
26
|
import { skillIdentityKey } from './skill-identity.js'
|
|
26
27
|
import { validateSkillSelection } from './skill-selection-provider.js'
|
|
27
28
|
import { normalizeEnabledSets } from './selection-draft.js'
|
|
28
29
|
import { AUDIT_TYPES } from '../shared/audit.js'
|
|
30
|
+
import { selectionDelta } from './selection-notify.js'
|
|
29
31
|
|
|
30
32
|
/** Audit event type (without the 'weir/' prefix) emitted after acceptance.
|
|
31
33
|
* Registered in src/shared/audit.js (AUDIT_TYPES); emit sites never invent
|
|
@@ -36,6 +38,14 @@ export const APPLY_AUDIT_TYPE = AUDIT_TYPES.capabilityApply
|
|
|
36
38
|
* @typedef {import('./skill-selection-provider.js')} SelectionProviderModule
|
|
37
39
|
* @typedef {{ kind: 'skill'|'mcp', ref: string, reason: string }} UnresolvedRef
|
|
38
40
|
* @typedef {{ server: string, state: 'draining'|'settled', inFlight: number }} DrainStatus
|
|
41
|
+
* The engine drain seam (step 4). begin(removed, sessionId) closes the gates
|
|
42
|
+
* of the removed servers FOR THAT SESSION and returns a handle: snapshot()
|
|
43
|
+
* reports the live in-flight counts (sent with the response, never awaited)
|
|
44
|
+
* and abort() reopens exactly the gates begin() closed when the Apply does
|
|
45
|
+
* not commit — abort() and begin() act on the SAME drain, so a failed Apply
|
|
46
|
+
* never leaves a session permanently gated. Production wires the manager's
|
|
47
|
+
* (agent, server)-keyed drain here (createSessionDrainAdapter, mcp-drain.js).
|
|
48
|
+
* @typedef {{ begin(removed: string[], sessionId: string): { snapshot(): DrainStatus[], abort(): void } }} ApplyDrain
|
|
39
49
|
* A step-tagged rejection keeps the old authority and the user's draft.
|
|
40
50
|
* @typedef {{ status: 'applied', revision: number, receipt: unknown, effective: unknown, unresolvedWarnings: UnresolvedRef[], draining?: DrainStatus[], warnings?: string[] }
|
|
41
51
|
* | { status: 'duplicate', revision: number, receipt: unknown, effective: unknown, warnings?: string[] }
|
|
@@ -94,10 +104,13 @@ export function createAdmissionFence() {
|
|
|
94
104
|
}
|
|
95
105
|
|
|
96
106
|
/**
|
|
97
|
-
* Drain coordinator for managed MCP servers removed by an Apply
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
107
|
+
* Drain coordinator for managed MCP servers removed by an Apply: the
|
|
108
|
+
* server-keyed DEFAULT seam implementation (the test surface). Production
|
|
109
|
+
* wires the manager's (agent, server)-keyed drain through
|
|
110
|
+
* createSessionDrainAdapter in mcp-drain.js — the engine passes the session
|
|
111
|
+
* id to begin(), which a server-keyed coordinator simply ignores. Closing a
|
|
112
|
+
* gate is reversible until the commit succeeds: a rejected or failed Apply
|
|
113
|
+
* aborts the closure. In-flight calls are reported, never force-cancelled.
|
|
101
114
|
*/
|
|
102
115
|
export function createDrainCoordinator() {
|
|
103
116
|
/** @type {Map<string, { closed: boolean, inFlight: Set<Promise<unknown>> }>} */
|
|
@@ -176,12 +189,12 @@ export function digestApplyRequest(normalized) {
|
|
|
176
189
|
* inventory: (options: { cwd?: string, scope: { session: { id: string } } }) => Promise<{ complete: boolean, candidates: any[] }>,
|
|
177
190
|
* provider: { acceptSelection(identities: unknown[], options?: unknown): { accepted: boolean, conflicts?: unknown[] } },
|
|
178
191
|
* fence?: ReturnType<typeof createAdmissionFence>,
|
|
179
|
-
* drain?:
|
|
192
|
+
* drain?: ApplyDrain,
|
|
180
193
|
* conditions?: () => string[] | Promise<string[]>,
|
|
181
194
|
* verifyContent?: (candidate: unknown) => Promise<void> | void,
|
|
182
195
|
* audit?: (session: { id?: string }, type: string, data?: unknown) => void,
|
|
183
196
|
* notify?: (response: unknown) => unknown,
|
|
184
|
-
* onAccepted?: (sessionId: string, delta: { added: string[], removed: string[] }) => void,
|
|
197
|
+
* onAccepted?: (sessionId: string, delta: { added: string[], removed: string[] }, info: { revision: number }) => void,
|
|
185
198
|
* invalidate?: (sessionId: string, presetId: string) => void,
|
|
186
199
|
* publishSnapshot?: (sessionId: string, snapshot: { revision: number, skills: unknown[], mcpServers: string[] }) => void,
|
|
187
200
|
* warn?: (message: string) => void,
|
|
@@ -197,7 +210,7 @@ export function createApplyEngine(options) {
|
|
|
197
210
|
verifyContent,
|
|
198
211
|
audit, notify, invalidate, publishSnapshot, onAccepted, warn = () => {}, trace = () => {},
|
|
199
212
|
} = options
|
|
200
|
-
/** In-memory authority snapshots per session (D6 seam
|
|
213
|
+
/** In-memory authority snapshots per session (D6 seam): written only by the publication segment, never loaded from disk — after a restart the durable accepted record is the only delta/recovery basis (apply-delta-durable-basis D2). */
|
|
201
214
|
const snapshots = new Map()
|
|
202
215
|
/** Per-session in-process serialization; cross-process exclusion is the store lock. */
|
|
203
216
|
const queues = new Map()
|
|
@@ -205,8 +218,15 @@ export function createApplyEngine(options) {
|
|
|
205
218
|
* Sessions whose last write ended indeterminate: the fence lease stays held
|
|
206
219
|
* and the prepared (never published) transaction is kept so a receipt query
|
|
207
220
|
* can settle it from durable evidence — publish on proof, reopen on proven
|
|
208
|
-
* absence, keep blocking while the record cannot vouch either way.
|
|
209
|
-
*
|
|
221
|
+
* absence, keep blocking while the record cannot vouch either way. The entry
|
|
222
|
+
* captures the publication basis (`basisSets`) of the aborted transaction: at
|
|
223
|
+
* settle time the durable record already holds the LANDED write, so the
|
|
224
|
+
* recovery notice delta cannot re-read it. Which record that is depends on the
|
|
225
|
+
* site — the committed-over record when the commit result proved it (a landed
|
|
226
|
+
* write whose fence was lost before publication), otherwise the transaction-
|
|
227
|
+
* start store read (the commit threw or its outcome is unknown: this process
|
|
228
|
+
* never observed the locked record, so no committed-over proof exists).
|
|
229
|
+
* @type {Map<string, { lease: { release(): void }, requestId: string, prepared: { handles: unknown[], identities: unknown[], view: unknown }, payload: unknown, basisSets: { skills: unknown[], mcpServers: string[] }, drainHandle: { abort(): void } | null, viewOptions: unknown }>}
|
|
210
230
|
*/
|
|
211
231
|
const blocked = new Map()
|
|
212
232
|
|
|
@@ -381,7 +401,7 @@ export function createApplyEngine(options) {
|
|
|
381
401
|
}
|
|
382
402
|
let drainHandle = null
|
|
383
403
|
try {
|
|
384
|
-
drainHandle = removedServers.length ? drain.begin(removedServers) : null
|
|
404
|
+
drainHandle = removedServers.length ? drain.begin(removedServers, sessionId) : null
|
|
385
405
|
} catch (error) {
|
|
386
406
|
lease.release()
|
|
387
407
|
return { status: 'rejected', reason: `drain-failed:${message(error)}`, step: 4 }
|
|
@@ -395,80 +415,90 @@ export function createApplyEngine(options) {
|
|
|
395
415
|
// The write outcome is UNKNOWN: stay blocked (fence held, gates closed)
|
|
396
416
|
// and let a receipt query confirm the authority; never roll back on a guess.
|
|
397
417
|
trace('indeterminate', { sessionId })
|
|
398
|
-
blocked.set(sessionId, { lease, requestId: normalized.requestId, prepared, payload, drainHandle, viewOptions })
|
|
418
|
+
blocked.set(sessionId, { lease, requestId: normalized.requestId, prepared, payload, basisSets: previousSets, drainHandle, viewOptions })
|
|
399
419
|
return { status: 'indeterminate', step: 5 }
|
|
400
420
|
}
|
|
401
421
|
trace('commit', { sessionId, status: result.status })
|
|
402
422
|
|
|
403
423
|
if (result.status === 'committed') {
|
|
424
|
+
// Basis authority (D2/D5): the store hands back the payload its CAS replaced,
|
|
425
|
+
// read under the same per-unit lock. The transaction-start read above is NOT
|
|
426
|
+
// that authority — a foreign writer can land between it and this commit, and
|
|
427
|
+
// the CAS then passes on a revision this engine never saw. A committed result
|
|
428
|
+
// that cannot prove the record (a store double, a future store that does not
|
|
429
|
+
// honour the interface) keeps that read and reports
|
|
430
|
+
// `committed-over-unavailable`: never a fabricated full-set delta.
|
|
431
|
+
const proven = Object.hasOwn(result, 'committedOver')
|
|
432
|
+
const basisSets = proven
|
|
433
|
+
? (result.committedOver === undefined ? { skills: [], mcpServers: [] } : safeSets(result.committedOver))
|
|
434
|
+
: previousSets
|
|
435
|
+
/** @type {string[]} */
|
|
436
|
+
const warnings = []
|
|
437
|
+
const degraded = (code, error) => {
|
|
438
|
+
warnings.push(code)
|
|
439
|
+
if (code === 'accepted-notification-degraded') warn(`apply onAccepted hook failed: ${message(error)}`)
|
|
440
|
+
else if (code === 'committed-over-unavailable') warn('apply commit result cannot prove the committed-over record: notice and drain basis fall back to the transaction-start read')
|
|
441
|
+
}
|
|
442
|
+
if (!proven) degraded('committed-over-unavailable')
|
|
443
|
+
// Two-phase drain (D3): the pre-commit closure above started draining from
|
|
444
|
+
// the transaction-start read — early, but possibly one revision stale.
|
|
445
|
+
// Servers the committed-over record drops that the earlier read could not
|
|
446
|
+
// name get their gates closed HERE, inside the still-held fence and before
|
|
447
|
+
// publication. The residual is a strict difference against the early set, so
|
|
448
|
+
// the two handles are disjoint by construction, and the reconciled handle is
|
|
449
|
+
// never abortable: a commit already landed.
|
|
450
|
+
const residualServers = basisSets.mcpServers.filter(server => !normalized.mcpServers.includes(server) && !removedServers.includes(server))
|
|
451
|
+
let reconciledHandle = null
|
|
452
|
+
if (residualServers.length) {
|
|
453
|
+
try {
|
|
454
|
+
reconciledHandle = drain.begin(residualServers, sessionId)
|
|
455
|
+
} catch (error) {
|
|
456
|
+
// The write is already durable: a reconciliation failure must never
|
|
457
|
+
// become a rejection. Report it and keep what the early handle closed.
|
|
458
|
+
degraded('drain-reconciliation-degraded', error)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
404
461
|
// Re-check the fence before publishing: the lease must still be ours.
|
|
405
462
|
// The revision was already re-checked by the store CAS (expectedRevision).
|
|
406
463
|
if (!fence.isHeld(sessionId)) {
|
|
407
464
|
// The write LANDED but an atomic publication can no longer be
|
|
408
465
|
// guaranteed: stay blocked and let a receipt query publish the
|
|
409
|
-
// authority from durable evidence.
|
|
466
|
+
// authority from durable evidence. The entry carries the basis this
|
|
467
|
+
// transaction would have published with (D4).
|
|
410
468
|
trace('indeterminate', { sessionId, reason: 'fence-lost-before-publish' })
|
|
411
|
-
blocked.set(sessionId, { lease, requestId: normalized.requestId, prepared, payload, drainHandle, viewOptions })
|
|
469
|
+
blocked.set(sessionId, { lease, requestId: normalized.requestId, prepared, payload, basisSets, drainHandle, viewOptions })
|
|
412
470
|
return { status: 'indeterminate', step: 5 }
|
|
413
471
|
}
|
|
414
472
|
// Synchronous publication segment: no await between the snapshot swap,
|
|
415
473
|
// the provider invalidation and the fence release. invalidate() must
|
|
416
474
|
// complete before the fence release and before the step-6 response, so
|
|
417
|
-
// a client that sees the new receipt never refetches a stale list.
|
|
418
|
-
|
|
419
|
-
const warnings = []
|
|
475
|
+
// a client that sees the new receipt never refetches a stale list. The
|
|
476
|
+
// SAME segment runs on the recovery-'published' path (D4).
|
|
420
477
|
trace('snapshot-swap', { sessionId, revision: result.revision })
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
warnings.push('accepted-notification-degraded')
|
|
439
|
-
warn(`apply onAccepted hook failed: ${message(error)}`)
|
|
440
|
-
}
|
|
441
|
-
snapshots.set(sessionId, { revision: result.revision, selection: structuredClone(payload), handles: prepared.handles, view: prepared.view })
|
|
442
|
-
// Task 6.1: load the accepted selection into the shared lifecycle
|
|
443
|
-
// memory snapshot inside the SAME non-async segment, so the
|
|
444
|
-
// agent/created listener's synchronous read never observes a durable
|
|
445
|
-
// acceptance this process has not published. A publication failure
|
|
446
|
-
// degrades to the listener's blocking synchronous disk read of the
|
|
447
|
-
// just-committed record — reported, never fatal.
|
|
448
|
-
try {
|
|
449
|
-
publishSnapshot?.(sessionId, { revision: result.revision, skills: structuredClone(payload.skills), mcpServers: [...payload.mcpServers] })
|
|
450
|
-
} catch {
|
|
451
|
-
warnings.push('snapshot-publication-degraded')
|
|
452
|
-
}
|
|
453
|
-
try {
|
|
454
|
-
trace('invalidate', { sessionId })
|
|
455
|
-
const accepted = provider.acceptSelection(prepared.identities, viewOptions)
|
|
456
|
-
if (!accepted?.accepted) warnings.push('provider-publication-degraded')
|
|
457
|
-
} catch {
|
|
458
|
-
// The record is durably accepted; a failed cache invalidation degrades
|
|
459
|
-
// convergence, never the policy. The warning is reported, not hidden.
|
|
460
|
-
warnings.push('invalidate-failed')
|
|
461
|
-
} finally {
|
|
462
|
-
trace('fence-release', { sessionId })
|
|
463
|
-
lease.release()
|
|
464
|
-
}
|
|
478
|
+
publishAccepted({
|
|
479
|
+
sessionId,
|
|
480
|
+
revision: result.revision,
|
|
481
|
+
payload,
|
|
482
|
+
prepared,
|
|
483
|
+
viewOptions,
|
|
484
|
+
basisSets,
|
|
485
|
+
lease,
|
|
486
|
+
degraded,
|
|
487
|
+
})
|
|
488
|
+
// The response's drain snapshot is the union of both closures: the early
|
|
489
|
+
// one from the transaction-start read and the reconciled one from the
|
|
490
|
+
// committed-over record. Disjoint by construction; absent when empty.
|
|
491
|
+
const draining = [
|
|
492
|
+
...(drainHandle ? drainHandle.snapshot() : []),
|
|
493
|
+
...(reconciledHandle ? reconciledHandle.snapshot() : []),
|
|
494
|
+
]
|
|
465
495
|
const response = {
|
|
466
496
|
status: 'applied',
|
|
467
497
|
revision: result.revision,
|
|
468
498
|
receipt: result.receipt,
|
|
469
499
|
effective: { skills: structuredClone(prepared.identities), mcpServers: [...normalized.mcpServers] },
|
|
470
500
|
unresolvedWarnings: normalized.unresolved.map(entry => ({ ...entry })),
|
|
471
|
-
...(
|
|
501
|
+
...(draining.length ? { draining } : {}),
|
|
472
502
|
...(warnings.length ? { warnings } : {}),
|
|
473
503
|
}
|
|
474
504
|
trace('response', { sessionId, revision: result.revision })
|
|
@@ -482,7 +512,7 @@ export function createApplyEngine(options) {
|
|
|
482
512
|
const KNOWN_UNCOMMITTED = new Set(['duplicate', 'request-conflict', 'revision-conflict', 'locked', 'unreadable', 'unsupported', 'write-failed'])
|
|
483
513
|
if (!KNOWN_UNCOMMITTED.has(result.status)) {
|
|
484
514
|
trace('indeterminate', { sessionId, status: result.status })
|
|
485
|
-
blocked.set(sessionId, { lease, requestId: normalized.requestId, prepared, payload, drainHandle, viewOptions })
|
|
515
|
+
blocked.set(sessionId, { lease, requestId: normalized.requestId, prepared, payload, basisSets: previousSets, drainHandle, viewOptions })
|
|
486
516
|
return { status: 'indeterminate', step: 5 }
|
|
487
517
|
}
|
|
488
518
|
drainHandle?.abort()
|
|
@@ -537,6 +567,71 @@ export function createApplyEngine(options) {
|
|
|
537
567
|
}
|
|
538
568
|
}
|
|
539
569
|
|
|
570
|
+
/**
|
|
571
|
+
* The ONE synchronous publication segment (apply-delta-durable-basis D4),
|
|
572
|
+
* run by BOTH the committed branch and the recovery-'published' branch:
|
|
573
|
+
* net delta → notice queue → snapshot swap → lifecycle publication →
|
|
574
|
+
* provider invalidation → fence release (→ blocked settle on recovery).
|
|
575
|
+
* No await anywhere in the segment: the atomicity invariant the committed
|
|
576
|
+
* branch documents (a client that sees the new receipt never refetches a
|
|
577
|
+
* stale list) holds for a recovered publication too.
|
|
578
|
+
*
|
|
579
|
+
* The delta basis is the durable evidence only (D2): `basisSets` is the
|
|
580
|
+
* accepted state this publication replaces — on the committed path the payload
|
|
581
|
+
* `store.commit` reports it committed over (read under the commit's own lock),
|
|
582
|
+
* carried into the recovery path by the blocked entry. It is never the
|
|
583
|
+
* in-memory snapshots map and never an unlocked re-read. A failed hook
|
|
584
|
+
* degrades, never changes the accepted policy; per-branch surfacing travels
|
|
585
|
+
* through `degraded` — the committed branch collects response warnings, the
|
|
586
|
+
* recovery branch warns only (it has no response payload).
|
|
587
|
+
*
|
|
588
|
+
* @param {{ sessionId: string, revision: number, payload: unknown,
|
|
589
|
+
* prepared: { handles: unknown[], identities: unknown[], view: unknown },
|
|
590
|
+
* viewOptions: unknown,
|
|
591
|
+
* basisSets: { skills: unknown[], mcpServers: string[] },
|
|
592
|
+
* lease: { release(): void }, settle?: (() => void) | null,
|
|
593
|
+
* degraded: (code: string, error?: unknown) => void }} segment
|
|
594
|
+
*/
|
|
595
|
+
function publishAccepted({ sessionId, revision, payload, prepared, viewOptions, basisSets, lease, settle = null, degraded }) {
|
|
596
|
+
// Task 12.4: the net accepted delta is known only HERE (durable pre-write
|
|
597
|
+
// record vs the just-committed payload); notify consumers queue it for
|
|
598
|
+
// the next safe request. durable-pending-notice D2: the acceptance revision
|
|
599
|
+
// rides along as a third argument for diagnosis and audit — additive, so
|
|
600
|
+
// every two-argument collector keeps working.
|
|
601
|
+
try {
|
|
602
|
+
const delta = selectionDelta(basisSets, safeSets(payload))
|
|
603
|
+
if (delta.added.length > 0 || delta.removed.length > 0) onAccepted?.(sessionId, delta, { revision })
|
|
604
|
+
} catch (error) {
|
|
605
|
+
degraded('accepted-notification-degraded', error)
|
|
606
|
+
}
|
|
607
|
+
snapshots.set(sessionId, { revision, selection: structuredClone(payload), handles: prepared.handles, view: prepared.view })
|
|
608
|
+
// Task 6.1: load the accepted selection into the shared lifecycle memory
|
|
609
|
+
// snapshot inside the SAME non-async segment (committed or recovered), so
|
|
610
|
+
// the agent/created listener's synchronous read never observes a durable
|
|
611
|
+
// acceptance this process has not published. A publication failure
|
|
612
|
+
// degrades to the listener's blocking synchronous disk read of the
|
|
613
|
+
// just-committed record — reported, never fatal.
|
|
614
|
+
try {
|
|
615
|
+
const sets = safeSets(payload)
|
|
616
|
+
publishSnapshot?.(sessionId, { revision, skills: structuredClone(sets.skills), mcpServers: [...sets.mcpServers] })
|
|
617
|
+
} catch (error) {
|
|
618
|
+
degraded('snapshot-publication-degraded', error)
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
trace('invalidate', { sessionId })
|
|
622
|
+
const accepted = provider.acceptSelection(prepared.identities, viewOptions)
|
|
623
|
+
if (!accepted?.accepted) degraded('provider-publication-degraded')
|
|
624
|
+
} catch (error) {
|
|
625
|
+
// The record is durably accepted; a failed cache invalidation degrades
|
|
626
|
+
// convergence, never the policy. The warning is reported, not hidden.
|
|
627
|
+
degraded('invalidate-failed', error)
|
|
628
|
+
} finally {
|
|
629
|
+
trace('fence-release', { sessionId })
|
|
630
|
+
lease.release()
|
|
631
|
+
settle?.()
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
540
635
|
/**
|
|
541
636
|
* Receipt query (tasks 4.4): after a lost response or an indeterminate
|
|
542
637
|
* write, the client enters "result pending confirmation" and queries the
|
|
@@ -548,8 +643,12 @@ export function createApplyEngine(options) {
|
|
|
548
643
|
* the query is serialized behind any in-flight Apply, then settles the
|
|
549
644
|
* pending transaction from durable evidence only —
|
|
550
645
|
* - pending receipt FOUND in a clean record: the write had landed; run the
|
|
551
|
-
* publication segment the lost response never ran
|
|
552
|
-
*
|
|
646
|
+
* publication segment the lost response never ran — the SAME segment the
|
|
647
|
+
* committed path runs (delta → notice queue → snapshot swap → lifecycle
|
|
648
|
+
* publication → provider invalidation → fence release, D4), with the
|
|
649
|
+
* delta computed against the basis the blocked entry captured (the
|
|
650
|
+
* committed-over record when the commit result proved it, D4) — and report
|
|
651
|
+
* recovery 'published';
|
|
553
652
|
* - a digest-verified record WITHOUT the pending receipt: durable proof the
|
|
554
653
|
* write never landed; reopen the drain gates, release the fence and
|
|
555
654
|
* report recovery 'not-committed' (proven absence, NOT a rollback);
|
|
@@ -585,27 +684,27 @@ export function createApplyEngine(options) {
|
|
|
585
684
|
if (pending) {
|
|
586
685
|
const confirmed = record.kind === 'ok' ? record.receipts.find(entry => entry.requestId === pending.requestId) ?? null : null
|
|
587
686
|
if (confirmed) {
|
|
588
|
-
// The indeterminate write LANDED:
|
|
589
|
-
//
|
|
590
|
-
//
|
|
687
|
+
// The indeterminate write LANDED: run the ONE publication segment the
|
|
688
|
+
// lost response never ran (D4), delta against the basis the blocked
|
|
689
|
+
// entry captured included, so the recovered acceptance notifies the
|
|
690
|
+
// model exactly once (the segment settles the blocked entry).
|
|
591
691
|
trace('recovery-publish', { sessionId, revision: confirmed.revision })
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
}
|
|
692
|
+
publishAccepted({
|
|
693
|
+
sessionId,
|
|
694
|
+
revision: confirmed.revision,
|
|
695
|
+
payload: pending.payload,
|
|
696
|
+
prepared: pending.prepared,
|
|
697
|
+
viewOptions: pending.viewOptions,
|
|
698
|
+
basisSets: pending.basisSets,
|
|
699
|
+
lease: pending.lease,
|
|
700
|
+
settle: () => blocked.delete(sessionId),
|
|
701
|
+
// Recovery has no response payload: degradation is warn-only.
|
|
702
|
+
degraded: (code, error) => {
|
|
703
|
+
if (code === 'snapshot-publication-degraded') warn(`snapshot publication failed: ${message(error)}`)
|
|
704
|
+
else if (code === 'invalidate-failed') warn(`receipt-recovery invalidate failed: ${message(error)}`)
|
|
705
|
+
else warn(`receipt-recovery publication degraded: ${code}${error instanceof Error ? `: ${message(error)}` : ''}`)
|
|
706
|
+
},
|
|
707
|
+
})
|
|
609
708
|
recovery = 'published'
|
|
610
709
|
queueMicrotask(() => {
|
|
611
710
|
try { audit?.({ id: sessionId }, APPLY_AUDIT_TYPE, { requestId: confirmed.requestId, revision: confirmed.revision, recovery }) } catch (error) { warn(`apply audit failed: ${message(error)}`) }
|
|
@@ -642,17 +741,6 @@ export function createApplyEngine(options) {
|
|
|
642
741
|
return {
|
|
643
742
|
apply,
|
|
644
743
|
queryReceipt,
|
|
645
|
-
/**
|
|
646
|
-
* Shared in-process commit coordinator (tasks 4.5): a content refresh runs
|
|
647
|
-
* its whole transaction — selection re-check and durable commit — through
|
|
648
|
-
* the SAME per-session serialization point as selection Applies, so the
|
|
649
|
-
* two never interleave in-process. Cross-process exclusion stays the
|
|
650
|
-
* store lock.
|
|
651
|
-
* @template T
|
|
652
|
-
* @param {string} sessionId @param {() => Promise<T>} task
|
|
653
|
-
* @returns {Promise<T>}
|
|
654
|
-
*/
|
|
655
|
-
coordinate: (sessionId, task) => serialize(sessionId, task),
|
|
656
744
|
/** Synchronous in-memory authority snapshot for a session (null until loaded). */
|
|
657
745
|
authority(sessionId) {
|
|
658
746
|
const snapshot = snapshots.get(sessionId)
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
// (an explicit empty set is a real, savable choice). The binding is the
|
|
8
8
|
// workspace's stable identity (canonical root path). Save and clear go
|
|
9
9
|
// through the group-2 CAS — a conflict never overwrites.
|
|
10
|
-
import { workspaceKeyOf } from './preset-library.js'
|
|
10
|
+
import { workspaceKeyOf, draftSelectionSets, noWorkspaceError } from './preset-library.js'
|
|
11
|
+
import { resolveSkillNamesForSave } from './initial-selection.js'
|
|
12
|
+
import { isSegment } from './store/paths.js'
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* @param {{ store: { read(unit: unknown): Promise<any>, commit(unit: unknown, expectedRevision: number, mutate: (payload: unknown) => unknown, request?: unknown): Promise<any> },
|
|
@@ -58,3 +60,136 @@ export function createDefaultsTransaction({ store, now = Date.now }) {
|
|
|
58
60
|
|
|
59
61
|
return { read, save, clear, unitOf, workspaceKeyOf }
|
|
60
62
|
}
|
|
63
|
+
|
|
64
|
+
// ---- /capabilities default verbs (capabilities-deepening D2) ----------------
|
|
65
|
+
// The three workspace-default verb bodies of the /capabilities command, moved
|
|
66
|
+
// verbatim out of the handler into the module that owns the defaults CAS
|
|
67
|
+
// contract (read/save/clear above). Per-invocation state arrives through the
|
|
68
|
+
// `call` argument ({ agent, options }); the handler's former closures are
|
|
69
|
+
// injected in `wiring` — the payload builder included, so this module keeps
|
|
70
|
+
// its import graph acyclic (read-payloads.js imports this module, never the
|
|
71
|
+
// reverse).
|
|
72
|
+
|
|
73
|
+
const message = error => (error instanceof Error ? error.message : String(error))
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The /capabilities default verb bodies. `wiring`:
|
|
77
|
+
* @param {{
|
|
78
|
+
* openStore: () => any,
|
|
79
|
+
* provider: any,
|
|
80
|
+
* inventory: (options: any) => Promise<any>,
|
|
81
|
+
* lifecycle: any,
|
|
82
|
+
* builders: { buildDefaultGetPayload: Function },
|
|
83
|
+
* }} wiring
|
|
84
|
+
*/
|
|
85
|
+
export function createDefaultsVerbs(wiring) {
|
|
86
|
+
/**
|
|
87
|
+
* The sets a save records. from:'applied' is server-authoritative: the sets
|
|
88
|
+
* come from the same provider/lifecycle state the receipt verb reports —
|
|
89
|
+
* client-sent set fields are never trusted then.
|
|
90
|
+
*/
|
|
91
|
+
const selectionSetsOf = async (spec, call) => {
|
|
92
|
+
if (spec.from === 'applied') {
|
|
93
|
+
const result = await wiring.provider.list(call.options)
|
|
94
|
+
const candidates = Array.isArray(result?.candidates) ? result.candidates : []
|
|
95
|
+
const skills = []
|
|
96
|
+
for (const candidate of candidates) {
|
|
97
|
+
if (candidate?.selected && candidate.identity) skills.push(candidate.identity)
|
|
98
|
+
}
|
|
99
|
+
const snapshot = wiring.lifecycle?.snapshotFor?.(call.agent.id)
|
|
100
|
+
return { skills, mcpServers: Array.isArray(snapshot?.mcpServers) ? [...snapshot.mcpServers] : [], unresolvedRefs: [] }
|
|
101
|
+
}
|
|
102
|
+
return draftSelectionSets(spec)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* default-get: thin shell over the shared builder. The no-workspace status
|
|
107
|
+
* is the ONLY error mapping (the historical noWorkspace object); every
|
|
108
|
+
* other status — including a non-ok record — stays success.
|
|
109
|
+
*/
|
|
110
|
+
async function defaultGet(_spec, call) {
|
|
111
|
+
try {
|
|
112
|
+
const payload = await wiring.builders.buildDefaultGetPayload({ store: wiring.openStore() }, call.options)
|
|
113
|
+
if (payload.status === 'no-workspace') return { kind: 'error', text: JSON.stringify(payload) }
|
|
114
|
+
return { kind: 'success', text: JSON.stringify(payload) }
|
|
115
|
+
} catch (cause) {
|
|
116
|
+
return { kind: 'error', text: `capabilities default-get failed: ${message(cause)}` }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* default-save: save this workspace's new-session default as a COPY
|
|
122
|
+
* snapshot of the draft or the applied sets. Saving never changes the
|
|
123
|
+
* current session; an explicit empty set is a real choice.
|
|
124
|
+
*/
|
|
125
|
+
async function defaultSave(spec, call) {
|
|
126
|
+
try {
|
|
127
|
+
if (spec?.from !== 'draft' && spec?.from !== 'applied') return { kind: 'error', text: 'capabilities default-save: from must be "draft" | "applied"' }
|
|
128
|
+
const workspaceKey = workspaceKeyOf(call.options)
|
|
129
|
+
if (!isSegment(workspaceKey)) return noWorkspaceError()
|
|
130
|
+
const transaction = createDefaultsTransaction({ store: wiring.openStore() })
|
|
131
|
+
let expectedRevision = spec.expectedRevision
|
|
132
|
+
if (expectedRevision === undefined) {
|
|
133
|
+
const current = await transaction.read(workspaceKey)
|
|
134
|
+
if (current.kind !== 'ok' && current.kind !== 'absent') return { kind: 'error', text: JSON.stringify({ status: current.kind }) }
|
|
135
|
+
expectedRevision = current.kind === 'ok' ? current.revision : 0
|
|
136
|
+
}
|
|
137
|
+
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
|
138
|
+
return { kind: 'error', text: 'capabilities default-save: expectedRevision must be a non-negative integer' }
|
|
139
|
+
}
|
|
140
|
+
const sets = await selectionSetsOf(spec, call)
|
|
141
|
+
let skills = sets.skills
|
|
142
|
+
if (spec.from === 'draft') {
|
|
143
|
+
// The draft's wire shape is name strings: resolve them to exact
|
|
144
|
+
// SkillIdentity objects BEFORE persisting, so the default record
|
|
145
|
+
// carries the resolved-sets contract and a duplicated name never
|
|
146
|
+
// lands as an unbindable string. The session's applied selection is
|
|
147
|
+
// the authoritative disambiguation; unresolvable or still-ambiguous
|
|
148
|
+
// names are an explicit error with ZERO writes.
|
|
149
|
+
const inventoryResult = await wiring.inventory(call.options)
|
|
150
|
+
const inventoryCandidates = Array.isArray(inventoryResult?.candidates) ? inventoryResult.candidates : []
|
|
151
|
+
const appliedResult = await wiring.provider.list(call.options)
|
|
152
|
+
const appliedCandidates = Array.isArray(appliedResult?.candidates) ? appliedResult.candidates : []
|
|
153
|
+
const appliedIdentities = []
|
|
154
|
+
for (const candidate of appliedCandidates) {
|
|
155
|
+
if (candidate?.selected && candidate.identity) appliedIdentities.push(candidate.identity)
|
|
156
|
+
}
|
|
157
|
+
const resolved = resolveSkillNamesForSave(spec.skills, inventoryCandidates, appliedIdentities)
|
|
158
|
+
if (resolved.missing.length > 0) return { kind: 'error', text: JSON.stringify({ status: 'missing', missing: resolved.missing }) }
|
|
159
|
+
if (resolved.ambiguous.length > 0) return { kind: 'error', text: JSON.stringify({ status: 'ambiguous', ambiguous: resolved.ambiguous }) }
|
|
160
|
+
skills = resolved.identities
|
|
161
|
+
}
|
|
162
|
+
const result = await transaction.save({ workspaceKey, expectedRevision, snapshot: { skills, mcpServers: sets.mcpServers, unresolvedRefs: sets.unresolvedRefs } })
|
|
163
|
+
return { kind: 'success', text: JSON.stringify({ ...result, workspaceKey }) }
|
|
164
|
+
} catch (cause) {
|
|
165
|
+
return { kind: 'error', text: `capabilities default-save failed: ${message(cause)}` }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* default-clear: clear this workspace's default (absent afterwards — NOT
|
|
171
|
+
* an explicit empty set). The JSON payload is optional.
|
|
172
|
+
*/
|
|
173
|
+
async function defaultClear(spec = {}, call) {
|
|
174
|
+
try {
|
|
175
|
+
const workspaceKey = workspaceKeyOf(call.options)
|
|
176
|
+
if (!isSegment(workspaceKey)) return noWorkspaceError()
|
|
177
|
+
const transaction = createDefaultsTransaction({ store: wiring.openStore() })
|
|
178
|
+
let expectedRevision = spec.expectedRevision
|
|
179
|
+
if (expectedRevision === undefined) {
|
|
180
|
+
const current = await transaction.read(workspaceKey)
|
|
181
|
+
if (current.kind !== 'ok' && current.kind !== 'absent') return { kind: 'error', text: JSON.stringify({ status: current.kind }) }
|
|
182
|
+
expectedRevision = current.kind === 'ok' ? current.revision : 0
|
|
183
|
+
}
|
|
184
|
+
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
|
185
|
+
return { kind: 'error', text: 'capabilities default-clear: expectedRevision must be a non-negative integer' }
|
|
186
|
+
}
|
|
187
|
+
const result = await transaction.clear({ workspaceKey, expectedRevision })
|
|
188
|
+
return { kind: 'success', text: JSON.stringify({ ...result, workspaceKey }) }
|
|
189
|
+
} catch (cause) {
|
|
190
|
+
return { kind: 'error', text: `capabilities default-clear failed: ${message(cause)}` }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { 'default-get': defaultGet, 'default-save': defaultSave, 'default-clear': defaultClear }
|
|
195
|
+
}
|