@cat-factory/app 0.222.0 → 0.224.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/inputGate/InputGateNotice.vue +31 -9
- package/app/components/panels/inspector/TaskTypeFields.vue +115 -0
- package/app/components/provisioning/ProvisioningLogsDrawer.vue +1 -0
- package/app/components/settings/CapabilityCredentialsPanel.vue +19 -0
- package/app/components/settings/InfrastructureWindow.vue +18 -6
- package/app/components/settings/ToolServerChecklist.vue +264 -0
- package/app/composables/api/toolServers.ts +21 -0
- package/app/composables/useApi.ts +2 -0
- package/app/modular/panels/inspector.logic.spec.ts +5 -0
- package/app/modular/panels/inspector.logic.ts +6 -0
- package/app/modular/panels/inspector.ts +2 -0
- package/app/stores/agents.ts +23 -0
- package/app/stores/toolServers.spec.ts +142 -0
- package/app/stores/toolServers.ts +112 -0
- package/app/types/toolServers.ts +17 -0
- package/app/utils/catalog.companions.spec.ts +80 -0
- package/app/utils/catalog.spec.ts +1 -0
- package/app/utils/catalog.ts +77 -9
- package/i18n/locales/de.json +54 -0
- package/i18n/locales/en.json +54 -0
- package/i18n/locales/es.json +54 -0
- package/i18n/locales/fr.json +54 -0
- package/i18n/locales/he.json +54 -0
- package/i18n/locales/it.json +54 -0
- package/i18n/locales/ja.json +54 -0
- package/i18n/locales/pl.json +54 -0
- package/i18n/locales/tr.json +54 -0
- package/i18n/locales/uk.json +54 -0
- package/package.json +2 -2
package/app/stores/agents.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
AGENT_ARCHETYPES,
|
|
8
8
|
AGENT_BY_KIND,
|
|
9
9
|
setCustomAgentKindMeta,
|
|
10
|
+
setCustomCompanionTargets,
|
|
10
11
|
SYSTEM_AGENT_META,
|
|
11
12
|
uid,
|
|
12
13
|
} from '~/utils/catalog'
|
|
@@ -105,6 +106,28 @@ export const useAgentsStore = defineStore('agents', () => {
|
|
|
105
106
|
// no tick gap. The watch lives in the store's effect scope (disposed with it).
|
|
106
107
|
watch(customByKind, (map) => setCustomAgentKindMeta(map), { immediate: true, flush: 'sync' })
|
|
107
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The custom COMPANION pairings (companion kind → the producer kinds it reviews), read off the
|
|
111
|
+
* same custom-kind sources the palette is built from. Kept apart from `customByKind` because a
|
|
112
|
+
* pairing is not display metadata: the builder uses it to decide a kind is a TOGGLE on its
|
|
113
|
+
* producer rather than a placeable block, and `AgentArchetype` has no business carrying it.
|
|
114
|
+
*/
|
|
115
|
+
const customCompanions = computed<Record<string, readonly AgentKind[]>>(() => {
|
|
116
|
+
const out: Record<string, readonly AgentKind[]> = {}
|
|
117
|
+
const add = (k: CustomAgentKind) => {
|
|
118
|
+
// A pairing with no targets is not a pairing. Recording it would make the kind vanish from
|
|
119
|
+
// the palette (an `isProducerCompanion` hit) with no producer to hang the toggle on.
|
|
120
|
+
if (k.companionTargets?.length) out[k.kind] = k.companionTargets
|
|
121
|
+
}
|
|
122
|
+
for (const k of consumerKinds.value) add(k)
|
|
123
|
+
for (const k of capabilitiesManifest.value?.slots?.agentKinds ?? []) add(k)
|
|
124
|
+
return out
|
|
125
|
+
})
|
|
126
|
+
watch(customCompanions, (map) => setCustomCompanionTargets(map), {
|
|
127
|
+
immediate: true,
|
|
128
|
+
flush: 'sync',
|
|
129
|
+
})
|
|
130
|
+
|
|
108
131
|
/** Display metadata for a KNOWN kind (built-in / system / custom), else undefined. */
|
|
109
132
|
function get(kind: AgentKind): AgentArchetype | undefined {
|
|
110
133
|
return AGENT_BY_KIND[kind] ?? SYSTEM_AGENT_META[kind] ?? customByKind.value[kind]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { useToolServersStore } from '~/stores/toolServers'
|
|
3
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
+
import type { ToolServerProbeResult, ToolServerView } from '~/types/toolServers'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Three behaviours carry this store, each about not losing an answer:
|
|
8
|
+
*
|
|
9
|
+
* - the availability probe. A 403 ("you may not manage secrets") is an ANSWER and resolves
|
|
10
|
+
* normally, hiding the surface; anything else propagates, because the panel is what can tell a
|
|
11
|
+
* reader the list could not be fetched. There is deliberately no 503 case, unlike the
|
|
12
|
+
* credential store: projecting a registry needs no encryption key.
|
|
13
|
+
* - a probe result is kept for every VERDICT, failures included. A failure is exactly the answer
|
|
14
|
+
* the operator asked for, and a store that only kept successes would leave the row looking
|
|
15
|
+
* untouched after a dead endpoint was reported.
|
|
16
|
+
* - results survive a re-read of the inventory, because a result describes the SERVER rather than
|
|
17
|
+
* the list it arrived in.
|
|
18
|
+
*/
|
|
19
|
+
function server(over: Partial<ToolServerView> = {}): ToolServerView {
|
|
20
|
+
return {
|
|
21
|
+
id: 'issues',
|
|
22
|
+
label: 'Issue tracker',
|
|
23
|
+
transport: 'http',
|
|
24
|
+
target: 'https://mcp.example/rpc',
|
|
25
|
+
declaredBy: ['coder'],
|
|
26
|
+
servableHarnesses: ['claude-code'],
|
|
27
|
+
credentials: [],
|
|
28
|
+
probeable: true,
|
|
29
|
+
...over,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('toolServers store', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('load stores the inventory and marks the surface available', async () => {
|
|
39
|
+
vi.stubGlobal('useApi', () => ({
|
|
40
|
+
listToolServers: () => Promise.resolve({ servers: [server()] }),
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
const store = useToolServersStore()
|
|
44
|
+
await store.load()
|
|
45
|
+
|
|
46
|
+
expect(store.available).toBe(true)
|
|
47
|
+
expect(store.hasSurface).toBe(true)
|
|
48
|
+
expect(store.loading).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('shares one read between concurrent callers', async () => {
|
|
52
|
+
let reads = 0
|
|
53
|
+
vi.stubGlobal('useApi', () => ({
|
|
54
|
+
listToolServers: () => {
|
|
55
|
+
reads++
|
|
56
|
+
return Promise.resolve({ servers: [server()] })
|
|
57
|
+
},
|
|
58
|
+
}))
|
|
59
|
+
|
|
60
|
+
const store = useToolServersStore()
|
|
61
|
+
// Both callers fire on the same interaction: the Infrastructure window asks `ensureLoaded` whether
|
|
62
|
+
// the tab exists at all, and the panel refreshes on mount so a redeploy shows up without a reload.
|
|
63
|
+
// A read that started microseconds ago IS that refresh, so it is shared rather than duplicated.
|
|
64
|
+
await Promise.all([store.ensureLoaded(), store.load()])
|
|
65
|
+
|
|
66
|
+
expect(reads).toBe(1)
|
|
67
|
+
expect(store.hasSurface).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('a 403 latches the surface unavailable without throwing', async () => {
|
|
71
|
+
vi.stubGlobal('useApi', () => ({
|
|
72
|
+
listToolServers: () => Promise.reject({ statusCode: 403 }),
|
|
73
|
+
}))
|
|
74
|
+
|
|
75
|
+
const store = useToolServersStore()
|
|
76
|
+
await expect(store.load()).resolves.toBeUndefined()
|
|
77
|
+
|
|
78
|
+
expect(store.available).toBe(false)
|
|
79
|
+
expect(store.hasSurface).toBe(false)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('a transient failure propagates and latches nothing', async () => {
|
|
83
|
+
vi.stubGlobal('useApi', () => ({
|
|
84
|
+
listToolServers: () => Promise.reject({ statusCode: 500 }),
|
|
85
|
+
}))
|
|
86
|
+
|
|
87
|
+
const store = useToolServersStore()
|
|
88
|
+
await expect(store.load()).rejects.toBeDefined()
|
|
89
|
+
|
|
90
|
+
// `available` stays null so `ensureLoaded` remains retryable, and an already-loaded surface is
|
|
91
|
+
// not hidden by one bad read.
|
|
92
|
+
expect(store.available).toBeNull()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('reports no surface for a deployment that registers no tool server', async () => {
|
|
96
|
+
vi.stubGlobal('useApi', () => ({ listToolServers: () => Promise.resolve({ servers: [] }) }))
|
|
97
|
+
|
|
98
|
+
const store = useToolServersStore()
|
|
99
|
+
await store.load()
|
|
100
|
+
|
|
101
|
+
// An answer rather than possibly an outage: the inventory is read off this process's own
|
|
102
|
+
// registry, which is why there is no `declarationsIncomplete` counterpart here.
|
|
103
|
+
expect(store.available).toBe(true)
|
|
104
|
+
expect(store.hasSurface).toBe(false)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('keeps a FAILING probe result, and keeps it across a re-read of the inventory', async () => {
|
|
108
|
+
const failure: ToolServerProbeResult = {
|
|
109
|
+
serverId: 'issues',
|
|
110
|
+
status: 'unreachable',
|
|
111
|
+
error: 'TypeError: fetch failed',
|
|
112
|
+
}
|
|
113
|
+
vi.stubGlobal('useApi', () => ({
|
|
114
|
+
listToolServers: () => Promise.resolve({ servers: [server()] }),
|
|
115
|
+
probeToolServer: () => Promise.resolve(failure),
|
|
116
|
+
}))
|
|
117
|
+
|
|
118
|
+
const store = useToolServersStore()
|
|
119
|
+
await store.load()
|
|
120
|
+
await store.probe('issues')
|
|
121
|
+
|
|
122
|
+
expect(store.results.issues).toEqual(failure)
|
|
123
|
+
expect(store.probing).toBeNull()
|
|
124
|
+
|
|
125
|
+
await store.load()
|
|
126
|
+
expect(store.results.issues).toEqual(failure)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('propagates a THROWN probe failure without storing a verdict', async () => {
|
|
130
|
+
// A 404 for a server the deployment has since dropped, or a transient 5xx: not a verdict, so the
|
|
131
|
+
// row must not claim the probe answered.
|
|
132
|
+
vi.stubGlobal('useApi', () => ({
|
|
133
|
+
probeToolServer: () => Promise.reject({ statusCode: 404 }),
|
|
134
|
+
}))
|
|
135
|
+
|
|
136
|
+
const store = useToolServersStore()
|
|
137
|
+
await expect(store.probe('ghost')).rejects.toBeDefined()
|
|
138
|
+
|
|
139
|
+
expect(store.results.ghost).toBeUndefined()
|
|
140
|
+
expect(store.probing).toBeNull()
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import type { ToolServerProbeResult, ToolServersView } from '~/types/toolServers'
|
|
4
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
|
+
import { apiErrorStatus } from '~/composables/api/errors'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The deployment's tool servers (MCP) and the results of probing them.
|
|
9
|
+
*
|
|
10
|
+
* Two halves with different lifetimes, which is why they are separate refs. The INVENTORY is
|
|
11
|
+
* deployment code: it changes when the deployment redeploys, so it is loaded on demand and re-read
|
|
12
|
+
* rather than patched. A PROBE RESULT is a moment in time and belongs to the operator who asked for
|
|
13
|
+
* it, so results are kept per server id and never fetched eagerly — a probe spends an outbound
|
|
14
|
+
* request under the deployment's own credential, so opening a panel must not fire one.
|
|
15
|
+
*
|
|
16
|
+
* Mirrors the capability-credential store's availability handling deliberately: the two surfaces sit
|
|
17
|
+
* in one tab, gate on the same permission, and a member without it must see neither.
|
|
18
|
+
*/
|
|
19
|
+
export const useToolServersStore = defineStore('toolServers', () => {
|
|
20
|
+
const api = useApi()
|
|
21
|
+
|
|
22
|
+
const view = ref<ToolServersView | null>(null)
|
|
23
|
+
// Probe results by server id. Kept after a re-read of the inventory: a result describes the server
|
|
24
|
+
// rather than the list it arrived in, and dropping it on refresh would erase the answer the
|
|
25
|
+
// operator just asked for.
|
|
26
|
+
const results = ref<Record<string, ToolServerProbeResult>>({})
|
|
27
|
+
const probing = ref<string | null>(null)
|
|
28
|
+
const loading = ref(false)
|
|
29
|
+
// The backend's two definitive refusals: no `secrets.manage` (403), and — unlike the credential
|
|
30
|
+
// store — never a 503, since the inventory needs no encryption key to project a registry. `null`
|
|
31
|
+
// until first probed. A 403 HIDES the surface rather than disabling it, because the inventory
|
|
32
|
+
// names the deployment's credential keys and its endpoints.
|
|
33
|
+
const available = ref<boolean | null>(null)
|
|
34
|
+
let inFlight: Promise<void> | null = null
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether there is anything to show. A deployment that registers no tool server has no row to
|
|
38
|
+
* render, and the panel section is hidden rather than rendering an empty heading.
|
|
39
|
+
*
|
|
40
|
+
* No `declarationsIncomplete` counterpart here, unlike the credential checklist: the inventory is
|
|
41
|
+
* read straight off this process's own registry, so an empty answer is an answer rather than
|
|
42
|
+
* possibly an outage.
|
|
43
|
+
*/
|
|
44
|
+
const hasSurface = computed(() => (view.value?.servers.length ?? 0) > 0)
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Refresh the inventory, sharing a read that is already in flight.
|
|
48
|
+
*
|
|
49
|
+
* Coalescing belongs on `load` and not only on `ensureLoaded` because both callers fire on the
|
|
50
|
+
* same interaction: the Infrastructure window calls `ensureLoaded` to decide whether the tab
|
|
51
|
+
* exists at all, and the panel refreshes on mount so a redeploy shows up without a reload. Two
|
|
52
|
+
* identical GETs per open is what a plain "force" would have cost, and a read that started
|
|
53
|
+
* microseconds ago IS the refresh.
|
|
54
|
+
*/
|
|
55
|
+
async function load() {
|
|
56
|
+
if (inFlight) return inFlight
|
|
57
|
+
inFlight = readInventory().finally(() => (inFlight = null))
|
|
58
|
+
return inFlight
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readInventory() {
|
|
62
|
+
const ws = useWorkspaceStore()
|
|
63
|
+
loading.value = true
|
|
64
|
+
try {
|
|
65
|
+
view.value = await api.listToolServers(ws.requireId())
|
|
66
|
+
available.value = true
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (apiErrorStatus(err) === 403) {
|
|
69
|
+
// A definitive answer, not a failure: this caller may not manage secrets. Hide the surface
|
|
70
|
+
// and stop probing; resolve normally.
|
|
71
|
+
available.value = false
|
|
72
|
+
view.value = null
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
// Any other failure (transient 5xx / network) leaves the state untouched, so it neither hides
|
|
76
|
+
// an available panel nor caches a false "unavailable", and PROPAGATES: the panel is the one
|
|
77
|
+
// surface that can tell a reader it is looking at a list we could not fetch. Same split as the
|
|
78
|
+
// capability-credential store.
|
|
79
|
+
throw err
|
|
80
|
+
} finally {
|
|
81
|
+
loading.value = false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Load once and stay loaded; `load()` re-reads (both share whatever is in flight). */
|
|
86
|
+
async function ensureLoaded() {
|
|
87
|
+
if (available.value !== null) return
|
|
88
|
+
return load()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Probe ONE server and keep its result.
|
|
93
|
+
*
|
|
94
|
+
* The result is stored for every outcome, failures included: a failure IS the answer the operator
|
|
95
|
+
* asked for, and a store that only kept successes would leave the row looking untouched after the
|
|
96
|
+
* probe reported a dead endpoint. A thrown error (a 404 for a server the deployment has since
|
|
97
|
+
* dropped, a transient 5xx) propagates instead, because those are not probe verdicts.
|
|
98
|
+
*/
|
|
99
|
+
async function probe(id: string) {
|
|
100
|
+
const ws = useWorkspaceStore()
|
|
101
|
+
probing.value = id
|
|
102
|
+
try {
|
|
103
|
+
const result = await api.probeToolServer(ws.requireId(), id)
|
|
104
|
+
results.value = { ...results.value, [id]: result }
|
|
105
|
+
return result
|
|
106
|
+
} finally {
|
|
107
|
+
probing.value = null
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { view, results, probing, loading, available, hasSurface, load, ensureLoaded, probe }
|
|
112
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Tool server (MCP) operability shapes: what this deployment declared, and what a probe answered.
|
|
2
|
+
//
|
|
3
|
+
// All wire shapes are sourced from @cat-factory/contracts (single source of truth). The probe
|
|
4
|
+
// STATUS and the not-probeable REASON in particular are vocabularies both sides must agree about —
|
|
5
|
+
// the backend decides them, this app maps each member to translated copy plus a remedy — so a
|
|
6
|
+
// member added on one side only renders as a blank chip rather than failing to compile.
|
|
7
|
+
|
|
8
|
+
export type {
|
|
9
|
+
ToolServerAllowedToolsCheck,
|
|
10
|
+
ToolServerCredential,
|
|
11
|
+
ToolServerNotProbeableReason,
|
|
12
|
+
ToolServerProbeResult,
|
|
13
|
+
ToolServerProbeStatus,
|
|
14
|
+
ToolServerTransport,
|
|
15
|
+
ToolServerView,
|
|
16
|
+
ToolServersView,
|
|
17
|
+
} from '@cat-factory/contracts'
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import type { AgentKind } from '~/types/domain'
|
|
3
|
+
import {
|
|
4
|
+
COMPANION_FOR_PRODUCER,
|
|
5
|
+
__resetCustomCompanionTargetsForTest,
|
|
6
|
+
companionForProducer,
|
|
7
|
+
isProducerCompanion,
|
|
8
|
+
setCustomCompanionTargets,
|
|
9
|
+
} from '~/utils/catalog'
|
|
10
|
+
|
|
11
|
+
// The SPA half of the companion registry. A companion is not a placeable palette block: the
|
|
12
|
+
// builder renders it as an "add companion" toggle ON its producer and inserts it immediately
|
|
13
|
+
// after. These two lookups are what decide that, so a deployment's registered pair is either
|
|
14
|
+
// visible as a toggle or invisible entirely, with nothing in between.
|
|
15
|
+
//
|
|
16
|
+
// The backend half is `extension-registries.companions.test.ts`. Both are needed: the backend
|
|
17
|
+
// enforces the adjacency rule on save, and this decides whether a person can ever express the
|
|
18
|
+
// pairing in the first place.
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
__resetCustomCompanionTargetsForTest()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('custom companion pairings in the palette', () => {
|
|
25
|
+
it('has no opinion about a deployment’s pair until the store projects it', () => {
|
|
26
|
+
// The pre-registration state is a real one: the snapshot arrives after first paint, and a
|
|
27
|
+
// custom companion must degrade to an ordinary kind rather than to a broken toggle.
|
|
28
|
+
expect(isProducerCompanion('acme:migration-auditor')).toBe(false)
|
|
29
|
+
expect(companionForProducer('acme:migrator')).toBeUndefined()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('renders a registered pair as a toggle on its producer', () => {
|
|
33
|
+
setCustomCompanionTargets({ 'acme:migration-auditor': ['acme:migrator'] })
|
|
34
|
+
expect(companionForProducer('acme:migrator')).toBe('acme:migration-auditor')
|
|
35
|
+
// ...and the companion itself leaves the palette, which is the other half of "it is a
|
|
36
|
+
// toggle, not a block". Registering only one of these would show the kind twice.
|
|
37
|
+
expect(isProducerCompanion('acme:migration-auditor')).toBe(true)
|
|
38
|
+
// The producer is not a companion just for being reviewed by one.
|
|
39
|
+
expect(isProducerCompanion('acme:migrator')).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('lets one companion review several producers', () => {
|
|
43
|
+
setCustomCompanionTargets({ 'acme:auditor': ['acme:migrator', 'acme:packager'] })
|
|
44
|
+
expect(companionForProducer('acme:migrator')).toBe('acme:auditor')
|
|
45
|
+
expect(companionForProducer('acme:packager')).toBe('acme:auditor')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('never lets a deployment re-point a BUILT-IN producer at its own companion', () => {
|
|
49
|
+
// Built-ins win, matching `agentKindMeta`'s precedence and the backend registry's refusal to
|
|
50
|
+
// shadow a built-in kind. The shipped pairing is what every stock pipeline relies on, so a
|
|
51
|
+
// silent re-point would change what those pipelines do without anyone editing them.
|
|
52
|
+
setCustomCompanionTargets({ 'acme:reviewer': ['coder'] })
|
|
53
|
+
expect(companionForProducer('coder')).toBe(COMPANION_FOR_PRODUCER.coder)
|
|
54
|
+
// The custom kind still leaves the palette: it IS a companion, it just does not get `coder`.
|
|
55
|
+
expect(isProducerCompanion('acme:reviewer')).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('resolves a contested producer deterministically, first registration winning', () => {
|
|
59
|
+
// Only one toggle can hang off a producer, so two companions claiming it is a case with an
|
|
60
|
+
// answer whether or not anyone chose one. Pinning it here is what keeps the answer from
|
|
61
|
+
// being "whichever the object happened to enumerate first".
|
|
62
|
+
const contested: Record<string, readonly AgentKind[]> = {
|
|
63
|
+
'acme:first': ['acme:migrator'],
|
|
64
|
+
'acme:second': ['acme:migrator'],
|
|
65
|
+
}
|
|
66
|
+
setCustomCompanionTargets(contested)
|
|
67
|
+
expect(companionForProducer('acme:migrator')).toBe('acme:first')
|
|
68
|
+
expect(isProducerCompanion('acme:second')).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('drops a pairing when the catalog it came from goes away', () => {
|
|
72
|
+
setCustomCompanionTargets({ 'acme:migration-auditor': ['acme:migrator'] })
|
|
73
|
+
expect(companionForProducer('acme:migrator')).toBe('acme:migration-auditor')
|
|
74
|
+
// A workspace switch re-projects an empty catalog. The lookups must follow it rather than
|
|
75
|
+
// keep answering from the old deployment's registrations.
|
|
76
|
+
setCustomCompanionTargets({})
|
|
77
|
+
expect(companionForProducer('acme:migrator')).toBeUndefined()
|
|
78
|
+
expect(isProducerCompanion('acme:migration-auditor')).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
})
|
package/app/utils/catalog.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { shallowRef } from 'vue'
|
|
1
|
+
import { computed, shallowRef } from 'vue'
|
|
2
2
|
import type {
|
|
3
3
|
AgentArchetype,
|
|
4
4
|
AgentCategory,
|
|
@@ -228,6 +228,21 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
|
|
|
228
228
|
// recreate / destroy) instead of the generic prose step-detail panel.
|
|
229
229
|
resultView: 'human-test',
|
|
230
230
|
},
|
|
231
|
+
{
|
|
232
|
+
// The `deployer`'s counterpart at the other end of the environment lifecycle, and a PALETTE
|
|
233
|
+
// block rather than a system kind precisely because deciding WHEN the environment goes away
|
|
234
|
+
// is the point of it: after the automated tester, or after a human has finished with the live
|
|
235
|
+
// URL. Without one, the TTL sweep reclaims environments on a timer long after the run
|
|
236
|
+
// settled, which is a fine backstop and cannot close the run's own teardown proof.
|
|
237
|
+
kind: 'disposer',
|
|
238
|
+
tier: 'intermediate',
|
|
239
|
+
label: 'Disposer',
|
|
240
|
+
icon: 'i-lucide-cloud-off',
|
|
241
|
+
color: '#34d399',
|
|
242
|
+
category: 'test',
|
|
243
|
+
description:
|
|
244
|
+
'Reclaims the ephemeral environments this run provisioned, and confirms they are actually gone. Place it after the last step that needs the environment.',
|
|
245
|
+
},
|
|
231
246
|
{
|
|
232
247
|
kind: 'visual-confirmation',
|
|
233
248
|
tier: 'advanced',
|
|
@@ -322,9 +337,13 @@ export const COMPANION_ARCHETYPES: AgentArchetype[] = [
|
|
|
322
337
|
]
|
|
323
338
|
|
|
324
339
|
/**
|
|
325
|
-
* Producer agent kind → its companion agent kind. Mirrors the backend
|
|
326
|
-
* (`@cat-factory/agents`). The builder shows an "add companion" toggle on
|
|
327
|
-
* found here, and inserts/removes the companion immediately after it.
|
|
340
|
+
* Producer agent kind → its companion agent kind, for the BUILT-IN pairs. Mirrors the backend
|
|
341
|
+
* `COMPANIONS` catalog (`@cat-factory/agents`). The builder shows an "add companion" toggle on
|
|
342
|
+
* a producer step found here, and inserts/removes the companion immediately after it.
|
|
343
|
+
*
|
|
344
|
+
* A DEPLOYMENT's own pair does not live here: it arrives on the snapshot as a custom agent
|
|
345
|
+
* kind carrying `companionTargets`, and is projected into {@link customCompanionTargets} by the
|
|
346
|
+
* agents store. Both are consulted below, built-ins first.
|
|
328
347
|
*/
|
|
329
348
|
export const COMPANION_FOR_PRODUCER: Record<string, AgentKind> = {
|
|
330
349
|
coder: 'reviewer',
|
|
@@ -335,18 +354,67 @@ export const COMPANION_FOR_PRODUCER: Record<string, AgentKind> = {
|
|
|
335
354
|
|
|
336
355
|
const COMPANION_KINDS: ReadonlySet<string> = new Set(COMPANION_ARCHETYPES.map((a) => a.kind))
|
|
337
356
|
|
|
338
|
-
/**
|
|
357
|
+
/**
|
|
358
|
+
* Reactive read-model of the deployment's CUSTOM companion pairings (companion kind → the
|
|
359
|
+
* producer kinds it reviews), kept in sync by the agents store from the snapshot's
|
|
360
|
+
* `customAgentKinds[].companionTargets`.
|
|
361
|
+
*
|
|
362
|
+
* A `shallowRef` for the same reason {@link customAgentKindMeta} is one: the pure lookups below
|
|
363
|
+
* must resolve a registered companion, and re-render when the catalog changes, without importing
|
|
364
|
+
* the store (circular) or mutating the frozen built-in map. Empty until the store first
|
|
365
|
+
* populates it, so a custom companion degrades to "not a companion" (an ordinary palette block),
|
|
366
|
+
* exactly as before registration.
|
|
367
|
+
*/
|
|
368
|
+
const customCompanionTargets = shallowRef<Record<string, readonly AgentKind[]>>({})
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The same projection INVERTED: producer kind → the companion that reviews it, the direction
|
|
372
|
+
* {@link companionForProducer} actually asks in. Derived rather than scanned per call, because
|
|
373
|
+
* that lookup runs once per step of every pipeline the builder renders.
|
|
374
|
+
*
|
|
375
|
+
* Inverting is also where an ambiguity has to be RESOLVED rather than left to iteration order:
|
|
376
|
+
* two registered companions may both claim a producer, and only one toggle can hang off it.
|
|
377
|
+
* First registration wins, stated here once, instead of "whichever `Object.entries` reached
|
|
378
|
+
* first" being the answer at each call site.
|
|
379
|
+
*/
|
|
380
|
+
const customCompanionByProducer = computed<Record<string, AgentKind>>(() => {
|
|
381
|
+
const out: Record<string, AgentKind> = {}
|
|
382
|
+
for (const [companion, targets] of Object.entries(customCompanionTargets.value)) {
|
|
383
|
+
for (const producer of targets) if (!(producer in out)) out[producer] = companion
|
|
384
|
+
}
|
|
385
|
+
return out
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
/** Replace the custom companion projection (called only by the agents store). */
|
|
389
|
+
export function setCustomCompanionTargets(map: Record<string, readonly AgentKind[]>): void {
|
|
390
|
+
customCompanionTargets.value = map
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Test-only: clear the custom companion projection so a spec starts from built-ins only. */
|
|
394
|
+
export function __resetCustomCompanionTargetsForTest(): void {
|
|
395
|
+
customCompanionTargets.value = {}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The companion kind that depends on a producer kind, or undefined if it has none.
|
|
400
|
+
*
|
|
401
|
+
* Built-ins win. A deployment cannot re-point `coder` at its own reviewer by registering one,
|
|
402
|
+
* for the same reason `agentKindMeta`'s precedence puts built-ins first and the backend registry
|
|
403
|
+
* never shadows a built-in kind: the shipped pairing is the one the engine's own pipelines rely
|
|
404
|
+
* on, and a silent re-point would change what every stock pipeline does.
|
|
405
|
+
*/
|
|
339
406
|
export function companionForProducer(kind: string): AgentKind | undefined {
|
|
340
|
-
return COMPANION_FOR_PRODUCER[kind]
|
|
407
|
+
return COMPANION_FOR_PRODUCER[kind] ?? customCompanionByProducer.value[kind]
|
|
341
408
|
}
|
|
342
409
|
|
|
343
410
|
/**
|
|
344
411
|
* Whether a kind is a dependent producer-companion (reviewer / architect-companion /
|
|
345
|
-
* spec-companion)
|
|
346
|
-
* Distinct from `pipelineRender`'s `isCompanionKind`, which also
|
|
412
|
+
* spec-companion, or a deployment's own): rendered as a toggle on its producer, not a
|
|
413
|
+
* standalone palette block. Distinct from `pipelineRender`'s `isCompanionKind`, which also
|
|
414
|
+
* counts the Tester's `fixer`.
|
|
347
415
|
*/
|
|
348
416
|
export function isProducerCompanion(kind: string): boolean {
|
|
349
|
-
return COMPANION_KINDS.has(kind)
|
|
417
|
+
return COMPANION_KINDS.has(kind) || kind in customCompanionTargets.value
|
|
350
418
|
}
|
|
351
419
|
|
|
352
420
|
/**
|
package/i18n/locales/de.json
CHANGED
|
@@ -606,6 +606,48 @@
|
|
|
606
606
|
"removeFailed": "Der Registry-Eintrag konnte nicht entfernt werden"
|
|
607
607
|
}
|
|
608
608
|
},
|
|
609
|
+
"toolServers": {
|
|
610
|
+
"heading": "Werkzeugserver (MCP)",
|
|
611
|
+
"intro": "Die MCP-Server, die diese Installation für ihre Agenten registriert. Ein Test löst die Zugangsdaten dieses Boards auf und spricht das Protokoll mit dem Server, das Ergebnis entspricht also dem, was ein Lauf erhält.",
|
|
612
|
+
"transport": {
|
|
613
|
+
"stdio": "Im Container",
|
|
614
|
+
"http": "Extern"
|
|
615
|
+
},
|
|
616
|
+
"declaredBy": "Zugewiesen an: {kinds}",
|
|
617
|
+
"declaredByNone": "Kein Agent erhält diesen Server, daher startet ihn kein Lauf.",
|
|
618
|
+
"servableHarnesses": "Läuft mit: {harnesses}",
|
|
619
|
+
"servableHarnessesNone": "Keine Agenten-CLI kann diesen Transport bedienen, daher greift dieser Server in keinem Lauf.",
|
|
620
|
+
"allowedTools": "Eingeschränkt auf: {tools}",
|
|
621
|
+
"credentials": "Zugangsdaten: {keys}",
|
|
622
|
+
"test": "Testen",
|
|
623
|
+
"notProbeable": {
|
|
624
|
+
"stdio": "Läuft im Container des Agenten und kann von hier aus nicht getestet werden.",
|
|
625
|
+
"containerLocal": "Lauscht neben dem Agenten in dessen eigenem Container, der von hier aus nicht erreichbar ist.",
|
|
626
|
+
"urlNotAllowed": "Unter dieser Adresse darf ein Werkzeugserver nicht erreicht werden (https oder einfaches http nur auf localhost)."
|
|
627
|
+
},
|
|
628
|
+
"status": {
|
|
629
|
+
"ok": "Hat geantwortet",
|
|
630
|
+
"credentialsMissing": "Keine Zugangsdaten",
|
|
631
|
+
"credentialRefused": "Zugangsdaten abgelehnt",
|
|
632
|
+
"unreachable": "Keine Antwort",
|
|
633
|
+
"httpError": "Anfrage abgewiesen",
|
|
634
|
+
"protocolError": "Kein MCP-Server",
|
|
635
|
+
"notProbeable": "Von hier nicht testbar"
|
|
636
|
+
},
|
|
637
|
+
"okDetail": "{name} {version}, Protokoll {protocol}, {count} Werkzeuge.",
|
|
638
|
+
"toolsIncomplete": "Der Server hat mehr Werkzeuge, als ein Test liest, diese Zahl ist also ein Mindestwert.",
|
|
639
|
+
"unmatchedTools": "Dieser Server bietet kein Werkzeug namens {tools}, dem Agenten wird also ein Werkzeug angekündigt, das er nicht aufrufen kann.",
|
|
640
|
+
"allowedToolsUnchecked": "Die Werkzeugliste war zu lang, um sie vollständig zu lesen, daher konnten die eingeschränkten Namen nicht geprüft werden.",
|
|
641
|
+
"unresolvedCredentials": "Für {keys} wurde nichts aufgelöst. Trage den Wert unten ein oder setze ihn in der Umgebung der Installation.",
|
|
642
|
+
"refusedCredentials": "{keys} benennt eine Variable, die zur Konfiguration der Plattform selbst gehört, und wird daher nie aufgelöst. Ändere die Deklaration im Code der Installation.",
|
|
643
|
+
"httpStatus": "HTTP {status}",
|
|
644
|
+
"showDetails": "Details anzeigen",
|
|
645
|
+
"hideDetails": "Details verbergen",
|
|
646
|
+
"toast": {
|
|
647
|
+
"loadFailed": "Die Werkzeugserver konnten nicht geladen werden",
|
|
648
|
+
"probeFailed": "Der Werkzeugserver konnte nicht getestet werden"
|
|
649
|
+
}
|
|
650
|
+
},
|
|
609
651
|
"capabilityCredentials": {
|
|
610
652
|
"tab": "Zugangsdaten für Fähigkeiten",
|
|
611
653
|
"intro": "Die Secrets, die die Tool-Server und generativen Integrationen dieser Installation namentlich anfordern. Werte gelten nur für dieses Board, werden verschlüsselt gespeichert und direkt an den Prozess des Agenten übergeben: Sie erscheinen weder in einem Prompt noch in einem Log. Werte lassen sich nur schreiben, nie auslesen, ein gespeicherter Wert wird also durch Eingabe eines neuen ersetzt.",
|
|
@@ -1491,6 +1533,12 @@
|
|
|
1491
1533
|
"inherited": "vom Service geerbt",
|
|
1492
1534
|
"frozen": "Eingefroren: der Agent hat gestartet"
|
|
1493
1535
|
},
|
|
1536
|
+
"taskTypeFields": {
|
|
1537
|
+
"title": "Aufgabentyp-Felder",
|
|
1538
|
+
"hint": "Die Antworten, die dieser Aufgabentyp benötigt. Fehlt eine Pflichtangabe, wird ein Lauf vor dem Start angehalten.",
|
|
1539
|
+
"save": "Speichern",
|
|
1540
|
+
"revert": "Zurücksetzen"
|
|
1541
|
+
},
|
|
1494
1542
|
"dependencies": {
|
|
1495
1543
|
"title": "Hängt ab von",
|
|
1496
1544
|
"hint": "Aufgaben, die gemergt sein müssen, bevor diese laufen kann. Das Run-Steuerelement bleibt gesperrt, bis jede Abhängigkeit erledigt ist.",
|
|
@@ -5519,6 +5567,11 @@
|
|
|
5519
5567
|
"title": "Keine Erfolgskriterien",
|
|
5520
5568
|
"hint": "Nenne die Frage, die der Spike beantwortet, oder wie ein gutes Ergebnis aussieht, damit die Zeitbox ein Ziel hat."
|
|
5521
5569
|
},
|
|
5570
|
+
"required_field_missing": {
|
|
5571
|
+
"title": "Fehlt: {field}",
|
|
5572
|
+
"hint": "Dieser Aufgabentyp verlangt „{field}“, die Aufgabe beantwortet es aber nicht. Trage es an der Aufgabe nach und prüfe erneut.",
|
|
5573
|
+
"unnamedField": "ein Pflichtfeld"
|
|
5574
|
+
},
|
|
5522
5575
|
"unknown": {
|
|
5523
5576
|
"title": "Unbekannter Befund",
|
|
5524
5577
|
"hint": "Dieser Lauf hat eine Prüfung erfasst, die diese Version nicht mehr kennt. Sieh die Aufgabe von Hand durch, bevor du fortfährst."
|
|
@@ -5872,6 +5925,7 @@
|
|
|
5872
5925
|
"operation": {
|
|
5873
5926
|
"provision": "Hochfahren",
|
|
5874
5927
|
"teardown": "Abbauen",
|
|
5928
|
+
"teardown-verify": "Abbau-Prüfung",
|
|
5875
5929
|
"status": "Statusprüfung",
|
|
5876
5930
|
"dispatch": "Hochfahren",
|
|
5877
5931
|
"release": "Abbauen",
|