weir-harness 2.1.1 → 2.1.3
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.capability-manager.js +467 -2
- package/lib/client.capability-model.js +77 -0
- package/lib/client.js +25 -4
- package/package.json +1 -1
- package/src/capabilities/apply-engine.js +20 -4
- package/src/capabilities/capability-remote.js +97 -10
- package/src/capabilities/lifecycle-feed.js +47 -0
- package/src/capabilities/mcp-drain.js +6 -0
- package/src/capabilities/mcp-facade-plugin.js +14 -4
- package/src/capabilities/mcp-facade.js +1 -1
- package/src/capabilities/mcp-inventory.js +152 -0
- package/src/capabilities/mcp-manager.js +220 -171
- package/src/capabilities/mcp-mount-ownership.js +114 -0
- package/src/capabilities/mcp-mount.js +334 -82
- package/src/capabilities/mcp-operation-coordinator.js +76 -0
- package/src/capabilities/mcp-runtime.js +3 -29
- package/src/capabilities/skill-inventory.js +18 -2
- package/src/capabilities/skill-scan-path-registry.js +238 -0
- package/src/capabilities/skill-selection-plugin.js +14 -0
- package/src/capabilities/store/paths.js +3 -0
- package/src/file-lease/client.js +61 -3
- package/src/file-lease/negotiation.js +89 -0
- package/src/file-lease/plugin.js +25 -23
- package/src/file-lease/tools.js +71 -121
- package/src/file-lease/watch.js +225 -36
package/lib/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Weir client chunks: {"client.blackboard-model.js":"2e5abdd44edb599d10d9d547fa07133e84250d6007161c2c8704b58151896f93","client.blackboard-panel.js":"c9d6a0f02623075fc884f3519f982574ad33ad871f7bcd7e8c36f12512f0de39","client.capability-badge.js":"f5c5d21293360d00815446beb8455794a1778ae033e270af37e436329ac09185","client.capability-manager.js":"
|
|
1
|
+
// Weir client chunks: {"client.blackboard-model.js":"2e5abdd44edb599d10d9d547fa07133e84250d6007161c2c8704b58151896f93","client.blackboard-panel.js":"c9d6a0f02623075fc884f3519f982574ad33ad871f7bcd7e8c36f12512f0de39","client.capability-badge.js":"f5c5d21293360d00815446beb8455794a1778ae033e270af37e436329ac09185","client.capability-manager.js":"0c5b0e469b66b1ccdf720bb3db91ae15fd0be70c84287739de36a3bfceaed223","client.capability-model.js":"9beddda506617152c51d8159964fee74aecb99e8d50b50de82ab050d597ec9d1","client.capability-presets.js":"12ac8da3568d013abd76b67cfe473a329f4af2feaac9cd55a82d8881e5bc1017","client.chain-editor.js":"1e9a33bc68cabc43bc27b91ef83eab5028a33256596c147e15722d684687765b","client.chain-model.js":"2e885922cf8c2972725c6eef8bf20c7d1202d961edfb8ad41574dac062d74371","client.disabled-categories-editor.js":"f9103fd2e2691eda9afd199e1a5462189097a9f3dee0518be5fa3587d896e033","client.file-lease-badge.js":"cd30546307c91c94fe05484557e46f58c53fe55657f62b0a0415a072dfb3b582","client.file-lease-tool-model.js":"f72d87b049454ec489b4dffac1f32dabc273947b8a4c55fe07c91ddf320bd899","client.file-lease-tool-view.js":"7004caa06b36d8497a1d2e448b6ce5e4c8efd22ad903d8b39e26220de64bb1fe","client.hash-edit-model.js":"71727d47a6ef32ebd2abfb063fa93499a5e607d6716e3ec8983ae056c4fb317f","client.hash-edit-view.js":"fc0f4dabddab9a48527553ad4232b3715e46e6863aab75e02ae2dcd6687265c2","client.lsp-model.js":"30943ed1ed11c1f2770a724da855ea2417c11566c2778c79e17cfc2de499cd80","client.lsp-panel.js":"61629a4d317dae9ed01c1cc0355632fb752788dc23f7606257188050734cd030","client.lsp-toggle.js":"d6af81be5e5b854e98563bbb9349a6db89834a9d7dee9cf0e97759ecb32090e8","client.mcp-manager.js":"cfcd4792251fcca8bfa6a2c35b80881ab6f6480cf2ebce24a8ce304cddf940a7","client.model-picker.js":"57f96826e00b37ae6d6474a6750f31b4e128e5fdb37e302875a41cdb2e18f14a","client.notify-permissions.js":"2be9bdbeffef96baa3cbe4ce430ab30a1d1bf797782453d287d54602ea47db8e","client.notify-web.js":"0943f67d5526ff0fb7cb4f69580e0a9ea54ed06465c513afc51a5a7fb1c55ccf","client.robash-editor.js":"cd6a283b72cb02598e32ca4ce03a1b8de5f328ecdd1f5f920377524f3bdcbe1c","client.robash-model.js":"f4395338a773e8bb48e4f96aeb737cd634edcca0b90af992a7dbbe47be42e451","client.settings-page.js":"f3c04a5b33cdb963357bc653f7050e598f19efc4cc833694379a4ba055e949d3","client.worktree-model.js":"8bfbaaa080f20e90a0128afccb9b4e232514615a8f2fee3a32a42dde78b34a8f","client.worktree-view.js":"356c3f5f683b8509ae8679243a6460ca02957fd15e057a629b2ac4fdd12ed057"}
|
|
2
2
|
window.__ModuleLoader__.load({
|
|
3
3
|
id: "weir-harness",
|
|
4
4
|
factory: (require) => {
|
|
@@ -891,6 +891,10 @@ window.__ModuleLoader__.load({
|
|
|
891
891
|
const capabilityVerbs = (sessionId) => ({
|
|
892
892
|
sessionId,
|
|
893
893
|
fetchReceipt: (sid) => capabilityRead("receipt", sid, null),
|
|
894
|
+
fetchSkillPaths: (sid) => capabilityRead("skillPaths", sid, {
|
|
895
|
+
status: "unavailable", saved: false, revision: 0, paths: [],
|
|
896
|
+
error: { code: "unavailable", message: "Skill scan paths are unavailable." }
|
|
897
|
+
}),
|
|
894
898
|
// Forwarded host events arrive via ctx.remote.$on — NOT a
|
|
895
899
|
// remote.session.subscribe (that API does not exist; the old
|
|
896
900
|
// wiring silently never subscribed, which is why cold sessions
|
|
@@ -910,6 +914,8 @@ window.__ModuleLoader__.load({
|
|
|
910
914
|
mcpPreview: (sid, request) => capabilityMutation("mutate", sid, request),
|
|
911
915
|
mcpMutate: (sid, request) => capabilityMutation("mutate", sid, request),
|
|
912
916
|
mcpBatch: (sid, request) => capabilityMutation("batch", sid, request),
|
|
917
|
+
skillPathAdd: (sid, request) => capabilityMutation("skillPathAdd", sid, request),
|
|
918
|
+
skillPathRemove: (sid, request) => capabilityMutation("skillPathRemove", sid, request),
|
|
913
919
|
fetchListing: (sid) => capabilityRead("list", sid, { error: true }),
|
|
914
920
|
fetchConditions: (sid) => capabilityRead("conditions", sid, { error: true }),
|
|
915
921
|
// The manager forwards only its established props. Adapt the lazy view
|
|
@@ -935,6 +941,10 @@ window.__ModuleLoader__.load({
|
|
|
935
941
|
// True once the right-sidebar package mounted the capabilities tab
|
|
936
942
|
// (the optional inject below ran); the Badge's openPanel verb exists
|
|
937
943
|
// only then — without it the Badge falls back to its composer popover.
|
|
944
|
+
const capabilityDictionaries = (model, panel) => ({
|
|
945
|
+
en: { ...model.dictionaries?.en, ...panel.dictionaries?.en },
|
|
946
|
+
zh: { ...model.dictionaries?.zh, ...panel.dictionaries?.zh }
|
|
947
|
+
});
|
|
938
948
|
let capabilitySidebarTab = false;
|
|
939
949
|
const CapabilityBadgeWrapper = (props) => {
|
|
940
950
|
const [impl, setImpl] = react.useState(null);
|
|
@@ -946,8 +956,19 @@ window.__ModuleLoader__.load({
|
|
|
946
956
|
require.async("./client.capability-manager.js"),
|
|
947
957
|
require.async("./client.mcp-manager.js").catch(() => null),
|
|
948
958
|
]).then(([badge, model, panel, mcp]) => {
|
|
949
|
-
registerChunkDictionaries("settings.weir.capability", model
|
|
950
|
-
if (alive) setImpl(() =>
|
|
959
|
+
registerChunkDictionaries("settings.weir.capability", capabilityDictionaries(model, panel));
|
|
960
|
+
if (alive) setImpl(() => {
|
|
961
|
+
// The Badge chunk's established prop bridge is intentionally unchanged.
|
|
962
|
+
// Bind the new verbs once at this composition-root adapter so the
|
|
963
|
+
// composer popover and sidebar drive the identical path child.
|
|
964
|
+
const Panel = (panelProps) => react_jsx_runtime.jsx(panel.CapabilityManagerPanel, {
|
|
965
|
+
...panelProps,
|
|
966
|
+
fetchSkillPaths: props.fetchSkillPaths,
|
|
967
|
+
skillPathAdd: props.skillPathAdd,
|
|
968
|
+
skillPathRemove: props.skillPathRemove
|
|
969
|
+
});
|
|
970
|
+
return { Badge: badge.CapabilityBadge, model, Panel, McpManagerView: mcp?.McpManagerView, McpManagerBoundary: mcp?.McpManagerBoundary };
|
|
971
|
+
});
|
|
951
972
|
});
|
|
952
973
|
return () => { alive = false; };
|
|
953
974
|
}, [props.sessionId]);
|
|
@@ -984,7 +1005,7 @@ window.__ModuleLoader__.load({
|
|
|
984
1005
|
require.async("./client.capability-manager.js"),
|
|
985
1006
|
require.async("./client.mcp-manager.js").catch(() => null),
|
|
986
1007
|
]).then((chunks) => {
|
|
987
|
-
registerChunkDictionaries("settings.weir.capability", chunks[0]
|
|
1008
|
+
registerChunkDictionaries("settings.weir.capability", capabilityDictionaries(chunks[0], chunks[1]));
|
|
988
1009
|
return chunks;
|
|
989
1010
|
}));
|
|
990
1011
|
function CapabilityPanelWrapper(props) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weir-harness",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Weir \u2014 orchestrator-centric DSH agent preset: intent gate, category delegation, todo driver, pull-based background jobs, auto-compaction, hashline edit.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,10 +42,11 @@ export const APPLY_AUDIT_TYPE = AUDIT_TYPES.capabilityApply
|
|
|
42
42
|
* of the removed servers FOR THAT SESSION and returns a handle: snapshot()
|
|
43
43
|
* reports the live in-flight counts (sent with the response, never awaited)
|
|
44
44
|
* and abort() reopens exactly the gates begin() closed when the Apply does
|
|
45
|
-
* not commit
|
|
46
|
-
*
|
|
47
|
-
* (agent, server)-keyed drain here
|
|
48
|
-
*
|
|
45
|
+
* not commit. After acceptance, reopen(restored, sessionId) opens only the
|
|
46
|
+
* servers the committed-over basis shows this Apply restored for that session.
|
|
47
|
+
* Production wires the manager's (agent, server)-keyed drain here
|
|
48
|
+
* (createSessionDrainAdapter, mcp-drain.js).
|
|
49
|
+
* @typedef {{ begin(removed: string[], sessionId: string): { snapshot(): DrainStatus[], abort(): void }, reopen(restored: string[], sessionId: string): void }} ApplyDrain
|
|
49
50
|
* A step-tagged rejection keeps the old authority and the user's draft.
|
|
50
51
|
* @typedef {{ status: 'applied', revision: number, receipt: unknown, effective: unknown, unresolvedWarnings: UnresolvedRef[], draining?: DrainStatus[], warnings?: string[] }
|
|
51
52
|
* | { status: 'duplicate', revision: number, receipt: unknown, effective: unknown, warnings?: string[] }
|
|
@@ -164,6 +165,9 @@ export function createDrainCoordinator() {
|
|
|
164
165
|
},
|
|
165
166
|
}
|
|
166
167
|
},
|
|
168
|
+
reopen(restored) {
|
|
169
|
+
for (const server of restored) stateOf(server).closed = false
|
|
170
|
+
},
|
|
167
171
|
}
|
|
168
172
|
}
|
|
169
173
|
|
|
@@ -607,6 +611,18 @@ export function createApplyEngine(options) {
|
|
|
607
611
|
* degraded: (code: string, error?: unknown) => void }} segment
|
|
608
612
|
*/
|
|
609
613
|
function publishAccepted({ sessionId, revision, payload, prepared, viewOptions, basisSets, lease, settle = null, degraded }) {
|
|
614
|
+
// The accepted set, not the transaction-start read, owns restoration. This
|
|
615
|
+
// runs only after a durable commit (or receipt proof), inside the held fence
|
|
616
|
+
// and before lifecycle publication/manager synchronization. A reopen failure
|
|
617
|
+
// cannot reject or roll back the landed write: degrade the response honestly.
|
|
618
|
+
const restoredServers = safeSets(payload).mcpServers.filter(server => !basisSets.mcpServers.includes(server))
|
|
619
|
+
if (restoredServers.length) {
|
|
620
|
+
try {
|
|
621
|
+
drain.reopen(restoredServers, sessionId)
|
|
622
|
+
} catch (error) {
|
|
623
|
+
degraded('drain-reopen-degraded', error)
|
|
624
|
+
}
|
|
625
|
+
}
|
|
610
626
|
// Task 12.4: the net accepted delta is known only HERE (durable pre-write
|
|
611
627
|
// record vs the just-committed payload); notify consumers queue it for
|
|
612
628
|
// the next safe request. durable-pending-notice D2: the acceptance revision
|
|
@@ -22,13 +22,14 @@ import { buildReceiptPayload, buildListPayload, buildConditionsPayload, buildPre
|
|
|
22
22
|
import { PRESET_SELECTED_EVENT } from './preset-invalidation.js'
|
|
23
23
|
import { createAudit } from '../shared/audit.js'
|
|
24
24
|
import { mcpDiagnostic, sanitizeMcpPayload } from './mcp-diagnostics.js'
|
|
25
|
+
import { createSkillScanPathRegistry } from './skill-scan-path-registry.js'
|
|
25
26
|
|
|
26
27
|
/** The host-root service key the typert gateway resolves (S27: ctx.get from the host root). */
|
|
27
28
|
export const CAPABILITY_READ_SERVICE_KEY = 'weirCapabilityRead'
|
|
28
29
|
/** The wire namespace: POST /api/weirCapabilities/<method>, client ctx.remote.weirCapabilities.*. */
|
|
29
30
|
export const CAPABILITY_READ_NAMESPACE = 'weirCapabilities'
|
|
30
|
-
/** The
|
|
31
|
-
export const CAPABILITY_READ_METHODS = ['receipt', 'list', 'conditions', 'presets', 'defaultGet']
|
|
31
|
+
/** The six read methods, each taking exactly one parameter. */
|
|
32
|
+
export const CAPABILITY_READ_METHODS = ['receipt', 'list', 'conditions', 'presets', 'defaultGet', 'skillPaths']
|
|
32
33
|
|
|
33
34
|
/** Typed infrastructure/ownership failure, distinct from domain status values. */
|
|
34
35
|
export class CapabilityReadError extends Error {
|
|
@@ -157,6 +158,7 @@ export function capabilityOwnership(observation, sessionId) {
|
|
|
157
158
|
export function createCapabilityReadService(dependencies = {}) {
|
|
158
159
|
const bridgeFor = feedResolver(dependencies)
|
|
159
160
|
const storeFor = dependencies.store ?? (profileContext => openCapabilityStore({ profileContext }))
|
|
161
|
+
const skillPathRegistryFor = dependencies.skillPathRegistry ?? (profileContext => createSkillScanPathRegistry({ store: storeFor(profileContext) }))
|
|
160
162
|
const ensure = dependencies.ensureRegistered ?? (() => true)
|
|
161
163
|
const onDegraded = dependencies.onDegraded ?? (() => {})
|
|
162
164
|
const observeSession = dependencies.observeSession ?? (sessionId => dependencies.sessionQuery?.()?.observeSession(sessionId))
|
|
@@ -242,12 +244,13 @@ export function createCapabilityReadService(dependencies = {}) {
|
|
|
242
244
|
defaultGet: sessionId => read(sessionId, 'defaultGet', (faces, options) => buildDefaultGetPayload({
|
|
243
245
|
store: storeFor(faces.profileContext?.()),
|
|
244
246
|
}, options)),
|
|
247
|
+
skillPaths: sessionId => read(sessionId, 'skillPaths', faces => skillPathRegistryFor(faces.profileContext?.()).read()),
|
|
245
248
|
}
|
|
246
249
|
}
|
|
247
250
|
|
|
248
251
|
export const CAPABILITY_MUTATION_SERVICE_KEY = 'weirCapabilityMutation'
|
|
249
252
|
export const CAPABILITY_MUTATION_NAMESPACE = 'weirCapabilityMutations'
|
|
250
|
-
export const CAPABILITY_MUTATION_METHODS = ['mutate', 'batch']
|
|
253
|
+
export const CAPABILITY_MUTATION_METHODS = ['mutate', 'batch', 'skillPathAdd', 'skillPathRemove']
|
|
251
254
|
|
|
252
255
|
export function sameMutationOwner(first, second) {
|
|
253
256
|
const profile = value => value ? `${value.home ?? ''}\0${value.name ?? ''}` : ''
|
|
@@ -257,9 +260,38 @@ export function sameMutationOwner(first, second) {
|
|
|
257
260
|
&& profile(first.profile) === profile(second.profile)
|
|
258
261
|
}
|
|
259
262
|
|
|
263
|
+
/** Call every accepting-process provider for exactly one changed Profile. */
|
|
264
|
+
export function invalidateSkillScanPathProviders(profile) {
|
|
265
|
+
const failures = []
|
|
266
|
+
if (typeof profile?.home !== 'string' || typeof profile?.name !== 'string') {
|
|
267
|
+
return [new Error('changed Profile identity is unavailable')]
|
|
268
|
+
}
|
|
269
|
+
for (const feeds of bridgeFeeds.values()) {
|
|
270
|
+
for (const token of feeds) {
|
|
271
|
+
const faces = token.faces ?? token
|
|
272
|
+
try {
|
|
273
|
+
const candidate = faces.profileContext?.()
|
|
274
|
+
if (candidate?.home !== profile.home || candidate?.name !== profile.name) continue
|
|
275
|
+
if (typeof faces.provider?.sourceChanged !== 'function') throw new Error('mounted Skill provider cannot invalidate')
|
|
276
|
+
faces.provider.sourceChanged()
|
|
277
|
+
} catch (cause) {
|
|
278
|
+
failures.push(cause)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return failures
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const pathDiagnostic = (cause, code = cause?.code ?? 'validation-failed') => ({
|
|
286
|
+
code,
|
|
287
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
288
|
+
})
|
|
289
|
+
|
|
260
290
|
/** Session-preset/profile/feed ownership for mutations; selectionSeq is not compared. */
|
|
261
291
|
export function createCapabilityMutationService(dependencies = {}) {
|
|
262
292
|
const bridgeFor = feedResolver(dependencies)
|
|
293
|
+
const storeFor = dependencies.store ?? (profileContext => openCapabilityStore({ profileContext }))
|
|
294
|
+
const skillPathRegistryFor = dependencies.skillPathRegistry ?? (profileContext => createSkillScanPathRegistry({ store: storeFor(profileContext) }))
|
|
263
295
|
const ensure = dependencies.ensureRegistered ?? (() => true)
|
|
264
296
|
const observeSession = dependencies.observeSession ?? (sessionId => dependencies.sessionQuery?.()?.observeSession(sessionId))
|
|
265
297
|
const observe = async sessionId => {
|
|
@@ -278,16 +310,18 @@ export function createCapabilityMutationService(dependencies = {}) {
|
|
|
278
310
|
throw new CapabilityReadError('unknown-session', `capability mutation: cannot observe session ${JSON.stringify(sessionId)}`, { cause })
|
|
279
311
|
}
|
|
280
312
|
}
|
|
313
|
+
// The same exact observation/feed/Profile recheck serves MCP and path writes.
|
|
314
|
+
const ownershipAssertion = (sessionId, first) => async () => {
|
|
315
|
+
const current = await observe(sessionId)
|
|
316
|
+
try {
|
|
317
|
+
if (!sameMutationOwner(first, current)) throw new CapabilityReadError('owner-changed', 'capability mutation: session preset/profile/feed owner changed')
|
|
318
|
+
} finally { current.lease[Symbol.dispose]() }
|
|
319
|
+
}
|
|
281
320
|
async function mutate(sessionId, request, batch = false) {
|
|
282
321
|
if (!ensure()) throw new CapabilityReadError('channel-unavailable', 'capability mutation channel is not registered')
|
|
283
322
|
const first = await observe(sessionId)
|
|
284
323
|
try {
|
|
285
|
-
const assertOwner =
|
|
286
|
-
const current = await observe(sessionId)
|
|
287
|
-
try {
|
|
288
|
-
if (!sameMutationOwner(first, current)) throw new CapabilityReadError('owner-changed', 'capability mutation: session preset/profile/feed owner changed')
|
|
289
|
-
} finally { current.lease[Symbol.dispose]() }
|
|
290
|
-
}
|
|
324
|
+
const assertOwner = ownershipAssertion(sessionId, first)
|
|
291
325
|
if (typeof first.faces.mcpOperations !== 'function') throw new CapabilityReadError('bridge-absent', 'capability mutation: owning preset has no MCP operations')
|
|
292
326
|
const operations = first.faces.mcpOperations({ id: sessionId, header: { cwd: first.owner.cwd } }, { assertOwner })
|
|
293
327
|
let result
|
|
@@ -322,9 +356,62 @@ export function createCapabilityMutationService(dependencies = {}) {
|
|
|
322
356
|
first.lease[Symbol.dispose]()
|
|
323
357
|
}
|
|
324
358
|
}
|
|
359
|
+
|
|
360
|
+
async function mutatePath(sessionId, request, kind) {
|
|
361
|
+
if (!ensure()) throw new CapabilityReadError('channel-unavailable', 'capability mutation channel is not registered')
|
|
362
|
+
const first = await observe(sessionId)
|
|
363
|
+
try {
|
|
364
|
+
const assertOwner = ownershipAssertion(sessionId, first)
|
|
365
|
+
let result
|
|
366
|
+
try {
|
|
367
|
+
const registry = skillPathRegistryFor(first.profile)
|
|
368
|
+
result = kind === 'add'
|
|
369
|
+
? await registry.add(request?.path, request?.expectedRevision, { assertOwner })
|
|
370
|
+
: await registry.remove(request?.path, request?.expectedRevision, { assertOwner })
|
|
371
|
+
} catch (cause) {
|
|
372
|
+
result = {
|
|
373
|
+
status: cause?.code === 'owner-changed' ? 'owner-changed' : 'validation-failed',
|
|
374
|
+
saved: false, revision: null, paths: null,
|
|
375
|
+
error: pathDiagnostic(cause, cause?.code ?? 'validation-failed'),
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// A known or possibly landed registry write may already have changed the
|
|
380
|
+
// durable discovery scope. Invalidate every same-Profile mount before the
|
|
381
|
+
// response, without converting an ambiguous outcome into a definite one.
|
|
382
|
+
const indeterminate = result.status === 'indeterminate'
|
|
383
|
+
let invalidationFailure = null
|
|
384
|
+
if (result.saved === true || indeterminate) {
|
|
385
|
+
const failures = invalidateSkillScanPathProviders(first.profile)
|
|
386
|
+
if (failures.length > 0) invalidationFailure = failures.map(failure => pathDiagnostic(failure).message).join('; ')
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
await assertOwner()
|
|
390
|
+
} catch (cause) {
|
|
391
|
+
return {
|
|
392
|
+
...result,
|
|
393
|
+
status: indeterminate ? 'indeterminate' : 'owner-changed', ownerChanged: true, saved: result.saved === true,
|
|
394
|
+
error: pathDiagnostic(cause, 'owner-changed'),
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (invalidationFailure !== null) {
|
|
398
|
+
return {
|
|
399
|
+
...result,
|
|
400
|
+
status: indeterminate ? 'indeterminate' : 'invalidation-failed',
|
|
401
|
+
error: { code: 'invalidation-failed', message: invalidationFailure },
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return result
|
|
405
|
+
} finally {
|
|
406
|
+
first.lease[Symbol.dispose]()
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
325
410
|
return {
|
|
326
411
|
mutate: (sessionId, request) => mutate(sessionId, request, false),
|
|
327
412
|
batch: (sessionId, request) => mutate(sessionId, request, true),
|
|
413
|
+
skillPathAdd: (sessionId, request) => mutatePath(sessionId, request, 'add'),
|
|
414
|
+
skillPathRemove: (sessionId, request) => mutatePath(sessionId, request, 'remove'),
|
|
328
415
|
}
|
|
329
416
|
}
|
|
330
417
|
|
|
@@ -343,7 +430,7 @@ export const CAPABILITY_ENDPOINTS = [
|
|
|
343
430
|
* codecs, no zod, no generated artifacts). Parameters and results are plain
|
|
344
431
|
* JSON; the envelope validates the argument name only.
|
|
345
432
|
*
|
|
346
|
-
* ALL
|
|
433
|
+
* ALL six read and four mutation invocations live in ONE contribution, because
|
|
347
434
|
* the registry keys exactly one record per package+face and rejects any second
|
|
348
435
|
* registration while that record is live: two separate `weir-harness` host-face
|
|
349
436
|
* contributions could never overlap generations, and the face the mutation half
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Per-mount Capability Lifecycle Snapshot ownership. A live token authorizes a
|
|
2
|
+
// manager only when its lazy accessor returns that exact mutable manager face.
|
|
3
|
+
// No mount-order, process-face, preset-name, or sole-feed fallback is permitted.
|
|
4
|
+
|
|
5
|
+
/** @typedef {{ lifecycle: any, manager: (() => any) | null | undefined }} LifecycleFeedToken */
|
|
6
|
+
/**
|
|
7
|
+
* Create an isolated registry for tests or an explicit in-process composition.
|
|
8
|
+
* Registration returns an ownership-scoped, idempotent disposer.
|
|
9
|
+
*/
|
|
10
|
+
export function createLifecycleFeedRegistry() {
|
|
11
|
+
/** @type {Set<LifecycleFeedToken>} */
|
|
12
|
+
const tokens = new Set()
|
|
13
|
+
return {
|
|
14
|
+
register({ lifecycle, manager }) {
|
|
15
|
+
const token = { lifecycle, manager }
|
|
16
|
+
tokens.add(token)
|
|
17
|
+
let live = true
|
|
18
|
+
return () => {
|
|
19
|
+
if (!live) return
|
|
20
|
+
live = false
|
|
21
|
+
tokens.delete(token)
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
lifecycleFor(managerFace) {
|
|
25
|
+
if (managerFace == null) return null
|
|
26
|
+
/** @type {LifecycleFeedToken | null} */
|
|
27
|
+
let owner = null
|
|
28
|
+
for (const token of tokens) {
|
|
29
|
+
let manager
|
|
30
|
+
try {
|
|
31
|
+
manager = typeof token.manager === 'function' ? token.manager() : null
|
|
32
|
+
} catch {
|
|
33
|
+
// A late, absent, or failing manager is denied on this adjudication.
|
|
34
|
+
// Do not cache either the failure or a previous successful owner.
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (manager !== managerFace) continue
|
|
38
|
+
if (owner) return null
|
|
39
|
+
owner = token
|
|
40
|
+
}
|
|
41
|
+
return owner?.lifecycle ?? null
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Internal module-local bridge shared by selection and manager rows. */
|
|
47
|
+
export const lifecycleFeedRegistry = createLifecycleFeedRegistry()
|
|
@@ -130,5 +130,11 @@ export function createSessionDrainAdapter(resolveDrain) {
|
|
|
130
130
|
abort: () => { for (const server of removed) drain.reopen(sessionId, server) },
|
|
131
131
|
}
|
|
132
132
|
},
|
|
133
|
+
/** Reopen only servers restored by an accepted Apply for this session. */
|
|
134
|
+
reopen(restored, sessionId) {
|
|
135
|
+
const drain = resolveDrain()
|
|
136
|
+
if (!drain) return
|
|
137
|
+
for (const server of restored) drain.reopen(sessionId, server)
|
|
138
|
+
},
|
|
133
139
|
}
|
|
134
140
|
}
|
|
@@ -14,13 +14,23 @@ import { createMcpFacade } from './mcp-facade.js'
|
|
|
14
14
|
*/
|
|
15
15
|
const bridge = new Map()
|
|
16
16
|
|
|
17
|
-
export function registerMcpFacadeBridge(groupId, payload) {
|
|
18
|
-
bridge.
|
|
19
|
-
|
|
17
|
+
export function registerMcpFacadeBridge(groupId, payload, owner = payload) {
|
|
18
|
+
if (bridge.has(groupId)) {
|
|
19
|
+
throw Object.assign(new Error(`MCP facade group ${groupId} already has an owner`), { code: 'duplicate-mcp-facade-owner' })
|
|
20
|
+
}
|
|
21
|
+
const record = { payload, owner }
|
|
22
|
+
bridge.set(groupId, record)
|
|
23
|
+
return () => {
|
|
24
|
+
// A stale/repeated disposer compares the exact registration and owner. It
|
|
25
|
+
// can never delete a later registration that reused the confirmed-free ID.
|
|
26
|
+
if (bridge.get(groupId) !== record || record.owner !== owner) return false
|
|
27
|
+
bridge.delete(groupId)
|
|
28
|
+
return true
|
|
29
|
+
}
|
|
20
30
|
}
|
|
21
31
|
|
|
22
32
|
export function mcpFacadeBridgeFor(groupId) {
|
|
23
|
-
return bridge.get(groupId) ?? null
|
|
33
|
+
return bridge.get(groupId)?.payload ?? null
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
export const name = 'weir-mcp-facade'
|
|
@@ -53,7 +53,7 @@ export function createMcpFacade({ host, identity, generation, admit, isEnabledFo
|
|
|
53
53
|
const disposeHost = typeof registration === 'function' ? registration : () => {}
|
|
54
54
|
let rollbackRegistration = null
|
|
55
55
|
try {
|
|
56
|
-
onRegister?.(name, rollback => { rollbackRegistration = rollback })
|
|
56
|
+
onRegister?.(name, rollback => { rollbackRegistration = rollback }, wrapped)
|
|
57
57
|
} catch (cause) {
|
|
58
58
|
// Host-first rollback is fail-closed: owner facts and partial denies
|
|
59
59
|
// stay in place unless Host removal is confirmed.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// The one read-only MCP Inventory Projection. Registry/runtime/mount/receipt and
|
|
2
|
+
// Loader facts are joined here; probing, mutation, credential resolution, agent
|
|
3
|
+
// visibility and every other operation remain with their existing owners.
|
|
4
|
+
// Pure dependencies keep this module independently testable and typechecked.
|
|
5
|
+
import { resolveMcpGroupIdentity } from './mcp-mount-ownership.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Classify Loader rows once and retain the facts needed by both public views.
|
|
9
|
+
* Every actual group remains a separate fact; same-identity groups never
|
|
10
|
+
* overwrite each other in an identity-keyed Map.
|
|
11
|
+
*
|
|
12
|
+
* @param {any[]} rows
|
|
13
|
+
* @param {Map<string, any>} mounted
|
|
14
|
+
* @param {Record<string, any>} registryServers
|
|
15
|
+
* @param {{ isResidue: (id: string) => boolean, groupPrefix: string, ownership?: any }} options
|
|
16
|
+
*/
|
|
17
|
+
function joinLoaderRows(rows, mounted, registryServers, { isResidue, groupPrefix, ownership = null }) {
|
|
18
|
+
const managed = []
|
|
19
|
+
const unmanaged = []
|
|
20
|
+
for (const row of rows) {
|
|
21
|
+
if (typeof row?.id === 'string' && isResidue(row.id)) {
|
|
22
|
+
const resolved = resolveMcpGroupIdentity({ groupId: row.id, mounted, ownership, row })
|
|
23
|
+
// Unresolved generated suffixes are conservative residue, not evidence
|
|
24
|
+
// from which a logical registry identity or mount health may be invented.
|
|
25
|
+
if (!resolved) continue
|
|
26
|
+
managed.push({
|
|
27
|
+
identity: resolved.identity,
|
|
28
|
+
groupId: row.id,
|
|
29
|
+
registry: registryServers[resolved.identity],
|
|
30
|
+
isMounted: resolved.mount != null,
|
|
31
|
+
mount: resolved.mount,
|
|
32
|
+
source: resolved.source,
|
|
33
|
+
allocation: resolved.allocation,
|
|
34
|
+
})
|
|
35
|
+
} else if (row?.name === '@deepseek-ai/dsh-mcp-client' && !(typeof row.id === 'string' && row.id.startsWith(groupPrefix))) {
|
|
36
|
+
unmanaged.push({ serverName: row.config?.serverName ?? row.id ?? 'unknown', state: 'unmanaged', readOnly: true })
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { managed, unmanaged }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Internal implementation behind mcp-manager.js's exported partitionMcpRows.
|
|
44
|
+
* Its field selection is the historical compatibility contract; production
|
|
45
|
+
* manager.list() uses the same Loader join and the full projection below.
|
|
46
|
+
*
|
|
47
|
+
* @param {any[]} rows
|
|
48
|
+
* @param {Map<string, any>} mounted
|
|
49
|
+
* @param {Record<string, any>} registryServers
|
|
50
|
+
* @param {{ isResidue: (id: string) => boolean, groupPrefix: string, ownership?: any }} options
|
|
51
|
+
*/
|
|
52
|
+
export function partitionMcpInventory(rows, mounted, registryServers, options) {
|
|
53
|
+
const joined = joinLoaderRows(rows, mounted, registryServers, options)
|
|
54
|
+
return {
|
|
55
|
+
managed: joined.managed.map(fact => ({
|
|
56
|
+
identity: fact.identity,
|
|
57
|
+
groupId: fact.groupId,
|
|
58
|
+
label: fact.registry?.label ?? fact.identity,
|
|
59
|
+
generation: fact.mount?.generation ?? fact.registry?.generation ?? null,
|
|
60
|
+
state: fact.isMounted ? 'mounted' : 'registered',
|
|
61
|
+
})),
|
|
62
|
+
unmanaged: joined.unmanaged,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {{
|
|
68
|
+
* registry: { read: () => Promise<any> },
|
|
69
|
+
* runtime: { snapshot: (identity: string) => any },
|
|
70
|
+
* mounted: Map<string, any>,
|
|
71
|
+
* receipts: { mount: Map<string, any>, test: Map<string, any> },
|
|
72
|
+
* listRows: () => any[],
|
|
73
|
+
* startErrors: Array<{ identity: string | null, reason: string }>,
|
|
74
|
+
* discoveryFor: (identity: string, generation: any) => any,
|
|
75
|
+
* support: () => any,
|
|
76
|
+
* isResidue: (id: string) => boolean,
|
|
77
|
+
* groupPrefix: string,
|
|
78
|
+
* ownership?: any,
|
|
79
|
+
* holdsReservation: (rows: any[], serverName: any) => any,
|
|
80
|
+
* redactEntry: (entry: any) => any,
|
|
81
|
+
* fingerprintEntry: (entry: any) => string,
|
|
82
|
+
* }} dependencies
|
|
83
|
+
*/
|
|
84
|
+
export function createMcpInventoryProjection(dependencies) {
|
|
85
|
+
const {
|
|
86
|
+
registry, runtime, mounted, receipts, listRows, startErrors,
|
|
87
|
+
discoveryFor, support, isResidue, groupPrefix, ownership = null, holdsReservation,
|
|
88
|
+
redactEntry, fingerprintEntry,
|
|
89
|
+
} = dependencies
|
|
90
|
+
|
|
91
|
+
async function list() {
|
|
92
|
+
const current = await registry.read()
|
|
93
|
+
const rows = listRows()
|
|
94
|
+
const registryServers = current.kind === 'ok' ? current.servers : {}
|
|
95
|
+
const loader = joinLoaderRows(rows, mounted, registryServers, { isResidue, groupPrefix, ownership })
|
|
96
|
+
if (current.kind === 'absent') {
|
|
97
|
+
return { managed: [], unmanaged: loader.unmanaged, warnings: [], registryRevision: 0, capabilities: support() }
|
|
98
|
+
}
|
|
99
|
+
if (current.kind !== 'ok') {
|
|
100
|
+
return { managed: [], unmanaged: loader.unmanaged, unavailable: { status: current.kind }, registryRevision: current.revision ?? 0, capabilities: support() }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Registry authority starts the full view. Join every actual Loader group,
|
|
104
|
+
// then select only this manager's exact own mount as mounted evidence.
|
|
105
|
+
const loaderByIdentity = new Map()
|
|
106
|
+
for (const fact of loader.managed) {
|
|
107
|
+
const facts = loaderByIdentity.get(fact.identity) ?? []
|
|
108
|
+
facts.push(fact)
|
|
109
|
+
loaderByIdentity.set(fact.identity, facts)
|
|
110
|
+
}
|
|
111
|
+
const defaults = new Set(current.enabled)
|
|
112
|
+
const managed = Object.values(current.servers).map(entry => {
|
|
113
|
+
const phase = runtime.snapshot(entry.identity)
|
|
114
|
+
const facts = loaderByIdentity.get(entry.identity) ?? []
|
|
115
|
+
const ownMount = mounted.get(entry.identity) ?? null
|
|
116
|
+
const ownLoaderFact = ownMount ? facts.find(fact => fact.groupId === ownMount.groupId) : null
|
|
117
|
+
// Some supported bare/test compositions expose no Loader listing. Keep
|
|
118
|
+
// their existing mount readback, but never let a different actual group
|
|
119
|
+
// satisfy this manager's strict mount evidence.
|
|
120
|
+
const mountedRecord = ownMount && (facts.length === 0 || ownLoaderFact) ? ownMount : null
|
|
121
|
+
const isMounted = phase.phase === 'mounted' && phase.generation === entry.generation && mountedRecord?.generation === entry.generation
|
|
122
|
+
const holder = holdsReservation(rows, entry.client?.serverName ?? entry.identity)
|
|
123
|
+
const state = holder ? 'conflict' : isMounted ? 'mounted' : phase.phase === 'saved-degraded' ? 'degraded' : ['cleanup-failed', 'cleanup-unknown'].includes(phase.phase) ? phase.phase : 'registered'
|
|
124
|
+
const discovery = discoveryFor(entry.identity, entry.generation)
|
|
125
|
+
return {
|
|
126
|
+
identity: entry.identity,
|
|
127
|
+
label: entry.label,
|
|
128
|
+
serverName: entry.client?.serverName ?? entry.identity,
|
|
129
|
+
transport: entry.transport?.kind ?? entry.client?.transport ?? 'unknown',
|
|
130
|
+
incarnation: entry.incarnation ?? null,
|
|
131
|
+
generation: entry.generation,
|
|
132
|
+
defaultEnabled: defaults.has(entry.identity),
|
|
133
|
+
state,
|
|
134
|
+
statusSource: isMounted ? 'registry-runtime-mount-readback' : holder ? 'serverName-reservation' : 'registry-runtime',
|
|
135
|
+
runtimePhase: phase.phase,
|
|
136
|
+
discovery: isMounted ? discovery : { toolCount: null, toolNamesPreview: [], truncated: false },
|
|
137
|
+
lastMount: receipts.mount.get(entry.identity) ?? null,
|
|
138
|
+
lastTest: receipts.test.get(entry.identity) ?? null,
|
|
139
|
+
startErrors: startErrors.filter(error => error.identity === entry.identity).map(error => ({ reason: error.reason })),
|
|
140
|
+
liveReconnect: 'unknown',
|
|
141
|
+
processExit: 'unknown',
|
|
142
|
+
stderr: 'unknown',
|
|
143
|
+
readOnly: false,
|
|
144
|
+
config: redactEntry(entry),
|
|
145
|
+
fingerprint: fingerprintEntry(entry),
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
return { managed, unmanaged: loader.unmanaged, warnings: current.warnings, registryRevision: current.revision, capabilities: support() }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { list }
|
|
152
|
+
}
|