@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
|
@@ -64,6 +64,10 @@ const ISSUE_KEYS = {
|
|
|
64
64
|
title: 'inputGate.issue.success_criteria_missing.title',
|
|
65
65
|
hint: 'inputGate.issue.success_criteria_missing.hint',
|
|
66
66
|
},
|
|
67
|
+
required_field_missing: {
|
|
68
|
+
title: 'inputGate.issue.required_field_missing.title',
|
|
69
|
+
hint: 'inputGate.issue.required_field_missing.hint',
|
|
70
|
+
},
|
|
67
71
|
} as const satisfies Record<InputGateIssueCode, { title: string; hint: string }>
|
|
68
72
|
|
|
69
73
|
/**
|
|
@@ -87,16 +91,30 @@ const TONE_COPY: Record<InputGateTone, { title: string; body: string }> = {
|
|
|
87
91
|
}
|
|
88
92
|
const copy = computed(() => TONE_COPY[props.tone])
|
|
89
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The interpolation a finding's copy is rendered with. Only `required_field_missing` carries a
|
|
96
|
+
* `field`, and its copy is the one line that cannot be written without knowing which input is
|
|
97
|
+
* missing: a deployment registers its own task types, so the platform has no vocabulary for
|
|
98
|
+
* "the incident's severity" and names the field instead. The label is deployment-supplied
|
|
99
|
+
* English, exactly as a custom agent kind's is.
|
|
100
|
+
*
|
|
101
|
+
* A finding whose `field` is somehow absent still renders: the copy falls back to naming no
|
|
102
|
+
* field rather than printing `undefined` into a sentence a human is meant to act on.
|
|
103
|
+
*/
|
|
104
|
+
function issueValues(issue: InputGateIssue): Record<string, string> {
|
|
105
|
+
return { field: issue.field?.label ?? t('inputGate.issue.required_field_missing.unnamedField') }
|
|
106
|
+
}
|
|
107
|
+
|
|
90
108
|
/** A finding's translated title, falling back to the generic line for a retired code. */
|
|
91
|
-
function issueTitle(
|
|
92
|
-
const key = ISSUE_KEYS[code]?.title
|
|
93
|
-
return key && te(key) ? t(key) : t('inputGate.issue.unknown.title')
|
|
109
|
+
function issueTitle(issue: InputGateIssue): string {
|
|
110
|
+
const key = ISSUE_KEYS[issue.code]?.title
|
|
111
|
+
return key && te(key) ? t(key, issueValues(issue)) : t('inputGate.issue.unknown.title')
|
|
94
112
|
}
|
|
95
113
|
|
|
96
114
|
/** A finding's translated remedy hint, on the same fallback. */
|
|
97
|
-
function issueHint(
|
|
98
|
-
const key = ISSUE_KEYS[code]?.hint
|
|
99
|
-
return key && te(key) ? t(key) : t('inputGate.issue.unknown.hint')
|
|
115
|
+
function issueHint(issue: InputGateIssue): string {
|
|
116
|
+
const key = ISSUE_KEYS[issue.code]?.hint
|
|
117
|
+
return key && te(key) ? t(key, issueValues(issue)) : t('inputGate.issue.unknown.hint')
|
|
100
118
|
}
|
|
101
119
|
|
|
102
120
|
async function resolve(choice: 'recheck' | 'proceed') {
|
|
@@ -126,7 +144,11 @@ async function resolve(choice: 'recheck' | 'proceed') {
|
|
|
126
144
|
<p v-if="!compact" class="text-muted mt-0.5 text-xs">{{ t(copy.body) }}</p>
|
|
127
145
|
|
|
128
146
|
<ul class="mt-2 space-y-1.5">
|
|
129
|
-
<li
|
|
147
|
+
<li
|
|
148
|
+
v-for="issue in issues"
|
|
149
|
+
:key="`${issue.code}:${issue.field?.key ?? ''}`"
|
|
150
|
+
class="flex items-start gap-2 text-xs"
|
|
151
|
+
>
|
|
130
152
|
<UBadge
|
|
131
153
|
:color="issue.severity === 'blocking' ? 'warning' : 'neutral'"
|
|
132
154
|
variant="subtle"
|
|
@@ -139,8 +161,8 @@ async function resolve(choice: 'recheck' | 'proceed') {
|
|
|
139
161
|
}}
|
|
140
162
|
</UBadge>
|
|
141
163
|
<span class="min-w-0">
|
|
142
|
-
<span class="font-medium">{{ issueTitle(issue
|
|
143
|
-
<span class="text-muted">, {{ issueHint(issue
|
|
164
|
+
<span class="font-medium">{{ issueTitle(issue) }}</span>
|
|
165
|
+
<span class="text-muted">, {{ issueHint(issue) }}</span>
|
|
144
166
|
</span>
|
|
145
167
|
</li>
|
|
146
168
|
</ul>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The answers to a CUSTOM task type's own declared fields, editable after creation.
|
|
3
|
+
//
|
|
4
|
+
// Why it exists: the create form is not the only door a task arrives through (the public API, an
|
|
5
|
+
// initiative spawn, a tracker import), and a type's declaration can get STRICTER after a task
|
|
6
|
+
// already exists. The pre-dispatch input gate judges the declaration as it stands now, so it
|
|
7
|
+
// parks runs whose task predates the requirement. Without this panel that park had exactly one
|
|
8
|
+
// exit, a human waiving the gate: `recheck` would re-read the same unanswered bag forever, and
|
|
9
|
+
// the remedy the notice names ("fill it in on the task") would be one nothing offered.
|
|
10
|
+
//
|
|
11
|
+
// Renders through the SAME `DescriptorFields` component the create form uses, against the SAME
|
|
12
|
+
// declaration, validated by the SAME shared rule. A field the form would have hidden by its
|
|
13
|
+
// `showWhen` is hidden here too, so the two doors cannot show a person different questions.
|
|
14
|
+
import { computed, ref, watch } from 'vue'
|
|
15
|
+
import type { DescriptorFieldValues } from '@cat-factory/contracts'
|
|
16
|
+
import { sanitizeDescriptorFields, validateDescriptorFields } from '@cat-factory/contracts'
|
|
17
|
+
import type { Block } from '~/types/domain'
|
|
18
|
+
import DescriptorFields from '~/components/common/DescriptorFields.vue'
|
|
19
|
+
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
20
|
+
|
|
21
|
+
const props = defineProps<{ block: Block }>()
|
|
22
|
+
|
|
23
|
+
const board = useBoardStore()
|
|
24
|
+
const taskTypes = useTaskTypesStore()
|
|
25
|
+
const { t } = useI18n()
|
|
26
|
+
|
|
27
|
+
/** The registered type this task is, or undefined for a built-in / unregistered one. */
|
|
28
|
+
const descriptor = computed(() =>
|
|
29
|
+
props.block.taskType
|
|
30
|
+
? taskTypes.customTaskTypes.find((tt) => tt.taskType === props.block.taskType)
|
|
31
|
+
: undefined,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The fields to render. A type carrying a bespoke `formPanel` owns its whole bag, so its
|
|
36
|
+
* descriptor fields are not what was collected and editing them here would write values its own
|
|
37
|
+
* form never offered. That is the same stand-down the create door and the input gate take, and
|
|
38
|
+
* all three have to agree or "the declaration" would mean three different things.
|
|
39
|
+
*/
|
|
40
|
+
const fields = computed(() => (descriptor.value?.formPanel ? [] : (descriptor.value?.fields ?? [])))
|
|
41
|
+
|
|
42
|
+
const stored = computed<DescriptorFieldValues>(() => props.block.taskTypeFields?.custom ?? {})
|
|
43
|
+
|
|
44
|
+
// Local edit buffer, re-seeded whenever the stored bag changes underneath (a live board push, or
|
|
45
|
+
// switching blocks). Editing writes on commit rather than per keystroke, so a half-typed answer
|
|
46
|
+
// never reaches the row the gate reads.
|
|
47
|
+
const draft = ref<DescriptorFieldValues>({ ...stored.value })
|
|
48
|
+
watch(stored, (next) => {
|
|
49
|
+
draft.value = { ...next }
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The same check the server runs, so the button reflects an invalid form rather than the save
|
|
54
|
+
* failing with a 422. Shared from contracts precisely so the two cannot drift.
|
|
55
|
+
*/
|
|
56
|
+
const problems = computed(() => validateDescriptorFields(fields.value, draft.value))
|
|
57
|
+
|
|
58
|
+
const dirty = computed(
|
|
59
|
+
() =>
|
|
60
|
+
JSON.stringify(sanitizeDescriptorFields(fields.value, draft.value)) !==
|
|
61
|
+
JSON.stringify(stored.value),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
const saving = ref(false)
|
|
65
|
+
|
|
66
|
+
async function save() {
|
|
67
|
+
if (problems.value.length || !dirty.value) return
|
|
68
|
+
saving.value = true
|
|
69
|
+
try {
|
|
70
|
+
await board.updateBlock(props.block.id, {
|
|
71
|
+
customTaskTypeFields: sanitizeDescriptorFields(fields.value, draft.value),
|
|
72
|
+
})
|
|
73
|
+
} finally {
|
|
74
|
+
saving.value = false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function revert() {
|
|
79
|
+
draft.value = { ...stored.value }
|
|
80
|
+
}
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<template>
|
|
84
|
+
<InspectorSection
|
|
85
|
+
v-if="fields.length"
|
|
86
|
+
:title="t('inspector.taskTypeFields.title')"
|
|
87
|
+
:hint="t('inspector.taskTypeFields.hint')"
|
|
88
|
+
icon="i-lucide-clipboard-list"
|
|
89
|
+
:count="fields.length"
|
|
90
|
+
>
|
|
91
|
+
<DescriptorFields v-model="draft" :fields="fields" testid-prefix="task-type-field" />
|
|
92
|
+
<div v-if="dirty" class="mt-2 flex items-center gap-2">
|
|
93
|
+
<UButton
|
|
94
|
+
size="xs"
|
|
95
|
+
color="primary"
|
|
96
|
+
variant="soft"
|
|
97
|
+
:loading="saving"
|
|
98
|
+
:disabled="problems.length > 0"
|
|
99
|
+
data-testid="task-type-fields-save"
|
|
100
|
+
@click="save"
|
|
101
|
+
>
|
|
102
|
+
{{ t('inspector.taskTypeFields.save') }}
|
|
103
|
+
</UButton>
|
|
104
|
+
<UButton
|
|
105
|
+
size="xs"
|
|
106
|
+
color="neutral"
|
|
107
|
+
variant="ghost"
|
|
108
|
+
data-testid="task-type-fields-revert"
|
|
109
|
+
@click="revert"
|
|
110
|
+
>
|
|
111
|
+
{{ t('inspector.taskTypeFields.revert') }}
|
|
112
|
+
</UButton>
|
|
113
|
+
</div>
|
|
114
|
+
</InspectorSection>
|
|
115
|
+
</template>
|
|
@@ -88,6 +88,7 @@ onBeforeUnmount(() => {
|
|
|
88
88
|
const OPERATION_LABEL = computed<Record<ProvisioningOperation, string>>(() => ({
|
|
89
89
|
provision: t('provisioning.operation.provision'),
|
|
90
90
|
teardown: t('provisioning.operation.teardown'),
|
|
91
|
+
'teardown-verify': t('provisioning.operation.teardown-verify'),
|
|
91
92
|
status: t('provisioning.operation.status'),
|
|
92
93
|
dispatch: t('provisioning.operation.dispatch'),
|
|
93
94
|
release: t('provisioning.operation.release'),
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
17
17
|
import type { CapabilityCredentialStatus } from '~/types/capabilityCredentials'
|
|
18
18
|
import SecretInput from '~/components/common/SecretInput.vue'
|
|
19
|
+
import ToolServerChecklist from '~/components/settings/ToolServerChecklist.vue'
|
|
19
20
|
|
|
20
21
|
const { t, d } = useI18n()
|
|
21
22
|
const store = useCapabilityCredentialsStore()
|
|
@@ -63,6 +64,19 @@ onMounted(async () => {
|
|
|
63
64
|
}
|
|
64
65
|
})
|
|
65
66
|
|
|
67
|
+
// The tool-server inventory that renders above the checklist. Its own read (and its own failure
|
|
68
|
+
// report, for the same reason): the two surfaces answer different questions off different endpoints,
|
|
69
|
+
// so one failing must not blank the other. Both resolve the same 403, so this is only reached by a
|
|
70
|
+
// caller the backend has already admitted.
|
|
71
|
+
const toolServers = useToolServersStore()
|
|
72
|
+
onMounted(async () => {
|
|
73
|
+
try {
|
|
74
|
+
await toolServers.load()
|
|
75
|
+
} catch (e) {
|
|
76
|
+
present(e, 'settings.toolServers.toast.loadFailed')
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
66
80
|
async function saveKey(key: string) {
|
|
67
81
|
const value = (drafts[key] ?? '').trim()
|
|
68
82
|
if (!value) return
|
|
@@ -99,6 +113,11 @@ async function removeKey(key: string) {
|
|
|
99
113
|
|
|
100
114
|
<template>
|
|
101
115
|
<div class="space-y-4" data-testid="capability-credentials-panel">
|
|
116
|
+
<!-- The servers FIRST, because they are what the keys below authenticate: a bare list of
|
|
117
|
+
variable names does not tell an operator which of them matters, and the Test button is the
|
|
118
|
+
only thing on either surface that can say whether the value they typed works. -->
|
|
119
|
+
<ToolServerChecklist />
|
|
120
|
+
|
|
102
121
|
<p class="text-sm text-slate-400">
|
|
103
122
|
{{ t('settings.capabilityCredentials.intro') }}
|
|
104
123
|
</p>
|
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
// - "Package registries" — the private npm registries a checkout installs from (formerly an
|
|
16
16
|
// Integrations-hub row). What a container can resolve its dependencies from is part of the
|
|
17
17
|
// execution environment, not an optional external system a workspace links in.
|
|
18
|
-
// - "Capability credentials" — the
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// - "Capability credentials" — the deployment's tool servers (MCP) with a Test button each, above
|
|
19
|
+
// the sealed per-workspace values behind the secrets a registered tool server or generative
|
|
20
|
+
// binary integration declares. What an agent's tools authenticate as belongs beside where those
|
|
21
|
+
// agents run, and it is `secrets.manage`-only (the READ included, since both halves name the
|
|
22
|
+
// deployment's credential keys), so the tab is HIDDEN rather than disabled for anyone else.
|
|
22
23
|
// Local-specific affordances render inline, gated on `auth.localMode?.enabled`. A tab whose
|
|
23
24
|
// backend integration is disabled (503) simply doesn't render.
|
|
24
25
|
import { computed, ref, watch } from 'vue'
|
|
@@ -43,6 +44,7 @@ const store = useProviderConnectionsStore()
|
|
|
43
44
|
const auth = useAuthStore()
|
|
44
45
|
const packageRegistries = usePackageRegistriesStore()
|
|
45
46
|
const capabilityCredentials = useCapabilityCredentialsStore()
|
|
47
|
+
const toolServers = useToolServersStore()
|
|
46
48
|
const { canManageSecrets } = useWorkspaceAccess()
|
|
47
49
|
|
|
48
50
|
const open = computed({
|
|
@@ -93,7 +95,14 @@ const tabs = computed(() =>
|
|
|
93
95
|
// the backend gates the read too), and `hasSurface` hides a tab with nothing in it — the
|
|
94
96
|
// panel is a checklist projected from the deployment's registered capabilities, so a build
|
|
95
97
|
// that registers none has no credential to type.
|
|
96
|
-
|
|
98
|
+
//
|
|
99
|
+
// EITHER surface earns the tab. A tool server that declares no credential has nothing on the
|
|
100
|
+
// checklist, and gating the tab on the checklist alone would leave the one server an operator
|
|
101
|
+
// most wants to test unreachable — while a credential whose capability is a generative
|
|
102
|
+
// integration has no tool-server row. Two questions, one tab, and neither is a subset of the
|
|
103
|
+
// other.
|
|
104
|
+
capabilityCredentials:
|
|
105
|
+
canManageSecrets.value && (capabilityCredentials.hasSurface || toolServers.hasSurface),
|
|
97
106
|
}).map((value) => ({
|
|
98
107
|
value,
|
|
99
108
|
label: TAB_LABELS.value[value],
|
|
@@ -121,7 +130,10 @@ watch(
|
|
|
121
130
|
// window must still open), reported by the panel, which can only do that once its tab exists.
|
|
122
131
|
// Not probed at all without the permission — the backend would refuse it, and asking would
|
|
123
132
|
// put a 403 in every member's console on every open.
|
|
124
|
-
if (canManageSecrets.value)
|
|
133
|
+
if (canManageSecrets.value) {
|
|
134
|
+
void capabilityCredentials.ensureLoaded().catch(() => {})
|
|
135
|
+
void toolServers.ensureLoaded().catch(() => {})
|
|
136
|
+
}
|
|
125
137
|
activeTab.value = openInfrastructureTab(tabValues.value, ui.infrastructureTab)
|
|
126
138
|
},
|
|
127
139
|
{ immediate: true },
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Tool servers (MCP) — the deployment's registered servers, and a Test button that speaks the
|
|
3
|
+
// protocol to one.
|
|
4
|
+
//
|
|
5
|
+
// It sits ABOVE the credential checklist in the same tab, because it is what the credentials on that
|
|
6
|
+
// list are FOR: the checklist answers "which keys does this deployment want", and this answers "and
|
|
7
|
+
// does the server they authenticate actually work". Both are `secrets.manage`-only, which is why the
|
|
8
|
+
// tab is hidden rather than disabled for anyone else.
|
|
9
|
+
//
|
|
10
|
+
// A row states four independent reasons a declared server may never reach a run — no kind declares
|
|
11
|
+
// it, no harness can serve its transport, its credentials do not resolve, the endpoint is dead — and
|
|
12
|
+
// each is rendered rather than implied. Before this panel the first two lived in a boot log and the
|
|
13
|
+
// deployment's own source, and the last two were only visible by starting a run and reading the
|
|
14
|
+
// agent's prompt.
|
|
15
|
+
import { computed, ref } from 'vue'
|
|
16
|
+
import type {
|
|
17
|
+
ToolServerNotProbeableReason,
|
|
18
|
+
ToolServerProbeStatus,
|
|
19
|
+
ToolServerTransport,
|
|
20
|
+
ToolServerView,
|
|
21
|
+
} from '~/types/toolServers'
|
|
22
|
+
|
|
23
|
+
const { t } = useI18n()
|
|
24
|
+
const store = useToolServersStore()
|
|
25
|
+
const { present } = usePipelineErrorToast()
|
|
26
|
+
|
|
27
|
+
// Which failure DETAILS are expanded, by server id. Collapsed by default: the translated status line
|
|
28
|
+
// is what an operator acts on, and the raw backend prose is a disclosure behind it (never the
|
|
29
|
+
// primary description) per the i18n rule.
|
|
30
|
+
const expanded = ref<Record<string, boolean>>({})
|
|
31
|
+
|
|
32
|
+
// Exhaustive Records over the wire vocabularies, so a member added in `@cat-factory/contracts` fails
|
|
33
|
+
// to compile here until it has translated copy. The sanctioned guard for an enum-keyed lookup the
|
|
34
|
+
// typed-message-key check cannot see.
|
|
35
|
+
const TRANSPORT_LABELS = computed<Record<ToolServerTransport, string>>(() => ({
|
|
36
|
+
stdio: t('settings.toolServers.transport.stdio'),
|
|
37
|
+
http: t('settings.toolServers.transport.http'),
|
|
38
|
+
}))
|
|
39
|
+
const STATUS_LABELS = computed<Record<ToolServerProbeStatus, string>>(() => ({
|
|
40
|
+
ok: t('settings.toolServers.status.ok'),
|
|
41
|
+
credentials_missing: t('settings.toolServers.status.credentialsMissing'),
|
|
42
|
+
credential_refused: t('settings.toolServers.status.credentialRefused'),
|
|
43
|
+
unreachable: t('settings.toolServers.status.unreachable'),
|
|
44
|
+
http_error: t('settings.toolServers.status.httpError'),
|
|
45
|
+
protocol_error: t('settings.toolServers.status.protocolError'),
|
|
46
|
+
not_probeable: t('settings.toolServers.status.notProbeable'),
|
|
47
|
+
}))
|
|
48
|
+
const NOT_PROBEABLE_LABELS = computed<Record<ToolServerNotProbeableReason, string>>(() => ({
|
|
49
|
+
stdio_transport: t('settings.toolServers.notProbeable.stdio'),
|
|
50
|
+
container_local_url: t('settings.toolServers.notProbeable.containerLocal'),
|
|
51
|
+
url_not_allowed: t('settings.toolServers.notProbeable.urlNotAllowed'),
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
const servers = computed<ToolServerView[]>(() => store.view?.servers ?? [])
|
|
55
|
+
|
|
56
|
+
function resultFor(id: string) {
|
|
57
|
+
return store.results[id]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Success is the only green; every other verdict names something for the operator to fix. */
|
|
61
|
+
function statusColor(status: ToolServerProbeStatus): 'success' | 'warning' | 'error' {
|
|
62
|
+
if (status === 'ok') return 'success'
|
|
63
|
+
return status === 'not_probeable' ? 'warning' : 'error'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function runProbe(id: string) {
|
|
67
|
+
try {
|
|
68
|
+
await store.probe(id)
|
|
69
|
+
} catch (e) {
|
|
70
|
+
// A THROWN failure is not a verdict — a 404 for a server the deployment has since dropped, or a
|
|
71
|
+
// transient 5xx. Presented through the shared status-class funnel rather than stored as a
|
|
72
|
+
// result, so the row does not claim the probe answered.
|
|
73
|
+
present(e, 'settings.toolServers.toast.probeFailed')
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<template>
|
|
79
|
+
<section v-if="servers.length" class="space-y-3" data-testid="tool-servers-section">
|
|
80
|
+
<div>
|
|
81
|
+
<h3 class="text-sm font-semibold text-slate-200">
|
|
82
|
+
{{ t('settings.toolServers.heading') }}
|
|
83
|
+
</h3>
|
|
84
|
+
<p class="text-xs text-slate-400">{{ t('settings.toolServers.intro') }}</p>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<article
|
|
88
|
+
v-for="server in servers"
|
|
89
|
+
:key="server.id"
|
|
90
|
+
class="space-y-2 rounded-lg border border-slate-700 p-3"
|
|
91
|
+
:data-testid="`tool-server-${server.id}`"
|
|
92
|
+
>
|
|
93
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
94
|
+
<span class="text-sm font-medium text-slate-200">{{ server.label }}</span>
|
|
95
|
+
<UBadge color="neutral" variant="soft" size="sm">
|
|
96
|
+
{{ TRANSPORT_LABELS[server.transport] }}
|
|
97
|
+
</UBadge>
|
|
98
|
+
<code class="font-mono text-[11px] text-slate-500">{{ server.id }}</code>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<p class="truncate font-mono text-[11px] text-slate-500" :title="server.target">
|
|
102
|
+
{{ server.target }}
|
|
103
|
+
</p>
|
|
104
|
+
<p v-if="server.guidance" class="text-xs text-slate-400">{{ server.guidance }}</p>
|
|
105
|
+
|
|
106
|
+
<!-- Which agents get it. An EMPTY list is a registration attached to nothing: it never reaches
|
|
107
|
+
a dispatch, so the credentials it asks for are keys an operator fills in for no run. Said
|
|
108
|
+
out loud, because no other surface in the platform can see that state. -->
|
|
109
|
+
<p v-if="server.declaredBy.length" class="text-[11px] text-slate-400">
|
|
110
|
+
{{ t('settings.toolServers.declaredBy', { kinds: server.declaredBy.join(', ') }) }}
|
|
111
|
+
</p>
|
|
112
|
+
<p v-else class="text-[11px] text-amber-400" :data-testid="`tool-server-orphan-${server.id}`">
|
|
113
|
+
{{ t('settings.toolServers.declaredByNone') }}
|
|
114
|
+
</p>
|
|
115
|
+
|
|
116
|
+
<!-- Which harnesses could serve it. EMPTY means the declaration can never run anywhere (an
|
|
117
|
+
`http` server narrowed to Codex, whose MCP client is stdio-only): it is never dropped FOR
|
|
118
|
+
A REASON on any run, so no prompt and no log line ever mentions it. -->
|
|
119
|
+
<p v-if="server.servableHarnesses.length" class="text-[11px] text-slate-400">
|
|
120
|
+
{{
|
|
121
|
+
t('settings.toolServers.servableHarnesses', {
|
|
122
|
+
harnesses: server.servableHarnesses.join(', '),
|
|
123
|
+
})
|
|
124
|
+
}}
|
|
125
|
+
</p>
|
|
126
|
+
<p v-else class="text-[11px] text-amber-400">
|
|
127
|
+
{{ t('settings.toolServers.servableHarnessesNone') }}
|
|
128
|
+
</p>
|
|
129
|
+
|
|
130
|
+
<p v-if="server.allowedTools?.length" class="text-[11px] text-slate-400">
|
|
131
|
+
{{ t('settings.toolServers.allowedTools', { tools: server.allowedTools.join(', ') }) }}
|
|
132
|
+
</p>
|
|
133
|
+
<p v-if="server.credentials.length" class="text-[11px] text-slate-400">
|
|
134
|
+
{{
|
|
135
|
+
t('settings.toolServers.credentials', {
|
|
136
|
+
keys: server.credentials.map((c) => c.key).join(', '),
|
|
137
|
+
})
|
|
138
|
+
}}
|
|
139
|
+
</p>
|
|
140
|
+
|
|
141
|
+
<div class="flex flex-wrap items-center gap-2 pt-1">
|
|
142
|
+
<UButton
|
|
143
|
+
v-if="server.probeable"
|
|
144
|
+
size="xs"
|
|
145
|
+
variant="subtle"
|
|
146
|
+
icon="i-lucide-plug"
|
|
147
|
+
:loading="store.probing === server.id"
|
|
148
|
+
:disabled="store.probing !== null"
|
|
149
|
+
:data-testid="`tool-server-test-${server.id}`"
|
|
150
|
+
@click="runProbe(server.id)"
|
|
151
|
+
>
|
|
152
|
+
{{ t('settings.toolServers.test') }}
|
|
153
|
+
</UButton>
|
|
154
|
+
<!-- Not a disabled button: nothing the operator can do here makes it clickable, so the row
|
|
155
|
+
states the reason instead. Each reason needs a different response — nothing to fix,
|
|
156
|
+
verify it from a run, or change the declaration. -->
|
|
157
|
+
<p
|
|
158
|
+
v-else-if="server.notProbeableReason"
|
|
159
|
+
class="text-[11px] text-slate-500"
|
|
160
|
+
:data-testid="`tool-server-unprobeable-${server.id}`"
|
|
161
|
+
>
|
|
162
|
+
{{ NOT_PROBEABLE_LABELS[server.notProbeableReason] }}
|
|
163
|
+
</p>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<div
|
|
167
|
+
v-if="resultFor(server.id)"
|
|
168
|
+
class="space-y-1 rounded-md border border-slate-800 bg-slate-900/40 p-2"
|
|
169
|
+
:data-testid="`tool-server-result-${server.id}`"
|
|
170
|
+
>
|
|
171
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
172
|
+
<UBadge
|
|
173
|
+
:color="statusColor(resultFor(server.id)!.status)"
|
|
174
|
+
variant="soft"
|
|
175
|
+
size="sm"
|
|
176
|
+
:data-testid="`tool-server-status-${server.id}`"
|
|
177
|
+
>
|
|
178
|
+
{{ STATUS_LABELS[resultFor(server.id)!.status] }}
|
|
179
|
+
</UBadge>
|
|
180
|
+
<span v-if="resultFor(server.id)!.httpStatus" class="text-[11px] text-slate-400">
|
|
181
|
+
{{ t('settings.toolServers.httpStatus', { status: resultFor(server.id)!.httpStatus }) }}
|
|
182
|
+
</span>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<p v-if="resultFor(server.id)!.status === 'ok'" class="text-[11px] text-slate-300">
|
|
186
|
+
{{
|
|
187
|
+
t('settings.toolServers.okDetail', {
|
|
188
|
+
name: resultFor(server.id)!.serverName || server.id,
|
|
189
|
+
version: resultFor(server.id)!.serverVersion || '?',
|
|
190
|
+
protocol: resultFor(server.id)!.protocolVersion ?? '?',
|
|
191
|
+
count: resultFor(server.id)!.toolCount ?? 0,
|
|
192
|
+
})
|
|
193
|
+
}}
|
|
194
|
+
</p>
|
|
195
|
+
<!-- A count off a truncated read is a FLOOR, not a total, and the difference decides whether
|
|
196
|
+
the allowedTools verdict below means anything. -->
|
|
197
|
+
<p v-if="resultFor(server.id)!.toolsComplete === false" class="text-[11px] text-slate-500">
|
|
198
|
+
{{ t('settings.toolServers.toolsIncomplete') }}
|
|
199
|
+
</p>
|
|
200
|
+
|
|
201
|
+
<!-- The reconciliation nothing else in the platform can do: a well-formed tool name that
|
|
202
|
+
matches nothing narrows the CLI's allow-list to a dead pattern while the prompt keeps
|
|
203
|
+
advertising the tool. Withheld entirely when the tool list was a prefix. -->
|
|
204
|
+
<p
|
|
205
|
+
v-if="resultFor(server.id)!.allowedTools?.unmatched?.length"
|
|
206
|
+
class="text-[11px] text-amber-400"
|
|
207
|
+
:data-testid="`tool-server-unmatched-${server.id}`"
|
|
208
|
+
>
|
|
209
|
+
{{
|
|
210
|
+
t('settings.toolServers.unmatchedTools', {
|
|
211
|
+
tools: resultFor(server.id)!.allowedTools!.unmatched.join(', '),
|
|
212
|
+
})
|
|
213
|
+
}}
|
|
214
|
+
</p>
|
|
215
|
+
<p
|
|
216
|
+
v-else-if="resultFor(server.id)!.allowedTools?.checked === false"
|
|
217
|
+
class="text-[11px] text-slate-500"
|
|
218
|
+
>
|
|
219
|
+
{{ t('settings.toolServers.allowedToolsUnchecked') }}
|
|
220
|
+
</p>
|
|
221
|
+
|
|
222
|
+
<p
|
|
223
|
+
v-if="resultFor(server.id)!.unresolvedCredentials?.length"
|
|
224
|
+
class="text-[11px] text-amber-400"
|
|
225
|
+
>
|
|
226
|
+
{{
|
|
227
|
+
t('settings.toolServers.unresolvedCredentials', {
|
|
228
|
+
keys: resultFor(server.id)!.unresolvedCredentials!.join(', '),
|
|
229
|
+
})
|
|
230
|
+
}}
|
|
231
|
+
</p>
|
|
232
|
+
<p v-if="resultFor(server.id)!.refusedCredentials?.length" class="text-[11px] text-red-400">
|
|
233
|
+
{{
|
|
234
|
+
t('settings.toolServers.refusedCredentials', {
|
|
235
|
+
keys: resultFor(server.id)!.refusedCredentials!.join(', '),
|
|
236
|
+
})
|
|
237
|
+
}}
|
|
238
|
+
</p>
|
|
239
|
+
|
|
240
|
+
<!-- Raw backend prose is DETAIL behind a disclosure, never the primary description. It is
|
|
241
|
+
already scrubbed through `redactSecrets` at the emit site. -->
|
|
242
|
+
<template v-if="resultFor(server.id)!.error">
|
|
243
|
+
<UButton
|
|
244
|
+
size="xs"
|
|
245
|
+
variant="link"
|
|
246
|
+
class="px-0"
|
|
247
|
+
:data-testid="`tool-server-details-${server.id}`"
|
|
248
|
+
@click="expanded[server.id] = !expanded[server.id]"
|
|
249
|
+
>
|
|
250
|
+
{{
|
|
251
|
+
expanded[server.id]
|
|
252
|
+
? t('settings.toolServers.hideDetails')
|
|
253
|
+
: t('settings.toolServers.showDetails')
|
|
254
|
+
}}
|
|
255
|
+
</UButton>
|
|
256
|
+
<pre
|
|
257
|
+
v-if="expanded[server.id]"
|
|
258
|
+
class="overflow-x-auto rounded bg-slate-950 p-2 font-mono text-[10px] text-slate-400"
|
|
259
|
+
>{{ resultFor(server.id)!.error }}</pre>
|
|
260
|
+
</template>
|
|
261
|
+
</div>
|
|
262
|
+
</article>
|
|
263
|
+
</section>
|
|
264
|
+
</template>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { listToolServersContract, probeToolServerContract } from '@cat-factory/contracts'
|
|
2
|
+
import type { ApiContext } from './context'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-workspace tool-server (MCP) operability: the inventory of what this deployment declared, and
|
|
6
|
+
* a probe that speaks the protocol to one of them.
|
|
7
|
+
*
|
|
8
|
+
* `secrets.manage`-gated end to end, the READ included — the inventory names the credential keys the
|
|
9
|
+
* deployment's capabilities want and the endpoints those credentials are sent to. The probe is a
|
|
10
|
+
* POST because it SPENDS an outbound request under the deployment's own credential, so it must not
|
|
11
|
+
* be safe to retry from a cache or a prefetch. See ToolServerController.
|
|
12
|
+
*/
|
|
13
|
+
export function toolServersApi({ send, ws }: ApiContext) {
|
|
14
|
+
return {
|
|
15
|
+
listToolServers: (workspaceId: string) =>
|
|
16
|
+
send(listToolServersContract, { pathPrefix: ws(workspaceId) }),
|
|
17
|
+
|
|
18
|
+
probeToolServer: (workspaceId: string, id: string) =>
|
|
19
|
+
send(probeToolServerContract, { pathPrefix: ws(workspaceId), pathParams: { id } }),
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -33,6 +33,7 @@ import { modelsApi } from './api/models'
|
|
|
33
33
|
import { notificationsApi } from './api/notifications'
|
|
34
34
|
import { packageRegistriesApi } from './api/packageRegistries'
|
|
35
35
|
import { capabilityCredentialsApi } from './api/capabilityCredentials'
|
|
36
|
+
import { toolServersApi } from './api/toolServers'
|
|
36
37
|
import { preflightsApi } from './api/preflights'
|
|
37
38
|
import { presetsApi } from './api/presets'
|
|
38
39
|
import { publicApiKeysApi } from './api/publicApiKeys'
|
|
@@ -156,6 +157,7 @@ export function useApi() {
|
|
|
156
157
|
...testSecretsApi(ctx),
|
|
157
158
|
...packageRegistriesApi(ctx),
|
|
158
159
|
...capabilityCredentialsApi(ctx),
|
|
160
|
+
...toolServersApi(ctx),
|
|
159
161
|
...previewApi(ctx),
|
|
160
162
|
...environmentsApi(ctx),
|
|
161
163
|
...recurringApi(ctx),
|
|
@@ -97,6 +97,11 @@ describe('inspector panel group', () => {
|
|
|
97
97
|
'task-dependencies',
|
|
98
98
|
'task-run-settings',
|
|
99
99
|
'task-agent-config',
|
|
100
|
+
// The custom type's own declared fields sit with the other task INPUTS (what the task is),
|
|
101
|
+
// not under run settings (how it runs). It is gated on being a task alone: the panel hides
|
|
102
|
+
// itself unless the type is one this deployment registered with descriptor fields, which
|
|
103
|
+
// the spec here cannot see and should not try to.
|
|
104
|
+
'task-type-fields',
|
|
100
105
|
'task-structure',
|
|
101
106
|
])
|
|
102
107
|
})
|
|
@@ -45,6 +45,7 @@ export const INSPECTOR_PANEL_IDS = [
|
|
|
45
45
|
'task-dependencies',
|
|
46
46
|
'task-run-settings',
|
|
47
47
|
'task-agent-config',
|
|
48
|
+
'task-type-fields',
|
|
48
49
|
'task-structure',
|
|
49
50
|
// service / module body
|
|
50
51
|
'container-summary',
|
|
@@ -134,6 +135,11 @@ export const INSPECTOR_PANEL_SPECS: readonly InspectorPanelSpec[] = [
|
|
|
134
135
|
{ id: 'task-dependencies', order: 60, when: isTask },
|
|
135
136
|
{ id: 'task-run-settings', order: 70, when: isTask },
|
|
136
137
|
{ id: 'task-agent-config', order: 80, when: isTask },
|
|
138
|
+
// The answers to a CUSTOM task type's declared fields. Gated on being a task alone; the panel
|
|
139
|
+
// itself hides unless the task's type is one this deployment registered WITH descriptor fields,
|
|
140
|
+
// which is the only case there is anything to edit. It sits beside the other task inputs rather
|
|
141
|
+
// than under Run settings: these are what the task IS, not how it runs.
|
|
142
|
+
{ id: 'task-type-fields', order: 85, when: isTask },
|
|
137
143
|
{ id: 'task-structure', order: 90, when: isTask },
|
|
138
144
|
{ id: 'container-summary', order: 110, when: isContainer },
|
|
139
145
|
{ id: 'frontend-config', order: 120, when: (b) => isFrame(b) && b.type === 'frontend' },
|
|
@@ -21,6 +21,7 @@ import TaskEstimateBadge from '~/components/panels/inspector/TaskEstimateBadge.v
|
|
|
21
21
|
import TaskDependencies from '~/components/panels/inspector/TaskDependencies.vue'
|
|
22
22
|
import TaskRunSettings from '~/components/panels/inspector/TaskRunSettings.vue'
|
|
23
23
|
import TaskAgentConfig from '~/components/panels/inspector/TaskAgentConfig.vue'
|
|
24
|
+
import TaskTypeFields from '~/components/panels/inspector/TaskTypeFields.vue'
|
|
24
25
|
import TaskStructure from '~/components/panels/inspector/TaskStructure.vue'
|
|
25
26
|
import ContainerSummary from '~/components/panels/inspector/ContainerSummary.vue'
|
|
26
27
|
import FrontendConfig from '~/components/panels/inspector/FrontendConfig.vue'
|
|
@@ -75,6 +76,7 @@ const COMPONENTS: Record<InspectorPanelId, Component> = {
|
|
|
75
76
|
'task-dependencies': TaskDependencies,
|
|
76
77
|
'task-run-settings': TaskRunSettings,
|
|
77
78
|
'task-agent-config': TaskAgentConfig,
|
|
79
|
+
'task-type-fields': TaskTypeFields,
|
|
78
80
|
'task-structure': TaskStructure,
|
|
79
81
|
'container-summary': ContainerSummary,
|
|
80
82
|
'frontend-config': FrontendConfig,
|