@cat-factory/app 0.232.2 → 0.234.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.
Files changed (34) hide show
  1. package/app/components/observability/OutcomeFilterChips.vue +41 -0
  2. package/app/components/observability/RunFailureSummary.vue +243 -0
  3. package/app/components/observability/ToolCallList.vue +290 -0
  4. package/app/components/panels/ObservabilityPanel.vue +396 -129
  5. package/app/components/settings/TaskTypeSuppressionsPanel.vue +119 -0
  6. package/app/components/settings/WorkspaceSettingsPanel.vue +22 -0
  7. package/app/composables/api/execution.ts +22 -0
  8. package/app/composables/api/taskTypeSuppressions.ts +25 -0
  9. package/app/composables/useApi.ts +2 -0
  10. package/app/composables/usePipelineHealth.spec.ts +15 -1
  11. package/app/composables/usePipelineHealth.ts +13 -7
  12. package/app/docs/consumer-extensions.md +7 -1
  13. package/app/stores/observability/toolCalls.ts +173 -0
  14. package/app/stores/observability.ts +13 -0
  15. package/app/stores/pipelines.ts +22 -5
  16. package/app/stores/taskTypes.spec.ts +29 -0
  17. package/app/stores/taskTypes.ts +40 -1
  18. package/app/stores/workspace/hydrate.ts +5 -0
  19. package/app/types/domain.ts +3 -0
  20. package/app/types/execution.ts +6 -0
  21. package/app/utils/descriptorFields.ts +13 -27
  22. package/app/utils/observability.spec.ts +313 -2
  23. package/app/utils/observability.ts +232 -1
  24. package/i18n/locales/de.json +53 -0
  25. package/i18n/locales/en.json +53 -0
  26. package/i18n/locales/es.json +53 -0
  27. package/i18n/locales/fr.json +53 -0
  28. package/i18n/locales/he.json +53 -0
  29. package/i18n/locales/it.json +53 -0
  30. package/i18n/locales/ja.json +53 -0
  31. package/i18n/locales/pl.json +53 -0
  32. package/i18n/locales/tr.json +53 -0
  33. package/i18n/locales/uk.json +53 -0
  34. package/package.json +2 -2
@@ -28,6 +28,11 @@ export const useTaskTypesStore = defineStore('taskTypes', () => {
28
28
  // The active per-workspace capability manifest (shared with the agents store), or null before
29
29
  // the first hydrate. This store reads only its own `taskTypes` slot off it.
30
30
  const capabilitiesManifest = ref<RemoteModuleManifest<AppSlots> | null>(null)
31
+ // The BACKEND-registered ids this board HIDES (`snapshot.suppressedTaskTypes`). Not part of the
32
+ // catalog above by construction (a suppressed type must not be creatable), but the board did
33
+ // decide about it, and that decision is the difference between a deployment with no operations
34
+ // and one whose operations are all hidden. See {@link hasRegisteredOperations}.
35
+ const suppressedTaskTypes = ref<string[]>([])
31
36
 
32
37
  /**
33
38
  * The merged CUSTOM task types (consumer-slot → backend-manifest), de-duplicated and never
@@ -47,6 +52,23 @@ export const useTaskTypesStore = defineStore('taskTypes', () => {
47
52
  return out
48
53
  })
49
54
 
55
+ /**
56
+ * Whether this deployment registers any REUSABLE OPERATION on the backend, hidden or not: what
57
+ * decides whether the workspace-settings Operations tab exists.
58
+ *
59
+ * Deliberately NOT `customTaskTypes.length`. That list is what the board OFFERS, so hiding the
60
+ * last operation empties it and the tab that un-hides one would disappear with it, leaving no
61
+ * way back short of an API call. The suppressed ids are the other half of the same catalog.
62
+ *
63
+ * Consumer CODE-shipped types are excluded on purpose: they have no backend row to suppress, so
64
+ * a deployment that ships only those has nothing for that screen to manage.
65
+ */
66
+ const hasRegisteredOperations = computed<boolean>(
67
+ () =>
68
+ (capabilitiesManifest.value?.slots?.taskTypes ?? []).length > 0 ||
69
+ suppressedTaskTypes.value.length > 0,
70
+ )
71
+
50
72
  /** The custom types indexed by id, for a per-type lookup (e.g. the create-form field descriptors). */
51
73
  const byTaskType = computed<Record<string, CustomTaskType>>(() =>
52
74
  Object.fromEntries(customTaskTypes.value.map((t) => [t.taskType, t])),
@@ -84,5 +106,22 @@ export const useTaskTypesStore = defineStore('taskTypes', () => {
84
106
  capabilitiesManifest.value = manifest
85
107
  }
86
108
 
87
- return { customTaskTypes, get, registerConsumerTaskTypes, hydrateCapabilities }
109
+ /**
110
+ * The suppressed-id half of the same snapshot read. Assigned unconditionally (an absent field is
111
+ * an empty list): this is per-WORKSPACE state, so carrying the previous board's answer forward
112
+ * would leave the Operations tab standing on a board that hid nothing.
113
+ */
114
+ function hydrateSuppressed(ids: readonly string[]) {
115
+ suppressedTaskTypes.value = [...ids]
116
+ }
117
+
118
+ return {
119
+ customTaskTypes,
120
+ suppressedTaskTypes,
121
+ hasRegisteredOperations,
122
+ get,
123
+ registerConsumerTaskTypes,
124
+ hydrateCapabilities,
125
+ hydrateSuppressed,
126
+ }
88
127
  })
@@ -71,6 +71,7 @@ export function applySnapshotToStores(snapshot: WorkspaceSnapshot, boardSince?:
71
71
  snapshot.pipelines,
72
72
  snapshot.pipelineCatalogVersions,
73
73
  snapshot.retiredPipelines,
74
+ snapshot.pipelineCatalogNames,
74
75
  )
75
76
  useExecutionStore().hydrate(snapshot.executions, snapshot.workspace.id)
76
77
  useAgentRunsStore().hydrate(snapshot.bootstrapJobs ?? [], snapshot.workspace.id)
@@ -114,6 +115,10 @@ export function applySnapshotToStores(snapshot: WorkspaceSnapshot, boardSince?:
114
115
  snapshot.binaryGeneratorsUnavailable === true,
115
116
  )
116
117
  useTaskTypesStore().hydrateCapabilities(capabilities)
118
+ // The complement of the offered catalog above: the registered operations this board HIDES. Both
119
+ // halves come from the one snapshot, so the settings screen can exist for a board that hid
120
+ // every one of them (the state whose only way back is that screen).
121
+ useTaskTypesStore().hydrateSuppressed(snapshot.suppressedTaskTypes ?? [])
117
122
  // The per-step parameters each registered gate declares, so a gated step's config form in the
118
123
  // builder comes from the gate's own registration rather than a form hard-coded per gate.
119
124
  usePipelinesStore().hydrateGateConfigForms(snapshot.gateConfigForms ?? [])
@@ -67,6 +67,9 @@ export type {
67
67
  TaskTypePresentation,
68
68
  TaskTypeFieldDescriptor,
69
69
  TaskTypeFieldOption,
70
+ // One row of the workspace's operation-suppression screen: a registered custom task type plus
71
+ // whether THIS board hides it (`backend/docs/reusable-operations.md`).
72
+ TaskTypeSuppression,
70
73
  // The shared descriptor-driven form vocabulary (`contracts/src/form-fields.ts`): one field
71
74
  // shape and one filled-value bag behind both the initiative-preset form and a custom task
72
75
  // type's per-case form, so `DescriptorFields.vue` renders either.
@@ -24,6 +24,7 @@ export type {
24
24
  StepPhaseMetrics,
25
25
  LlmCallMetric,
26
26
  LlmCallActivity,
27
+ LlmCallOutcome,
27
28
  LlmExportInsight,
28
29
  LlmMetricsExport,
29
30
  PlatformObservability,
@@ -47,6 +48,11 @@ export type {
47
48
  ReportTotals,
48
49
  ReportsView,
49
50
  AgentSearchQuery,
51
+ AgentToolCall,
52
+ RunToolCallFailures,
53
+ RunToolCallTrajectory,
54
+ ToolCallBodiesState,
55
+ ToolCallOutcome,
50
56
  WebSearchAvailability,
51
57
  WebSearchProvider,
52
58
  PipelineStep,
@@ -1,40 +1,26 @@
1
+ import { descriptorFieldDefaults } from '@cat-factory/contracts'
1
2
  import type { DescriptorField, DescriptorFieldValue, DescriptorFieldValues } from '~/types/domain'
2
3
 
3
4
  // Form-side helpers over the shared descriptor-field vocabulary (`contracts/src/form-fields.ts`),
4
5
  // used by every surface that renders one through `DescriptorFields.vue`: an initiative preset's
5
6
  // create form and a reusable operation's per-case form on a custom task type.
6
7
  //
7
- // The RULES (visibility, validation, sanitization, prose rendering) live in contracts, because the
8
- // server has to agree about them. What lives here is what only a FORM decides: which values to
9
- // start it with, and how one edit changes the bag. Both are pure functions over the value bag
10
- // rather than methods inside the SFC, so the mutation rules a wrong answer would freeze on an
11
- // entity are unit-testable without mounting a component.
8
+ // The RULES (visibility, validation, sanitization, prose rendering, and now default seeding) live
9
+ // in contracts, because the server has to agree about them. What lives here is what only a FORM
10
+ // decides: how one edit changes the bag. Pure functions over the value bag rather than methods
11
+ // inside the SFC, so the mutation rules a wrong answer would freeze on an entity are unit-testable
12
+ // without mounting a component.
12
13
 
13
14
  /**
14
- * The initial, typed values a field list implies: its declared DEFAULTS folded into the
15
- * `DescriptorFieldValues` shape the renderer and the wire contract expect (`checkbox-group` to
16
- * `string[]`, `checkbox` to a boolean, `number` to a number, everything else a string). Only fields
17
- * with a meaningful default are seeded, so an unfilled optional field stays absent (which is what
18
- * validation reads as unset) and never freezes an empty value. A repo-detection probe's prefill and
19
- * the user's own edits layer on top.
15
+ * The initial values a field list implies, for seeding a freshly opened form. A repo-detection
16
+ * probe's prefill and the user's own edits layer on top.
17
+ *
18
+ * The SHARED helper, not a form-side copy: the server folds the same defaults in at the creation
19
+ * door (`withDescriptorFieldDefaults`), so a duplicate here would be the drift that made a headless
20
+ * caller and this form disagree about what a descriptor's default means.
20
21
  */
21
22
  export function defaultDescriptorValues(fields: readonly DescriptorField[]): DescriptorFieldValues {
22
- const values: DescriptorFieldValues = {}
23
- for (const field of fields) {
24
- if (field.type === 'checkbox-group') {
25
- if (field.defaultValues?.length) values[field.key] = [...field.defaultValues]
26
- } else if (field.type === 'checkbox') {
27
- if (field.default === 'true') values[field.key] = true
28
- } else if (field.type === 'number') {
29
- const parsed = Number(field.default)
30
- if (field.default !== undefined && field.default !== '' && Number.isFinite(parsed)) {
31
- values[field.key] = parsed
32
- }
33
- } else if (field.default) {
34
- values[field.key] = field.default
35
- }
36
- }
37
- return values
23
+ return descriptorFieldDefaults(fields)
38
24
  }
39
25
 
40
26
  /**
@@ -1,6 +1,26 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import type { PipelineStep, StepPhaseMetrics } from '~/types/execution'
3
- import { foldRunPhaseMetrics, formatCost, sumCosts, totalInputTokens } from './observability'
2
+ import type {
3
+ AgentFailure,
4
+ AgentToolCall,
5
+ LlmCallMetric,
6
+ PipelineStep,
7
+ RunToolCallFailures,
8
+ StepPhaseMetrics,
9
+ } from '~/types/execution'
10
+ import type { SinkAnswer } from './observability'
11
+ import {
12
+ countCallOutcomes,
13
+ deriveRunFailureEvidence,
14
+ filterCallsByOutcome,
15
+ filterToolCallsByOutcome,
16
+ foldRunPhaseMetrics,
17
+ formatCost,
18
+ hasFailureEvidence,
19
+ noFailingCallReason,
20
+ sinkAnswer,
21
+ sumCosts,
22
+ totalInputTokens,
23
+ } from './observability'
4
24
 
5
25
  describe('totalInputTokens', () => {
6
26
  it('sums all three input classes, so the headline matches Claude Code’s context gauge', () => {
@@ -128,3 +148,294 @@ describe('sumCosts', () => {
128
148
  expect(sumCosts([undefined])).toBeNull()
129
149
  })
130
150
  })
151
+
152
+ describe('failing-call-first triage', () => {
153
+ const call = (over: Partial<LlmCallMetric> & Pick<LlmCallMetric, 'id'>): LlmCallMetric =>
154
+ ({
155
+ workspaceId: 'ws',
156
+ executionId: 'run',
157
+ agentKind: 'coder',
158
+ provider: 'anthropic',
159
+ model: 'm',
160
+ createdAt: 1,
161
+ streaming: false,
162
+ phase: 'agent',
163
+ turnIndex: null,
164
+ messageCount: 1,
165
+ toolCount: 0,
166
+ requestMaxTokens: null,
167
+ promptTokens: 0,
168
+ cacheReadTokens: 0,
169
+ cacheWriteTokens: 0,
170
+ completionTokens: 0,
171
+ totalTokens: 0,
172
+ finishReason: 'stop',
173
+ upstreamMs: 1,
174
+ overheadMs: 1,
175
+ totalMs: 2,
176
+ ok: true,
177
+ httpStatus: 200,
178
+ errorMessage: null,
179
+ promptText: '',
180
+ promptPrefixCount: 0,
181
+ promptHash: '',
182
+ responseText: '',
183
+ reasoningText: '',
184
+ ...over,
185
+ }) as LlmCallMetric
186
+
187
+ const tool = (over: Partial<AgentToolCall> & Pick<AgentToolCall, 'id'>): AgentToolCall => ({
188
+ workspaceId: 'ws',
189
+ executionId: 'run',
190
+ agentKind: 'coder',
191
+ jobId: 'job',
192
+ seq: 0,
193
+ tool: 'bash',
194
+ startedAt: 1,
195
+ endedAt: 2,
196
+ ok: true,
197
+ bodies: 'stored',
198
+ args: '',
199
+ result: '',
200
+ argsDropped: 0,
201
+ resultDropped: 0,
202
+ createdAt: 1,
203
+ ...over,
204
+ })
205
+
206
+ /** An untruncated failure read holding exactly these rows: the ordinary case. */
207
+ const toolFailures = (
208
+ failures: AgentToolCall[],
209
+ total = failures.length,
210
+ ): RunToolCallFailures => ({
211
+ total,
212
+ failed: failures.length,
213
+ failures,
214
+ failuresTruncated: false,
215
+ })
216
+
217
+ const failure = (): AgentFailure => ({
218
+ kind: 'agent',
219
+ message: 'the coder step failed',
220
+ detail: null,
221
+ hint: null,
222
+ occurredAt: 5,
223
+ lastSubtasks: null,
224
+ })
225
+
226
+ describe('countCallOutcomes', () => {
227
+ it('keeps a failed call and a TRUNCATED one in different buckets', () => {
228
+ // They need different fixes (transport/proxy/spend versus an output limit), so a filter
229
+ // that lumped them together would send an operator to the wrong conversation.
230
+ const counts = countCallOutcomes([
231
+ call({ id: 'a' }),
232
+ call({ id: 'b', ok: false, finishReason: null }),
233
+ call({ id: 'c', finishReason: 'length' }),
234
+ call({ id: 'd', finishReason: 'content_filter' }),
235
+ ])
236
+ expect(counts).toEqual({ all: 4, ok: 1, warning: 2, error: 1 })
237
+ })
238
+
239
+ it('counts a failed call as an error even when its finish reason looks like a warning', () => {
240
+ // `ok: false` wins: a call that failed AND reported `length` is a failure, not a
241
+ // truncation, and counting it in both buckets would make the chips sum past the total.
242
+ const counts = countCallOutcomes([call({ id: 'a', ok: false, finishReason: 'length' })])
243
+ expect(counts).toEqual({ all: 1, ok: 0, warning: 0, error: 1 })
244
+ })
245
+ })
246
+
247
+ describe('filterCallsByOutcome', () => {
248
+ const calls = [
249
+ call({ id: 'ok' }),
250
+ call({ id: 'warn', finishReason: 'length' }),
251
+ call({ id: 'err', ok: false }),
252
+ ]
253
+
254
+ it('narrows to one class and passes everything through on `all`', () => {
255
+ expect(filterCallsByOutcome(calls, 'error').map((c) => c.id)).toEqual(['err'])
256
+ expect(filterCallsByOutcome(calls, 'warning').map((c) => c.id)).toEqual(['warn'])
257
+ expect(filterCallsByOutcome(calls, 'all').map((c) => c.id)).toEqual(['ok', 'warn', 'err'])
258
+ })
259
+
260
+ it('returns a fresh array, never the caller’s own list', () => {
261
+ // The panel holds the store's array; a filter that aliased it on `all` would let a sort in
262
+ // the component reorder the store.
263
+ expect(filterCallsByOutcome(calls, 'all')).not.toBe(calls)
264
+ })
265
+ })
266
+
267
+ describe('filterToolCallsByOutcome', () => {
268
+ it('narrows a trajectory to the failing calls, keeping their order', () => {
269
+ const trajectory = [
270
+ tool({ id: '1' }),
271
+ tool({ id: '2', ok: false }),
272
+ tool({ id: '3' }),
273
+ tool({ id: '4', ok: false }),
274
+ ]
275
+ expect(filterToolCallsByOutcome(trajectory, 'error').map((c) => c.id)).toEqual(['2', '4'])
276
+ expect(filterToolCallsByOutcome(trajectory, 'ok').map((c) => c.id)).toEqual(['1', '3'])
277
+ expect(filterToolCallsByOutcome(trajectory, 'all')).toHaveLength(4)
278
+ })
279
+ })
280
+
281
+ describe('deriveRunFailureEvidence', () => {
282
+ /** Both sinks answered with the given rows. */
283
+ const answered = (rows: number): SinkAnswer => ({ status: 'answered', rows })
284
+ const evidenceFor = (input: {
285
+ failure?: AgentFailure | null
286
+ calls?: LlmCallMetric[]
287
+ toolFailures?: RunToolCallFailures | null
288
+ }) => {
289
+ const calls = input.calls ?? []
290
+ const toolFailures = input.toolFailures ?? null
291
+ return deriveRunFailureEvidence({
292
+ failure: input.failure ?? null,
293
+ calls,
294
+ callsAnswer: answered(calls.length),
295
+ toolFailures,
296
+ toolsAnswer: toolFailures ? answered(toolFailures.total) : { status: 'pending' },
297
+ })
298
+ }
299
+
300
+ it('picks the LAST failing row from each sink, respecting their opposite orders', () => {
301
+ // Calls arrive newest-first; the failing tool calls arrive oldest-first (trajectory order).
302
+ // Reading either the wrong way round still yields a failing call, just not the one nearest
303
+ // the failure, which is the only reason to pin one.
304
+ const evidence = evidenceFor({
305
+ failure: failure(),
306
+ calls: [
307
+ call({ id: 'newest-error', ok: false, createdAt: 30 }),
308
+ call({ id: 'older-error', ok: false, createdAt: 10 }),
309
+ ],
310
+ toolFailures: toolFailures([
311
+ tool({ id: 'oldest-fail', ok: false, startedAt: 10 }),
312
+ tool({ id: 'latest-fail', ok: false, startedAt: 30 }),
313
+ ]),
314
+ })
315
+ expect(evidence.lastErroredCall?.id).toBe('newest-error')
316
+ expect(evidence.lastFailedToolCall?.id).toBe('latest-fail')
317
+ expect(evidence.erroredCallCount).toBe(2)
318
+ expect(evidence.failedToolCallCount).toBe(2)
319
+ })
320
+
321
+ it('counts failing tool calls off the run AGGREGATE, never off the rows it holds', () => {
322
+ // The failure this split exists to prevent. The backend narrows and counts over the whole
323
+ // run; the rows come back bounded. A count taken from the list would under-report exactly
324
+ // the long runs worth opening the panel for, and would disagree with the debug overview's
325
+ // `toolCalls.totals.failures` on the same run.
326
+ const evidence = evidenceFor({
327
+ failure: failure(),
328
+ toolFailures: {
329
+ total: 5_000,
330
+ failed: 240,
331
+ failures: [tool({ id: 'held', ok: false })],
332
+ failuresTruncated: true,
333
+ },
334
+ })
335
+ expect(evidence.failedToolCallCount).toBe(240)
336
+ expect(evidence.failedToolCallsTruncated).toBe(true)
337
+ expect(evidence.lastFailedToolCall?.id).toBe('held')
338
+ })
339
+
340
+ it('reports a run whose model calls are all healthy but whose tools are not', () => {
341
+ // The whole failure class this surface exists for: the model call that requested the tool
342
+ // still reports `ok`, so every LLM rollup reads clean.
343
+ const evidence = evidenceFor({
344
+ failure: failure(),
345
+ calls: [call({ id: 'fine' })],
346
+ toolFailures: toolFailures([tool({ id: 'broke', ok: false })]),
347
+ })
348
+ expect(evidence.lastErroredCall).toBeNull()
349
+ expect(evidence.lastFailedToolCall?.id).toBe('broke')
350
+ expect(hasFailureEvidence(evidence)).toBe(true)
351
+ })
352
+
353
+ it('has nothing to pin for a run that neither failed nor recorded a failing call', () => {
354
+ expect(hasFailureEvidence(evidenceFor({ calls: [call({ id: 'a' })] }))).toBe(false)
355
+ })
356
+
357
+ it('pins the section for an UNREACHABLE sink even with no failure and no failing call', () => {
358
+ // "One of these reads did not come back" has to reach the operator before they read the
359
+ // rest of the page as whole. Staying silent is how the other numbers get believed.
360
+ const evidence = deriveRunFailureEvidence({
361
+ failure: null,
362
+ calls: [],
363
+ callsAnswer: { status: 'answered', rows: 0 },
364
+ toolFailures: null,
365
+ toolsAnswer: { status: 'unreachable' },
366
+ })
367
+ expect(hasFailureEvidence(evidence)).toBe(true)
368
+ })
369
+ })
370
+
371
+ describe('sinkAnswer', () => {
372
+ it('reports an in-flight read as pending whatever it is still holding', () => {
373
+ // A refresh over a previous answer (or a previous error) is not that answer: what the panel
374
+ // says next depends on what is coming back.
375
+ expect(sinkAnswer({ loading: true, error: 'boom', loaded: true, rows: 3 })).toEqual({
376
+ status: 'pending',
377
+ })
378
+ })
379
+
380
+ it('separates a failed read from an answer of zero rows', () => {
381
+ expect(sinkAnswer({ loading: false, error: 'boom', loaded: false, rows: 0 })).toEqual({
382
+ status: 'unreachable',
383
+ })
384
+ expect(sinkAnswer({ loading: false, error: null, loaded: true, rows: 0 })).toEqual({
385
+ status: 'answered',
386
+ rows: 0,
387
+ })
388
+ })
389
+
390
+ it('treats never-requested as pending, not as an answer', () => {
391
+ expect(sinkAnswer({ loading: false, error: null, loaded: false, rows: 0 })).toEqual({
392
+ status: 'pending',
393
+ })
394
+ })
395
+ })
396
+
397
+ describe('noFailingCallReason', () => {
398
+ const reasonFor = (calls: SinkAnswer, tools: SinkAnswer, failed = 0) =>
399
+ noFailingCallReason(
400
+ deriveRunFailureEvidence({
401
+ failure: failure(),
402
+ calls: [],
403
+ callsAnswer: calls,
404
+ toolFailures: { total: 0, failed, failures: [], failuresTruncated: false },
405
+ toolsAnswer: tools,
406
+ }),
407
+ )
408
+ const answered = (rows: number): SinkAnswer => ({ status: 'answered', rows })
409
+
410
+ it('says nothing when a failing call WAS found', () => {
411
+ expect(reasonFor(answered(1), answered(1), 1)).toBeNull()
412
+ })
413
+
414
+ it('distinguishes "both sinks answered and nothing failed" from "nothing was recorded"', () => {
415
+ // The distinction the whole helper exists for: an unwired sink, a capture opt-out and a
416
+ // container that died before reporting all produce zero failing rows, exactly like a run
417
+ // whose every call succeeded, and only one of those is a clean bill of health.
418
+ expect(reasonFor(answered(1), answered(1))).toBe('recorded-clean')
419
+ expect(reasonFor(answered(0), answered(0))).toBe('no-telemetry')
420
+ })
421
+
422
+ it('names WHICH sink is empty when only one holds rows', () => {
423
+ expect(reasonFor(answered(1), answered(0))).toBe('partial-calls-only')
424
+ expect(reasonFor(answered(0), answered(1))).toBe('partial-tools-only')
425
+ })
426
+
427
+ it('withholds every verdict while a sink has not answered yet', () => {
428
+ // A read still in flight is not a read that came back clean, and rendering it as one is a
429
+ // clean bill of health written before the evidence arrived.
430
+ expect(reasonFor({ status: 'pending' }, answered(1))).toBeNull()
431
+ expect(reasonFor(answered(1), { status: 'pending' })).toBeNull()
432
+ })
433
+
434
+ it('lets an UNREACHABLE sink outrank every statement about the run', () => {
435
+ // Each other reason is a claim about the run. This is the one case where the panel has no
436
+ // standing to make one, so it must win even against a sink that answered richly.
437
+ expect(reasonFor({ status: 'unreachable' }, answered(9))).toBe('sink-unreachable')
438
+ expect(reasonFor(answered(9), { status: 'unreachable' })).toBe('sink-unreachable')
439
+ })
440
+ })
441
+ })