@cat-factory/app 0.242.2 → 0.244.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.
@@ -175,8 +175,11 @@ const intakeDispatch = computed<'queue' | 'per-ticket'>(() =>
175
175
  isBugIntake.value ? 'queue' : 'per-ticket',
176
176
  )
177
177
 
178
- // Sources that can back intake right now (connected / App-installed AND enabled).
179
- const intakeSources = computed(() => tasks.offeredSources)
178
+ // Sources that can back intake right now: connected / App-installed AND enabled, AND able to run
179
+ // the predicate search intake fires. The last is not a refinement of the first two — a source
180
+ // without it saves a schedule that can never produce a ticket — so it is asked of the server
181
+ // (`supportsIntake`, derived from the registered provider) rather than inferred from the id here.
182
+ const intakeSources = computed(() => tasks.offeredSources.filter((s) => s.supportsIntake))
180
183
 
181
184
  watch(open, (isOpen) => {
182
185
  if (!isOpen) return
@@ -490,8 +493,14 @@ async function add() {
490
493
  <p class="text-[11px] text-slate-500">
491
494
  {{ t('board.recurring.intakeHint') }}
492
495
  </p>
496
+ <!-- Two different remedies: connect something, versus connect something ELSE. A source
497
+ that is connected but cannot run a scheduled search is not an absent connection. -->
493
498
  <p v-if="intakeSources.length === 0" class="text-[11px] text-amber-500">
494
- {{ t('board.recurring.intakeNoSources') }}
499
+ {{
500
+ tasks.anyOffered
501
+ ? t('board.recurring.intakeNoIntakeSources')
502
+ : t('board.recurring.intakeNoSources')
503
+ }}
495
504
  </p>
496
505
  <div v-else class="flex flex-wrap gap-1">
497
506
  <UButton
@@ -1,12 +1,18 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import {
3
+ KNOWN_OBSERVED_STATUSES,
3
4
  KNOWN_REASONS,
5
+ OBSERVED_STATUS_KEY,
4
6
  REASON_KEY,
5
7
  REMEDY_KEY,
8
+ observationFor,
9
+ observationIsFault,
10
+ observationText,
6
11
  reasonText,
7
12
  remedyText,
13
+ unattributedObservations,
8
14
  } from './StepToolServers.logic'
9
- import type { ToolServerUnavailableReason } from '~/types/toolServers'
15
+ import type { ObservedToolServer, ToolServerUnavailableReason } from '~/types/toolServers'
10
16
 
11
17
  /**
12
18
  * A dropped tool server is the whole point of this surface: until it existed, a run that quietly
@@ -98,3 +104,104 @@ function render(reason: string): { key: string; params?: Record<string, unknown>
98
104
  })
99
105
  return seen
100
106
  }
107
+
108
+ /**
109
+ * The OBSERVED half: what the agent's CLI said about the servers the platform wired. Its whole
110
+ * value is a set of distinctions, and each one collapses into "looks fine" if it is lost — so
111
+ * these pin the distinctions rather than the copy.
112
+ */
113
+ describe('tool-server startup observations', () => {
114
+ it('gives every observed status but `ready` its own copy', () => {
115
+ // `ready` is deliberately absent: a started server's line depends on its tool count, which is
116
+ // three sentences rather than one key. Everything else is exhaustive against the schema, so a
117
+ // member added on the backend fails here instead of rendering blank.
118
+ expect([...Object.keys(OBSERVED_STATUS_KEY), 'ready'].sort()).toEqual(
119
+ [...KNOWN_OBSERVED_STATUSES].sort(),
120
+ )
121
+ const keys = Object.values(OBSERVED_STATUS_KEY)
122
+ expect(new Set(keys).size).toBe(keys.length)
123
+ })
124
+
125
+ it('says NOTHING when no observation was made', () => {
126
+ // The distinction the whole field rests on. A codex run, an image one version behind and an
127
+ // unmapped runner pool all report nothing, and any placeholder here would read as a verdict
128
+ // about a server that was very likely fine.
129
+ expect(observationFor(undefined, 'slack')).toBeNull()
130
+ expect(observationText(null, (key) => key)).toBeNull()
131
+ expect(observationIsFault(null)).toBe(false)
132
+ })
133
+
134
+ it('distinguishes “nobody looked” from “the CLI never loaded it”', () => {
135
+ // Both render as an absent server on the chip if they are conflated, and only the second is
136
+ // evidence of anything. The first must never raise a fault.
137
+ const notLoaded = observationFor([{ id: 'jira', status: 'ready' }], 'slack')
138
+ expect(notLoaded).toEqual({ kind: 'not_loaded' })
139
+ expect(observationText(notLoaded, (key) => key)).toBe(
140
+ 'panels.stepDetail.toolServers.observed.notLoaded',
141
+ )
142
+ expect(observationIsFault(notLoaded)).toBe(true)
143
+ })
144
+
145
+ it('separates “started with no tools” from “started, tools uncounted”', () => {
146
+ // A server that connected and exposes nothing reaches the agent exactly like one that was
147
+ // never wired, and every other signal about it says healthy — so it gets its own sentence
148
+ // rather than a "0 tools" that reads as a rendering artefact.
149
+ const none = observationFor([{ id: 'slack', status: 'ready', toolCount: 0 }], 'slack')
150
+ const uncounted = observationFor([{ id: 'slack', status: 'ready' }], 'slack')
151
+ const some = observationFor([{ id: 'slack', status: 'ready', toolCount: 3 }], 'slack')
152
+ expect(observationText(none, (key) => key)).toBe(
153
+ 'panels.stepDetail.toolServers.observed.readyNoTools',
154
+ )
155
+ expect(observationText(uncounted, (key) => key)).toBe(
156
+ 'panels.stepDetail.toolServers.observed.ready',
157
+ )
158
+ expect(observationText(some, (key) => key)).toBe(
159
+ 'panels.stepDetail.toolServers.observed.readyTools',
160
+ )
161
+ // None of the three is a fault: the count is a diagnosis for a person to read, not a verdict
162
+ // the surface should paint red.
163
+ for (const observation of [none, uncounted, some]) {
164
+ expect(observationIsFault(observation)).toBe(false)
165
+ }
166
+ })
167
+
168
+ it('flags the two states the platform promised a tool it did not get', () => {
169
+ for (const status of ['failed', 'needs_auth'] as const) {
170
+ expect(observationIsFault(observationFor([{ id: 'slack', status }], 'slack'))).toBe(true)
171
+ }
172
+ })
173
+
174
+ it('never flags a state the report did not resolve', () => {
175
+ // `unknown` is a fact about THIS build (a word it could not map) or about the moment the
176
+ // report was taken (a server still handshaking), never about the server itself. Painting it
177
+ // as a fault would send an operator to debug a working integration every time a CLI adds a
178
+ // status word or starts a server a moment slower than the session announcement.
179
+ // Cast, because the point is a value the TYPE excludes and the DATA carries: the vocabulary
180
+ // is persisted on a run, so a status recorded by a newer harness (or retired since) reads back
181
+ // here as a member this build has no case for.
182
+ const persisted = observationFor(
183
+ [{ id: 'slack', status: 'reticulating' } as unknown as ObservedToolServer],
184
+ 'slack',
185
+ )
186
+ expect(persisted).toEqual({ kind: 'loaded', status: 'unknown' })
187
+ expect(observationIsFault(persisted)).toBe(false)
188
+ })
189
+
190
+ it('states a report naming servers the dispatch did not wire', () => {
191
+ // Empty on every ordinary run (`--strict-mcp-config`). The only way to reach it is a producer
192
+ // describing some OTHER job, and silently filtering those rows would present that report as
193
+ // this run's clean bill of health.
194
+ const record = {
195
+ agentKind: 'coder',
196
+ wired: [{ id: 'slack', label: 'Slack', transport: 'http' as const }],
197
+ unavailable: [],
198
+ observed: [
199
+ { id: 'slack', status: 'ready' as const },
200
+ { id: 'stranger', status: 'ready' as const },
201
+ ],
202
+ }
203
+ expect(unattributedObservations(record).map((s) => s.id)).toEqual(['stranger'])
204
+ // …and nothing to state when there was no report at all.
205
+ expect(unattributedObservations({ ...record, observed: undefined })).toEqual([])
206
+ })
207
+ })
@@ -1,5 +1,13 @@
1
- import { toolServerUnavailableReasonSchema } from '@cat-factory/contracts'
2
- import type { ToolServerUnavailableReason } from '~/types/toolServers'
1
+ import {
2
+ toolServerObservedStatusSchema,
3
+ toolServerUnavailableReasonSchema,
4
+ } from '@cat-factory/contracts'
5
+ import type {
6
+ ObservedToolServer,
7
+ StepToolServers,
8
+ ToolServerObservedStatus,
9
+ ToolServerUnavailableReason,
10
+ } from '~/types/toolServers'
3
11
 
4
12
  // The step surface's tool-server (MCP) reason mapping. Kept out of the SFC on the same seam as
5
13
  // `StepTestReport.logic.ts`, so the vocabulary rule can be asserted without mounting a component.
@@ -102,3 +110,143 @@ export function remedyText(
102
110
  ): string | null {
103
111
  return isKnownReason(reason) ? t(REMEDY_KEY[reason]) : null
104
112
  }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // The OBSERVED half: what the agent's CLI reported about the servers it loaded.
116
+ //
117
+ // The mapping above answers "why did the platform withhold this tool". This one answers the
118
+ // question that mapping structurally cannot: a server that passed every check, was promised to the
119
+ // agent in its prompt, and then did not come up. The two are rendered together on one chip because
120
+ // they are two halves of one answer about one server — but they are never merged into one status,
121
+ // because a withheld server and a failed one need different people to fix different things.
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * What this build knows about one WIRED server after joining the CLI's report onto it.
126
+ *
127
+ * `null` and `not_loaded` are the pair this whole type exists to keep apart, and conflating them is
128
+ * the mistake that would make the surface lie. `null` means NO OBSERVATION WAS MADE — the run's
129
+ * harness publishes no report (codex's CLI does not, nor does any image older than the one that
130
+ * introduced this, nor an unmapped runner pool) — so nothing at all is known about whether the
131
+ * server started. `not_loaded` means an observation WAS made and this server was not in it, which
132
+ * is positive evidence that the CLI never loaded it. Rendering the first as the second would
133
+ * accuse every wired server on every deployment one image behind.
134
+ */
135
+ export type ToolServerObservation =
136
+ | null
137
+ | { kind: 'loaded'; status: ToolServerObservedStatus; toolCount?: number }
138
+ | { kind: 'not_loaded' }
139
+
140
+ /**
141
+ * Join the CLI's report onto one wired server id.
142
+ *
143
+ * Takes the whole `observed` field rather than a prepared map so the absent-vs-empty decision is
144
+ * made HERE, in the one place that owns the distinction, instead of by each caller building the
145
+ * map. An absent field yields `null` for every server; a present one yields either the CLI's line
146
+ * or `not_loaded`.
147
+ */
148
+ export function observationFor(
149
+ observed: readonly ObservedToolServer[] | undefined,
150
+ id: string,
151
+ ): ToolServerObservation {
152
+ if (!observed) return null
153
+ const found = observed.find((server) => server.id === id)
154
+ if (!found) return { kind: 'not_loaded' }
155
+ return {
156
+ kind: 'loaded',
157
+ status: isKnownObservedStatus(found.status) ? found.status : 'unknown',
158
+ // Carried only when the CLI counted, and `0` is carried: a server that connected and exposed
159
+ // nothing reaches the agent exactly like one that was never wired, so it is the most
160
+ // diagnostic count on the field and the one a truthiness guard would erase.
161
+ ...(typeof found.toolCount === 'number' ? { toolCount: found.toolCount } : {}),
162
+ }
163
+ }
164
+
165
+ /**
166
+ * The servers the CLI named that this step's record does not list as wired.
167
+ *
168
+ * Empty by construction on every ordinary run: the harness starts the CLI under
169
+ * `--strict-mcp-config`, so the only servers it can load are the ones this dispatch wrote for it.
170
+ * Surfaced anyway rather than filtered out, because the one way to reach it is a producer whose
171
+ * report does not describe this job — a runner-pool manifest pointing `toolServersPath` at the
172
+ * wrong field, say — and a surface that silently dropped those rows would present a report about
173
+ * some other job as a clean bill of health for this one.
174
+ */
175
+ export function unattributedObservations(record: StepToolServers): ObservedToolServer[] {
176
+ if (!record.observed) return []
177
+ const wired = new Set(record.wired.map((server) => server.id))
178
+ return record.observed.filter((server) => !wired.has(server.id))
179
+ }
180
+
181
+ /**
182
+ * The i18n key per observed status, for a server the CLI DID name. An exhaustive `Record` for the
183
+ * same reason {@link REASON_KEY} is one: a member added to the wire vocabulary must fail to
184
+ * compile here rather than render as a blank line.
185
+ *
186
+ * `ready` is deliberately absent — a started server's line depends on its tool COUNT, which is
187
+ * three separate sentences (counted some, counted none, did not count), so it is resolved by
188
+ * {@link observationText} rather than by a single key.
189
+ */
190
+ export const OBSERVED_STATUS_KEY: Record<Exclude<ToolServerObservedStatus, 'ready'>, string> = {
191
+ failed: 'panels.stepDetail.toolServers.observed.failed',
192
+ needs_auth: 'panels.stepDetail.toolServers.observed.needsAuth',
193
+ unknown: 'panels.stepDetail.toolServers.observed.unknown',
194
+ }
195
+
196
+ /** The observed-status vocabulary as the SCHEMA states it: what a parity assertion grades against. */
197
+ export const KNOWN_OBSERVED_STATUSES = toolServerObservedStatusSchema.options
198
+
199
+ /**
200
+ * Whether a persisted status is a member THIS build knows, derived from the picklist's own options.
201
+ * A predicate rather than a truthiness check on the lookup, for the reason {@link isKnownReason}
202
+ * gives: an `Object.prototype` member name reads back as a truthy non-key.
203
+ */
204
+ export function isKnownObservedStatus(status: string): status is ToolServerObservedStatus {
205
+ return (KNOWN_OBSERVED_STATUSES as readonly string[]).includes(status)
206
+ }
207
+
208
+ /**
209
+ * Render one observation, or `null` when there is nothing to say.
210
+ *
211
+ * `null` is returned for `null` — no observation was made — and it is the whole reason this
212
+ * function exists rather than a template expression: the surface must be BYTE-FOR-BYTE what it was
213
+ * before this field existed on every run that carries no report, which is every codex run, every
214
+ * run on an image one version behind, and every run on an unmapped runner pool. Silence is the
215
+ * only honest rendering of "nobody looked".
216
+ */
217
+ export function observationText(
218
+ observation: ToolServerObservation,
219
+ t: (key: string, params?: Record<string, unknown>) => string,
220
+ ): string | null {
221
+ if (observation === null) return null
222
+ if (observation.kind === 'not_loaded') {
223
+ return t('panels.stepDetail.toolServers.observed.notLoaded')
224
+ }
225
+ if (observation.status !== 'ready') return t(OBSERVED_STATUS_KEY[observation.status])
226
+ if (observation.toolCount === undefined) {
227
+ return t('panels.stepDetail.toolServers.observed.ready')
228
+ }
229
+ // Zero gets its own sentence rather than "0 tools": a server that started and offers nothing is
230
+ // a distinct fault (a narrowed `allowedTools` matching nothing, a vendor that authenticated and
231
+ // served an empty catalog), and it is the one an operator would otherwise never suspect,
232
+ // because every other signal about it says healthy.
233
+ return observation.toolCount === 0
234
+ ? t('panels.stepDetail.toolServers.observed.readyNoTools')
235
+ : t('panels.stepDetail.toolServers.observed.readyTools', { count: observation.toolCount })
236
+ }
237
+
238
+ /**
239
+ * Whether an observation should DRAW ATTENTION on the chip: the server was promised to the agent
240
+ * and the CLI could not deliver it.
241
+ *
242
+ * `unknown` is deliberately NOT alarming. It covers a word this build could not map (a fact about
243
+ * this build rather than about the server) and a server the CLI reported as still handshaking when
244
+ * it announced the session (a fact about the moment the report was taken). Neither says anything
245
+ * happened to the server, and painting either as a fault would send an operator to debug a working
246
+ * integration every time a CLI adds a status or starts a server a moment slower.
247
+ */
248
+ export function observationIsFault(observation: ToolServerObservation): boolean {
249
+ if (observation === null) return false
250
+ if (observation.kind === 'not_loaded') return true
251
+ return observation.status === 'failed' || observation.status === 'needs_auth'
252
+ }
@@ -8,6 +8,13 @@
8
8
  // unavailable server is a chip of its own with its own translated reason, never a shorter list of
9
9
  // the wired ones: "absent" and "zero" must not render the same.
10
10
  //
11
+ // The wired half carries a SECOND answer when the run's harness reported one: what the agent's
12
+ // CLI said about each server when it started up. The two are deliberately not merged into one
13
+ // status — the platform withholding a tool and the CLI failing to start one are different faults
14
+ // for different people — and the absence of a CLI report renders as nothing at all, because
15
+ // "nobody looked" and "the CLI loaded nothing" are opposite facts and only silence states the
16
+ // first one honestly (a codex run, an older image and an unmapped runner pool all reach it).
17
+ //
11
18
  // SELF-HIDING when the record holds nothing, like the sibling panels around it. The record is
12
19
  // written on EVERY container dispatch, so both lists empty is the state of every step on every
13
20
  // deployment that registers no tool servers at all — the overwhelming default. That state is a
@@ -16,7 +23,14 @@
16
23
  // section on every step of every run to say nothing happened. The distinction absent-vs-empty is
17
24
  // still carried on the wire and answered by the debug API, which is where a reader asking it looks.
18
25
  import type { StepToolServers } from '~/types/toolServers'
19
- import { reasonText, remedyText } from '~/components/panels/StepToolServers.logic'
26
+ import {
27
+ observationFor,
28
+ observationIsFault,
29
+ observationText,
30
+ reasonText,
31
+ remedyText,
32
+ unattributedObservations,
33
+ } from '~/components/panels/StepToolServers.logic'
20
34
  import { agentKindMeta } from '~/utils/catalog'
21
35
 
22
36
  const props = defineProps<{
@@ -33,8 +47,22 @@ const props = defineProps<{
33
47
 
34
48
  const { t } = useI18n()
35
49
 
50
+ /**
51
+ * Servers the CLI named that this dispatch did not wire. Empty on every ordinary run (the CLI runs
52
+ * under `--strict-mcp-config`), and surfaced rather than filtered because the only way to reach it
53
+ * is a report that describes some other job — which must not be presented as this job's clean bill
54
+ * of health.
55
+ */
56
+ const unattributed = computed(() => unattributedObservations(props.toolServers))
57
+
36
58
  const hasAny = computed(
37
- () => props.toolServers.wired.length > 0 || props.toolServers.unavailable.length > 0,
59
+ () =>
60
+ props.toolServers.wired.length > 0 ||
61
+ props.toolServers.unavailable.length > 0 ||
62
+ // A report about servers this dispatch did not wire is the one thing worth showing on an
63
+ // otherwise empty record: both lists empty says the dispatched kind declared none, and a CLI
64
+ // naming servers anyway contradicts exactly that.
65
+ unattributed.value.length > 0,
38
66
  )
39
67
 
40
68
  /** Set only when a helper re-dispatch owns this record, so the ordinary case renders no extra line. */
@@ -44,6 +72,22 @@ const dispatchedAs = computed(() =>
44
72
  : null,
45
73
  )
46
74
 
75
+ /**
76
+ * Each wired server with the CLI's own verdict joined onto it, when a verdict was reported. Bound
77
+ * here for the same reason `drops` is: the join and the absent-vs-not-loaded decision are pure and
78
+ * assertable without mounting, and resolving them once per record beats once per re-render.
79
+ */
80
+ const wired = computed(() =>
81
+ props.toolServers.wired.map((server) => {
82
+ const observation = observationFor(props.toolServers.observed, server.id)
83
+ return {
84
+ ...server,
85
+ observedText: observationText(observation, (key, params) => t(key, params ?? {})),
86
+ isFault: observationIsFault(observation),
87
+ }
88
+ }),
89
+ )
90
+
47
91
  /**
48
92
  * Each dropped server with both halves of its answer resolved: WHY it was dropped, and what to
49
93
  * change so the next run gets it. Bound here rather than called from the template so the pure
@@ -80,20 +124,59 @@ const drops = computed(() =>
80
124
  {{ t('panels.stepDetail.toolServers.dispatchedAs', { agent: dispatchedAs }) }}
81
125
  </p>
82
126
 
83
- <ul v-if="toolServers.wired.length" class="flex flex-wrap gap-1.5">
127
+ <ul v-if="wired.length" class="flex flex-wrap gap-1.5">
84
128
  <li
85
- v-for="server in toolServers.wired"
129
+ v-for="server in wired"
86
130
  :key="server.id"
87
131
  data-testid="step-tool-server-wired"
88
- class="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 text-[12px] text-emerald-200"
132
+ :data-observed-fault="server.isFault ? 'true' : undefined"
133
+ class="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[12px]"
134
+ :class="
135
+ server.isFault
136
+ ? 'border-amber-500/30 bg-amber-500/10 text-amber-200'
137
+ : 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200'
138
+ "
89
139
  :title="
90
140
  server.tools?.length
91
141
  ? t('panels.stepDetail.toolServers.narrowed', { tools: server.tools.join(', ') })
92
142
  : t('panels.stepDetail.toolServers.allTools')
93
143
  "
94
144
  >
95
- <UIcon name="i-lucide-check" class="h-3.5 w-3.5" />
145
+ <UIcon
146
+ :name="server.isFault ? 'i-lucide-triangle-alert' : 'i-lucide-check'"
147
+ class="h-3.5 w-3.5"
148
+ />
96
149
  <span>{{ server.label || server.id }}</span>
150
+ <!--
151
+ The CLI's own verdict, when the run's harness reported one. Absent renders NOTHING: a
152
+ harness that publishes no report has said nothing about this server, and a placeholder
153
+ would read as a verdict.
154
+ -->
155
+ <span
156
+ v-if="server.observedText"
157
+ data-testid="step-tool-server-observed"
158
+ class="opacity-80"
159
+ >{{ server.observedText }}</span
160
+ >
161
+ </li>
162
+ </ul>
163
+
164
+ <!--
165
+ A report naming servers this dispatch did not wire can only come from a producer describing
166
+ some other job (a runner-pool manifest mapped at the wrong field). Stated rather than
167
+ filtered, so the rest of the report is not read as authoritative about this run.
168
+ -->
169
+ <ul v-if="unattributed.length" class="mt-2 space-y-1.5">
170
+ <li
171
+ v-for="server in unattributed"
172
+ :key="server.id"
173
+ data-testid="step-tool-server-unattributed"
174
+ class="flex items-start gap-1.5 text-[12px] text-slate-400"
175
+ >
176
+ <UIcon name="i-lucide-circle-help" class="mt-0.5 h-3.5 w-3.5 shrink-0" />
177
+ <span>{{
178
+ t('panels.stepDetail.toolServers.observed.unattributed', { id: server.id })
179
+ }}</span>
97
180
  </li>
98
181
  </ul>
99
182