@cat-factory/app 0.241.2 → 0.242.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.
@@ -12,8 +12,8 @@
12
12
  // It composes NOTHING itself: `composeRunOutcome` (`~/utils/runOutcome`) is the pure reduction,
13
13
  // so the rules that matter (a regression is an `established` requirement observed to fail; an
14
14
  // absent producer never renders as a clean result) are unit-tested without mounting this. What
15
- // lives here is presentation only, plus the ONE fetch the card owns: the enclosing service's
16
- // spec, which turns the tester's requirement IDS into the requirement TITLES a reader came for.
15
+ // lives here is presentation only, plus the ONE fetch the card owns: the spec THIS RUN was
16
+ // judged against, which turns the tester's requirement IDS into the TITLES a reader came for.
17
17
  import { computed, onUnmounted, ref, watch } from 'vue'
18
18
  import type {
19
19
  OutcomeCheckKind,
@@ -47,32 +47,43 @@ const { t } = useI18n()
47
47
  const blobs = useArtifactBlobs()
48
48
  onUnmounted(() => blobs.revokeAll())
49
49
 
50
- // The shared seam contract. The `onOpen` loader fetches the ENCLOSING SERVICE's spec: the
51
- // requirement verdicts are keyed by the spec's own ids, and without it the coverage section can
52
- // only show ids (which it then says, rather than letting an id read as a title).
53
- const { open, blockId, instanceId, close } = useResultView('outcome', {
54
- onOpen: (view) => {
55
- const block = board.getBlock(view.blockId)
56
- const service = block ? board.serviceOf(block) : undefined
57
- if (service) void serviceSpec.load(service.id)
58
- },
59
- })
50
+ // The shared seam contract.
51
+ const { open, blockId, instanceId, close } = useResultView('outcome')
60
52
 
61
53
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
62
- const service = computed(() => (block.value ? board.serviceOf(block.value) : undefined))
63
- const instance = computed(() => {
54
+ const runId = computed(() => {
64
55
  // The run carried by the opener, else the block's own live run: a card opened from a
65
56
  // notification names the run, one opened from the board does not.
66
- const id = instanceId.value ?? block.value?.executionId ?? null
67
- return id ? (execution.getInstance(id) ?? null) : null
57
+ return instanceId.value ?? block.value?.executionId ?? null
68
58
  })
59
+ const instance = computed(() => (runId.value ? (execution.getInstance(runId.value) ?? null) : null))
60
+
61
+ // The ONE fetch this card owns: the spec THIS RUN was judged against. Requirement verdicts are
62
+ // keyed by the spec's own ids, and without the spec the coverage section can only show ids
63
+ // (which it then says, rather than letting an id read as a title).
64
+ //
65
+ // Keyed by the RUN, not by the enclosing service, and that is a correctness matter rather than a
66
+ // cache detail: the service read comes from the repo's default branch, so for as long as the
67
+ // run's pull request is open it is missing exactly the requirements the run added and the tester
68
+ // just ruled on. Every one of those verdicts joined against nothing and rendered as "not
69
+ // checked", and the card's counts contradicted `GET /api/v1/runs/:runId/outcome` for one run.
70
+ //
71
+ // A watch rather than the `onOpen` hook, because the run id can arrive after the block does (a
72
+ // card open on a task that starts a run) and the join must follow it.
73
+ watch(
74
+ runId,
75
+ (id) => {
76
+ if (id) void serviceSpec.loadForRun(id)
77
+ },
78
+ { immediate: true },
79
+ )
69
80
 
70
81
  const outcome = computed(() =>
71
82
  block.value
72
83
  ? composeRunOutcome({
73
84
  block: block.value,
74
85
  instance: instance.value,
75
- spec: service.value ? serviceSpec.viewFor(service.value.id) : null,
86
+ spec: runId.value ? serviceSpec.viewForRun(runId.value) : null,
76
87
  })
77
88
  : null,
78
89
  )
@@ -110,6 +121,7 @@ const REQUIREMENTS_GAP_KEYS: Record<RequirementsGap, string> = {
110
121
  no_tester_step: 'outcome.requirements.gap.no_tester_step',
111
122
  tester_not_reported: 'outcome.requirements.gap.tester_not_reported',
112
123
  no_verdicts: 'outcome.requirements.gap.no_verdicts',
124
+ no_requirements: 'outcome.requirements.gap.no_requirements',
113
125
  }
114
126
  const TESTS_GAP_KEYS: Record<TestsGap, string> = {
115
127
  run_unavailable: RUN_UNAVAILABLE_KEY,
@@ -128,7 +140,6 @@ const VISUALS_GAP_KEYS: Record<VisualsGap, string> = {
128
140
  */
129
141
  const SPEC_JOIN_KEYS: Record<Exclude<OutcomeSpecJoin, 'joined'>, string> = {
130
142
  not_read: 'outcome.requirements.spec.not_read',
131
- unmatched: 'outcome.requirements.spec.unmatched',
132
143
  }
133
144
 
134
145
  const VERDICT_META: Record<RequirementVerdictStatus, { color: string; key: string }> = {
@@ -186,9 +197,11 @@ const headerTitle = computed(() => outcome.value?.title ?? t('outcome.title'))
186
197
  const disposition = computed(() => outcome.value?.disposition ?? 'not_run')
187
198
 
188
199
  /**
189
- * The note under the requirement counts when the rows carry no spec titles, null when they do.
190
- * Resolved here so the `joined` exclusion is checked by the compiler once, rather than by a
191
- * template condition that would silently render nothing if the union grew.
200
+ * The note under the requirement counts when the section was NOT counted against the service's
201
+ * `spec/`, null when it was. It is a statement about the DENOMINATOR, not about missing titles:
202
+ * an unjoined section counts only what the tester chose to rule on and says nothing about what it
203
+ * skipped. Resolved here so the `joined` exclusion is checked by the compiler once, rather than by
204
+ * a template condition that would silently render nothing if the union grew.
192
205
  */
193
206
  const specNote = computed(() => {
194
207
  const requirements = outcome.value?.requirements
@@ -198,19 +211,22 @@ const specNote = computed(() => {
198
211
  return t(SPEC_JOIN_KEYS[requirements.spec])
199
212
  })
200
213
 
214
+ /** The requirement rows, in the composer's severity-first order. */
215
+ const requirementRows = computed(() => {
216
+ const requirements = outcome.value?.requirements
217
+ return requirements?.status === 'reported' ? requirements.entries : []
218
+ })
219
+
201
220
  /**
202
- * The requirement rows, each carrying whether its id is standing in for a title it has no way
203
- * to show. Marked per row ONLY where the section as a whole joined: an id sitting unmarked
204
- * between two named requirements reads as a requirement someone named after a slug, while a
205
- * marker on every row of a section the note above already explains is just noise.
221
+ * How many verdicts the tester returned against ids this service's `spec/` does not carry, or 0.
222
+ *
223
+ * Surfaced because the counts above are the SPEC's and this number is the difference between them
224
+ * and the tester's own tally. Unstated, a reader comparing the two reads the gap as one of the two
225
+ * being wrong, when it is really a spec that moved on under the tester.
206
226
  */
207
- const requirementRows = computed(() => {
227
+ const unmatchedVerdicts = computed(() => {
208
228
  const requirements = outcome.value?.requirements
209
- if (!requirements || requirements.status !== 'reported') return []
210
- return requirements.entries.map((entry) => ({
211
- ...entry,
212
- idOnly: requirements.spec === 'joined' && entry.title === null,
213
- }))
229
+ return requirements?.status === 'reported' ? requirements.unmatchedVerdicts : 0
214
230
  })
215
231
 
216
232
  /** The captured views, resolved to blobs as they arrive (the card shows them inline). */
@@ -373,8 +389,8 @@ function openTestReport() {
373
389
  }}
374
390
  </UBadge>
375
391
  </div>
376
- <!-- The ids are all there is: say WHICH reason, rather than letting a slug read as
377
- the name of a requirement (never read, versus read and naming none of these). -->
392
+ <!-- The coverage was not counted against the spec, so say what the numbers above do
393
+ and do not cover rather than letting them read as the whole picture. -->
378
394
  <p
379
395
  v-if="specNote"
380
396
  class="mb-2 text-[11px] leading-relaxed text-amber-300/90"
@@ -382,6 +398,16 @@ function openTestReport() {
382
398
  >
383
399
  {{ specNote }}
384
400
  </p>
401
+ <!-- The tester ruled on ids the spec does not carry, so its own tally and the counts
402
+ above legitimately differ. Said out loud, because the alternative is a reader
403
+ deciding which of the two numbers to distrust. -->
404
+ <p
405
+ v-if="unmatchedVerdicts > 0"
406
+ class="mb-2 text-[11px] leading-relaxed text-amber-300/90"
407
+ data-testid="outcome-unmatched-verdicts"
408
+ >
409
+ {{ t('outcome.requirements.unmatchedVerdicts', { count: unmatchedVerdicts }) }}
410
+ </p>
385
411
  <ul class="space-y-1.5">
386
412
  <li
387
413
  v-for="req in requirementRows"
@@ -396,18 +422,6 @@ function openTestReport() {
396
422
  <div class="min-w-0">
397
423
  <div class="flex flex-wrap items-center gap-1.5">
398
424
  <span class="text-[13px] text-slate-200">{{ req.title ?? req.id }}</span>
399
- <!-- This row's id is standing in for a title the spec does not have for it,
400
- beside rows that DO carry one. -->
401
- <UBadge
402
- v-if="req.idOnly"
403
- color="neutral"
404
- variant="subtle"
405
- size="sm"
406
- :title="t('outcome.requirements.idOnlyHint')"
407
- data-testid="outcome-requirement-id-only"
408
- >
409
- {{ t('outcome.requirements.idOnly') }}
410
- </UBadge>
411
425
  <UBadge
412
426
  v-if="req.regression"
413
427
  color="error"
@@ -1,14 +1,24 @@
1
- import { getServiceSpecContract } from '@cat-factory/contracts'
1
+ import { getRunSpecContract, getServiceSpecContract } from '@cat-factory/contracts'
2
2
  import type { ApiContext } from './context'
3
3
 
4
4
  /**
5
- * The service-spec read (the inspector's "View Requirements" window). Reassembles the
6
- * sharded `spec/` artifact from the service repo's default branch. Always 200: a service
7
- * with no spec on main (or no GitHub connected) returns `{ present: false }`.
5
+ * The two spec reads, which answer different questions and are paired here so a caller has to
6
+ * pick one deliberately.
7
+ *
8
+ * `getServiceSpec` reassembles the sharded `spec/` artifact from the service repo's DEFAULT
9
+ * branch: what the service requires today, for the inspector's "View Requirements" window.
10
+ * `getRunSpec` reads it from the branch ONE RUN pushed to: what that run's tester actually ruled
11
+ * against, for the outcome card's requirement join. While a pull request is open the two are
12
+ * different trees, and joining a run's verdicts against the first shows every requirement the
13
+ * run itself added as "not checked".
14
+ *
15
+ * Both are always 200: no spec, no repo or no VCS connected returns `{ present: false }`.
8
16
  */
9
17
  export function specApi({ send, ws }: ApiContext) {
10
18
  return {
11
19
  getServiceSpec: (workspaceId: string, blockId: string) =>
12
20
  send(getServiceSpecContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
21
+ getRunSpec: (workspaceId: string, executionId: string) =>
22
+ send(getRunSpecContract, { pathPrefix: ws(workspaceId), pathParams: { executionId } }),
13
23
  }
14
24
  }
@@ -229,6 +229,14 @@ Both links are absolute on purpose: this page ships inside the `@cat-factory/app
229
229
  relative path out of it resolves only from a checkout, which is the one place its reader is not.
230
230
  (`scripts/check-shipped-doc-links.mjs` fails CI on a shipped doc that grows one.)
231
231
 
232
+ The BACKEND half of an operation is registered with one dependency: the runtime facade you boot
233
+ through (`@cat-factory/node-server`, `@cat-factory/local-server` or `@cat-factory/worker`) exports
234
+ every registry constructor and every type a registration literal names. Do not reach below it for
235
+ `@cat-factory/kernel`, `@cat-factory/contracts` or `@cat-factory/prompt-fragments`: those publish at
236
+ EXACT versions, so floating one past what your facade pins resolves a second physical copy and your
237
+ registrations land where nothing reads them
238
+ ([ADR 0044](https://github.com/kibertoad/cat-factory/blob/main/backend/docs/adr/0044-facade-extension-surface.md)).
239
+
232
240
  The **same type can be delivered from the backend** instead of code-shipped: register it on the
233
241
  deployment's app-owned `TaskTypeRegistry` and it arrives in the workspace snapshot's
234
242
  `customTaskTypes`, folded into the SAME merged catalog (data over the wire, never components). The
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useServiceSpecStore } from '~/stores/serviceSpec'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import type { ServiceSpecView } from '~/types/spec'
5
+
6
+ // The store holds TWO spec reads, and which one a caller takes is a correctness matter rather
7
+ // than a caching detail. The service read is the repo's DEFAULT branch ("what does this service
8
+ // require", the inspector's requirements window); the run read is the branch ONE RUN pushed to
9
+ // ("what did this run rule on", the outcome card's requirement join). While a pull request is
10
+ // open they are different trees, and the card joining against the first showed every requirement
11
+ // the run itself added as "not checked" while `/api/v1/runs/:runId/outcome` said otherwise.
12
+ //
13
+ // So what is pinned here is that the two never answer for each other: one key space cannot serve
14
+ // the other's question even when a block id and an execution id happen to collide.
15
+
16
+ function view(service: string): ServiceSpecView {
17
+ return {
18
+ present: true,
19
+ spec: { service, summary: '', modules: [] },
20
+ features: [],
21
+ } as unknown as ServiceSpecView
22
+ }
23
+
24
+ function stubApi(over: Record<string, unknown> = {}) {
25
+ vi.stubGlobal('useApi', () => ({
26
+ getServiceSpec: () => Promise.resolve(view('from-default-branch')),
27
+ getRunSpec: () => Promise.resolve(view('from-run-branch')),
28
+ ...over,
29
+ }))
30
+ }
31
+
32
+ beforeEach(() => {
33
+ useWorkspaceStore().workspaceId = 'ws1'
34
+ stubApi()
35
+ })
36
+
37
+ describe('serviceSpec store', () => {
38
+ it('reads a run’s spec from the run endpoint, not the service one', async () => {
39
+ const store = useServiceSpecStore()
40
+ await store.loadForRun('exec_1')
41
+ expect(store.viewForRun('exec_1')?.spec?.service).toBe('from-run-branch')
42
+ })
43
+
44
+ it('keeps the two reads in separate key spaces', async () => {
45
+ // Same id, two questions. Without the prefixes one load would satisfy the other's accessor
46
+ // and the card would silently join against the default branch again.
47
+ const store = useServiceSpecStore()
48
+ await store.load('id_1')
49
+ await store.loadForRun('id_1')
50
+ expect(store.viewFor('id_1')?.spec?.service).toBe('from-default-branch')
51
+ expect(store.viewForRun('id_1')?.spec?.service).toBe('from-run-branch')
52
+ })
53
+
54
+ it('does not answer a run read out of the service cache', async () => {
55
+ const store = useServiceSpecStore()
56
+ await store.load('id_1')
57
+ expect(store.viewForRun('id_1')).toBeUndefined()
58
+ })
59
+
60
+ it('coalesces overlapping loads of one run onto a single request', async () => {
61
+ let calls = 0
62
+ stubApi({
63
+ getRunSpec: () => {
64
+ calls += 1
65
+ return Promise.resolve(view('from-run-branch'))
66
+ },
67
+ })
68
+ const store = useServiceSpecStore()
69
+ await Promise.all([store.loadForRun('exec_1'), store.loadForRun('exec_1')])
70
+ expect(calls).toBe(1)
71
+ })
72
+
73
+ it('records a failed run read without poisoning the cached view', async () => {
74
+ stubApi({ getRunSpec: () => Promise.reject(new Error('offline')) })
75
+ const store = useServiceSpecStore()
76
+ await store.loadForRun('exec_1')
77
+ // The card composes `spec: 'not_read'` off an absent view and says so; a fabricated empty
78
+ // view would have read as a service that declares nothing.
79
+ expect(store.viewForRun('exec_1')).toBeUndefined()
80
+ })
81
+ })
@@ -4,69 +4,99 @@ import type { ServiceSpecView } from '~/types/spec'
4
4
  import { useWorkspaceStore } from '~/stores/workspace'
5
5
 
6
6
  /**
7
- * Service-spec read state for the inspector's "View Requirements" window. The spec lives
8
- * sharded in the service repo under `spec/`; the backend reassembles it from the repo's
9
- * default branch and serves a {@link ServiceSpecView}. Read-only and fetched on demand
10
- * (per service frame block), cached per block. Nothing is persisted client-side.
7
+ * Service-spec read state. The spec lives sharded in the service repo under `spec/`; the backend
8
+ * reassembles it and serves a {@link ServiceSpecView}. Read-only, fetched on demand, cached.
9
+ * Nothing is persisted client-side.
10
+ *
11
+ * TWO reads, because there are two questions and they have different answers while a pull
12
+ * request is open:
13
+ *
14
+ * - {@link load} / {@link viewFor}: what the SERVICE requires, from the repo's default branch,
15
+ * for the inspector's "View Requirements" window.
16
+ * - {@link loadForRun} / {@link viewForRun}: what ONE RUN was judged against, from the branch
17
+ * that run pushed to, for the outcome card's requirement join. The card used to read the
18
+ * first, so every verdict naming a requirement the run itself added joined against a spec
19
+ * that does not carry it yet and rendered as "not checked" — and the card's counts then
20
+ * disagreed with `GET /api/v1/runs/:runId/outcome`, which reads the run's branch.
21
+ *
22
+ * One cache, two key spaces: the entries are keyed by a PREFIXED id so a block id and an
23
+ * execution id can never collide, and every accessor mints its key through the same helper.
11
24
  */
12
25
  export const useServiceSpecStore = defineStore('serviceSpec', () => {
13
26
  const api = useApi()
14
27
  const workspace = useWorkspaceStore()
15
28
 
16
- /** The fetched view per block id (undefined = not yet fetched). */
29
+ const serviceKey = (blockId: string) => `service:${blockId}`
30
+ const runKey = (executionId: string) => `run:${executionId}`
31
+
32
+ /** The fetched view per cache key (undefined = not yet fetched). */
17
33
  const views = ref<Record<string, ServiceSpecView>>({})
18
34
  /**
19
- * In-flight loads keyed by block id — the SINGLE source of truth for "is this block
20
- * loading". A reactive Map so `isLoading` (derived from `.has`) tracks set/delete, and it
21
- * also coalesces overlapping loads onto one request: no separate loading-flag Set to keep
22
- * in sync.
35
+ * In-flight loads keyed by cache key — the SINGLE source of truth for "is this loading". A
36
+ * reactive Map so `isLoading` (derived from `.has`) tracks set/delete, and it also coalesces
37
+ * overlapping loads onto one request: no separate loading-flag Set to keep in sync.
23
38
  */
24
39
  const inFlight = reactive(new Map<string, Promise<void>>())
25
- /** Block ids whose last fetch failed (network / unexpected error). */
26
- const erroredByBlock = ref<Set<string>>(new Set())
40
+ /** Cache keys whose last fetch failed (network / unexpected error). */
41
+ const erroredByKey = ref<Set<string>>(new Set())
27
42
 
28
43
  function viewFor(blockId: string): ServiceSpecView | undefined {
29
- return views.value[blockId]
44
+ return views.value[serviceKey(blockId)]
45
+ }
46
+ function viewForRun(executionId: string): ServiceSpecView | undefined {
47
+ return views.value[runKey(executionId)]
30
48
  }
31
49
  function isLoading(blockId: string): boolean {
32
- return inFlight.has(blockId)
50
+ return inFlight.has(serviceKey(blockId))
33
51
  }
34
52
  function isErrored(blockId: string): boolean {
35
- return erroredByBlock.value.has(blockId)
53
+ return erroredByKey.value.has(serviceKey(blockId))
36
54
  }
37
55
 
38
56
  function setErrored(key: string, on: boolean) {
39
- const next = new Set(erroredByBlock.value)
57
+ const next = new Set(erroredByKey.value)
40
58
  if (on) next.add(key)
41
59
  else next.delete(key)
42
- erroredByBlock.value = next
60
+ erroredByKey.value = next
43
61
  }
44
62
 
45
- /** Fetch (and cache) the spec view for a service frame block. */
46
- async function load(blockId: string) {
63
+ /** Fetch (and cache) one view, coalescing concurrent loads of the same key. */
64
+ function loadKeyed(key: string, fetch: (workspaceId: string) => Promise<ServiceSpecView>) {
47
65
  if (!workspace.workspaceId) return
48
- const pending = inFlight.get(blockId)
66
+ const pending = inFlight.get(key)
49
67
  if (pending) return pending
50
- setErrored(blockId, false)
68
+ setErrored(key, false)
51
69
  const promise = (async () => {
52
70
  try {
53
- const view = await api.getServiceSpec(workspace.requireId(), blockId)
54
- views.value = { ...views.value, [blockId]: view }
71
+ const view = await fetch(workspace.requireId())
72
+ views.value = { ...views.value, [key]: view }
55
73
  } catch {
56
- setErrored(blockId, true)
74
+ setErrored(key, true)
57
75
  } finally {
58
- inFlight.delete(blockId)
76
+ inFlight.delete(key)
59
77
  }
60
78
  })()
61
- inFlight.set(blockId, promise)
79
+ inFlight.set(key, promise)
62
80
  return promise
63
81
  }
64
82
 
83
+ /** Fetch the DEFAULT-branch spec view for a service frame block. */
84
+ async function load(blockId: string) {
85
+ return loadKeyed(serviceKey(blockId), (workspaceId) => api.getServiceSpec(workspaceId, blockId))
86
+ }
87
+
88
+ /** Fetch the spec view ONE RUN was judged against, from that run's own branch. */
89
+ async function loadForRun(executionId: string) {
90
+ return loadKeyed(runKey(executionId), (workspaceId) => api.getRunSpec(workspaceId, executionId))
91
+ }
92
+
65
93
  return {
66
94
  views,
67
95
  viewFor,
96
+ viewForRun,
68
97
  isLoading,
69
98
  isErrored,
70
99
  load,
100
+ loadForRun,
71
101
  }
72
102
  })
@@ -523,13 +523,14 @@ export function isConsensusEligibleKind(kind: string): boolean {
523
523
  }
524
524
 
525
525
  /**
526
- * Whether an agent kind is one of the Tester gate kinds (API or UI). Mirrors the backend
527
- * `isTesterKind`; used by the pipeline builder to surface the test quality-control companion
528
- * toggle only on Tester steps.
526
+ * Whether an agent kind is one of the Tester gate kinds (API or UI). Used by the pipeline builder
527
+ * to surface the test quality-control companion toggle only on Tester steps.
528
+ *
529
+ * Re-exported from `@cat-factory/contracts` rather than mirrored: it used to be a hand-written
530
+ * copy of the engine's rule with the two slugs spelled out as literals, which is the shape that
531
+ * silently stops matching the day a third tester kind ships.
529
532
  */
530
- export function isTesterKind(kind: string): boolean {
531
- return kind === 'tester-api' || kind === 'tester-ui'
532
- }
533
+ export { isTesterKind } from '@cat-factory/contracts'
533
534
 
534
535
  /**
535
536
  * Display metadata for the engine-driven "system" kinds — the gate/automation