@cat-factory/app 0.241.3 → 0.242.1

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.
@@ -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
  }
@@ -1,15 +1,8 @@
1
1
  <script setup lang="ts">
2
2
  import BoardCanvas from '~/components/board/BoardCanvas.vue'
3
3
  import SideBar from '~/components/layout/SideBar.vue'
4
- import BoardToolbar from '~/components/layout/BoardToolbar.vue'
5
- import SpendWarningBanner from '~/components/layout/SpendWarningBanner.vue'
6
- import ConnectionStatusBanner from '~/components/layout/ConnectionStatusBanner.vue'
4
+ import BoardTopOverlays from '~/components/layout/BoardTopOverlays.vue'
7
5
  import TranslationWarningBanner from '~/components/layout/TranslationWarningBanner.vue'
8
- import GitHubPatBanner from '~/components/layout/GitHubPatBanner.vue'
9
- import AiProvidersBanner from '~/components/layout/AiProvidersBanner.vue'
10
- import ProviderConfigBanner from '~/components/layout/ProviderConfigBanner.vue'
11
- import InfraSetupBanner from '~/components/layout/InfraSetupBanner.vue'
12
- import DefaultTestEnvBanner from '~/components/layout/DefaultTestEnvBanner.vue'
13
6
  // Always-mounted, fast-path surfaces (opened frequently during a run / board edits, or
14
7
  // store-driven so they must react from anywhere — kept eager for snappy open/close).
15
8
  import PipelineBuilder from '~/components/pipeline/PipelineBuilder.vue'
@@ -398,170 +391,138 @@ watch(
398
391
  </script>
399
392
 
400
393
  <template>
401
- <div class="flex h-screen w-screen overflow-hidden bg-slate-950 text-slate-100">
402
- <!-- Non-English locale warning (unofficial translation); slim strip above everything. -->
394
+ <!-- A COLUMN, so the translation strip below takes its own height instead of covering the
395
+ row: page chrome that overlays the app is how the board's top controls came to be
396
+ buried. Everything else lives in the row beneath it. -->
397
+ <div class="flex h-screen w-screen flex-col overflow-hidden bg-slate-950 text-slate-100">
398
+ <!-- Non-English locale warning (unofficial translation); slim full-width strip above
399
+ everything, in flow. -->
403
400
  <TranslationWarningBanner />
404
- <!-- Local-mode setup prompt (missing GitHub PAT); floats over whatever is shown below. -->
405
- <GitHubPatBanner />
406
- <!-- Stacked advisory banners: one click-through column so concurrent prompts never draw on
407
- top of each other (a fresh, unconfigured deployment can raise all three at once — no AI
408
- model + no runner pool + no storage). The wrapper is `pointer-events-none`; each banner
409
- re-enables pointer events on its own card, so the empty strip never intercepts clicks on
410
- the board chrome underneath.
411
- - AI-readiness (no usable model source, or default preset uses unavailable models).
412
- - Infrastructure provider (env/runner-pool wired but missing mandatory config).
413
- - Infra-setup (this deployment needs an executor / test env / storage the operator hasn't
414
- defined yet, so a class of agents can't run).
415
- - Default test environment (this BOARD has never chosen the provisioning mechanism its
416
- new services should default to). Last in the column: it asks for a convenience default,
417
- so it yields to the prompts about things that are outright broken. -->
418
- <div
419
- v-if="workspace.ready && !needsGitHubInstall && !githubProbePending"
420
- class="pointer-events-none absolute inset-x-0 top-0 z-40 flex flex-col items-center gap-2 px-4 pt-4"
421
- >
422
- <AiProvidersBanner />
423
- <ProviderConfigBanner />
424
- <InfraSetupBanner />
425
- <DefaultTestEnvBanner />
426
- </div>
427
-
428
- <!-- Resolving whether the GitHub App is installed, before we decide what to show. -->
429
- <div
430
- v-if="workspace.ready && githubProbePending"
431
- class="m-auto flex flex-col items-center gap-3 text-slate-400"
432
- >
433
- <UIcon name="i-lucide-loader" class="h-8 w-8 animate-spin" />
434
- <span class="text-sm">{{ $t('app.loading') }}</span>
435
- </div>
401
+ <div class="relative flex min-h-0 flex-1">
402
+ <!-- Resolving whether the GitHub App is installed, before we decide what to show. -->
403
+ <div
404
+ v-if="workspace.ready && githubProbePending"
405
+ class="m-auto flex flex-col items-center gap-3 text-slate-400"
406
+ >
407
+ <UIcon name="i-lucide-loader" class="h-8 w-8 animate-spin" />
408
+ <span class="text-sm">{{ $t('app.loading') }}</span>
409
+ </div>
436
410
 
437
- <!-- App enabled but not installed on this workspace: hard onboarding gate. -->
438
- <GitHubOnboarding v-else-if="workspace.ready && needsGitHubInstall" />
411
+ <!-- App enabled but not installed on this workspace: hard onboarding gate. -->
412
+ <GitHubOnboarding v-else-if="workspace.ready && needsGitHubInstall" />
439
413
 
440
- <template v-else-if="workspace.ready">
441
- <!-- Headless readiness marker for the e2e suite: reflects whether the real-time
442
- WebSocket is actually connected (and thus subscribed + resynced). A live spec must
443
- wait for this before driving a run, otherwise the run's first status events are
444
- broadcast to a not-yet-subscribed browser and missed, leaving the card stuck on a
445
- stale status until its assertion times out (the source of the e2e flakiness). Hidden
446
- and inert; no visual or behavioural effect. -->
447
- <span
448
- data-testid="workspace-stream"
449
- :data-connected="streamConnected ? 'true' : 'false'"
450
- hidden
451
- />
452
- <SideBar />
453
- <main class="relative min-w-0 flex-1">
454
- <BoardCanvas />
455
- <!-- Compact-viewport navbar trigger: the SideBar is an off-canvas drawer below
456
- lg, so surface a hamburger to open it. Hidden on lg+ (static sidebar). -->
457
- <!-- z-30 keeps the trigger above the centered BoardToolbar (z-20), whose
458
- max-width can otherwise reach this corner on the narrowest viewports. -->
459
- <UButton
460
- class="absolute start-3 top-3 z-30 lg:hidden"
461
- icon="i-lucide-menu"
462
- color="neutral"
463
- variant="soft"
464
- size="sm"
465
- :aria-label="ui.mobileNavOpen ? $t('nav.closeMenu') : $t('nav.openMenu')"
466
- data-testid="mobile-nav-toggle"
467
- @click="ui.toggleMobileNav()"
414
+ <template v-else-if="workspace.ready">
415
+ <!-- Headless readiness marker for the e2e suite: reflects whether the real-time
416
+ WebSocket is actually connected (and thus subscribed + resynced). A live spec must
417
+ wait for this before driving a run, otherwise the run's first status events are
418
+ broadcast to a not-yet-subscribed browser and missed, leaving the card stuck on a
419
+ stale status until its assertion times out (the source of the e2e flakiness). Hidden
420
+ and inert; no visual or behavioural effect. -->
421
+ <span
422
+ data-testid="workspace-stream"
423
+ :data-connected="streamConnected ? 'true' : 'false'"
424
+ hidden
468
425
  />
469
- <BoardToolbar />
470
- <SpendWarningBanner />
471
- <ConnectionStatusBanner
472
- :connected="streamConnected"
473
- :ever-connected="streamEverConnected"
474
- :connection-failed="streamConnectionFailed"
475
- />
476
- <InspectorPanel />
477
- <!-- Code-split focus view. The fade lives here (not inside the component) so the
478
- leave animation still plays when `focusBlockId` clears and the v-if unmounts
479
- the chunk — an inner Transition would be torn down before it could run. -->
480
- <Transition name="focus-fade">
481
- <BlockFocusView v-if="ui.focusBlockId" />
482
- </Transition>
483
- </main>
426
+ <SideBar />
427
+ <main class="relative min-w-0 flex-1">
428
+ <BoardCanvas />
429
+ <!-- Toolbar, nav trigger and every advisory banner, in ONE stacked region that owns
430
+ their placement, so no two of them can cover each other. -->
431
+ <BoardTopOverlays
432
+ :connected="streamConnected"
433
+ :ever-connected="streamEverConnected"
434
+ :connection-failed="streamConnectionFailed"
435
+ />
436
+ <InspectorPanel />
437
+ <!-- Code-split focus view. The fade lives here (not inside the component) so the
438
+ leave animation still plays when `focusBlockId` clears and the v-if unmounts
439
+ the chunk — an inner Transition would be torn down before it could run. -->
440
+ <Transition name="focus-fade">
441
+ <BlockFocusView v-if="ui.focusBlockId" />
442
+ </Transition>
443
+ </main>
484
444
 
485
- <!-- Always-mounted, fast-path surfaces. -->
486
- <PipelineBuilder />
487
- <DecisionModal />
488
- <AgentStepDetail />
489
- <StepResultViewHost />
490
- <!-- Consumer-contributed top-level overlays (extension slice D). Renders nothing until a
491
- consumer opens one via `ui.openOverlay` / `useAppOverlays().open(...)`. -->
492
- <AppOverlayHost />
493
- <AddTaskModal />
494
- <ReviewFrictionDialog v-if="ui.reviewFrictionContext" />
495
- <CreateInitiativeModal />
496
- <CommandBar />
497
- <PersonalCredentialModal />
498
- <ConfirmDialog />
499
- <KeyboardShortcutsHelp />
445
+ <!-- Always-mounted, fast-path surfaces. -->
446
+ <PipelineBuilder />
447
+ <DecisionModal />
448
+ <AgentStepDetail />
449
+ <StepResultViewHost />
450
+ <!-- Consumer-contributed top-level overlays (extension slice D). Renders nothing until a
451
+ consumer opens one via `ui.openOverlay` / `useAppOverlays().open(...)`. -->
452
+ <AppOverlayHost />
453
+ <AddTaskModal />
454
+ <ReviewFrictionDialog v-if="ui.reviewFrictionContext" />
455
+ <CreateInitiativeModal />
456
+ <CommandBar />
457
+ <PersonalCredentialModal />
458
+ <ConfirmDialog />
459
+ <KeyboardShortcutsHelp />
500
460
 
501
- <!-- Lazy panels: mounted only while their ui open-flag is set, so each loads on
502
- first open (its own chunk) rather than bloating the initial bundle. -->
503
- <TaskSourceConnectModal v-if="ui.taskConnect" />
504
- <TaskImportModal v-if="ui.taskImport" />
505
- <BugHuntModal v-if="ui.bugHunt" />
506
- <RecurringPipelineModal v-if="ui.addRecurringFrameId" />
507
- <ObservabilityPanel v-if="ui.observabilityInstanceId" />
508
- <OperatorDashboardPanel v-if="ui.operatorDashboardOpen" />
509
- <ReportsPanel v-if="ui.reportsOpen" />
510
- <KaizenPanel v-if="ui.kaizenScreenOpen" />
511
- <DocumentSourceConnectModal v-if="ui.documentConnect" />
512
- <DocumentImportModal v-if="ui.documentImport" />
513
- <DocumentTemplatesModal v-if="ui.documentTemplates" />
514
- <SpawnPreviewModal v-if="ui.spawnPreview" />
515
- <BootstrapModal v-if="ui.bootstrapOpen" />
516
- <AddServiceFromRepoModal v-if="ui.addServiceOpen" />
517
- <GitHubPanel v-if="ui.githubOpen" />
518
- <SlackPanel v-if="ui.slackOpen" />
519
- <FragmentLibraryPanel v-if="ui.fragmentLibraryOpen" />
520
- <FoundationalServicePanel v-if="ui.foundationalServicesOpen" />
521
- <PipelineHealthModal v-if="ui.pipelineHealthOpen" />
522
- <RiskPolicyHealthModal v-if="ui.riskPolicyHealthOpen" />
523
- <ModelPresetHealthModal v-if="ui.modelPresetHealthOpen" />
524
- <IntegrationsHub v-if="ui.integrationsOpen" />
525
- <ModelProvidersHub v-if="ui.modelProvidersOpen" />
526
- <PersonalSetupModal v-if="ui.personalSetupOpen" />
527
- <WorkspaceSettingsPanel v-if="ui.workspaceSettingsOpen" />
528
- <AccountSettingsPanel v-if="ui.accountSettingsOpen" />
529
- <ObservabilityConnectionPanel v-if="ui.observabilityConnectionOpen" />
530
- <ApiTokensPanel v-if="ui.apiTokensOpen" />
531
- <InfrastructureWindow v-if="ui.infrastructureOpen" />
532
- <EnvironmentSetupWizard v-if="ui.environmentWizardOpen" />
533
- <ModelConfigurationPanel v-if="ui.modelConfigOpen" />
534
- <LocalModelEndpointsPanel v-if="ui.localModelsOpen" />
535
- <SandboxPanel v-if="ui.sandboxOpen" />
536
- <UserSecretsSection v-if="ui.userSecretsOpen" />
537
- <OpenRouterCatalogPanel v-if="ui.openRouterOpen" />
538
- <VendorCredentialsModal v-if="ui.vendorCredentialsOpen" />
539
- <AiProviderOnboardingModal v-if="ui.aiProviderSetupOpen" />
540
- <AiPresetMismatchDialog v-if="ui.aiPresetMismatchOpen" />
541
- <TutorialPrompt v-if="tutorial.promptOpen" />
542
- <TutorialCatalogue v-if="tutorial.catalogueOpen" />
543
- <TutorialOverlay v-if="tutorial.touring" />
544
- <!-- Mounted off the PENDING id rather than off whether it may currently be SHOWN: the
545
- component holds a suppressed offer (a tour is running, a tutorial window is open) and
546
- renders it once the way is clear, which is the whole reason the offer survives the
547
- moment it was raised in. -->
548
- <TutorialNudge v-if="tutorial.pendingNudgeId" />
549
- </template>
461
+ <!-- Lazy panels: mounted only while their ui open-flag is set, so each loads on
462
+ first open (its own chunk) rather than bloating the initial bundle. -->
463
+ <TaskSourceConnectModal v-if="ui.taskConnect" />
464
+ <TaskImportModal v-if="ui.taskImport" />
465
+ <BugHuntModal v-if="ui.bugHunt" />
466
+ <RecurringPipelineModal v-if="ui.addRecurringFrameId" />
467
+ <ObservabilityPanel v-if="ui.observabilityInstanceId" />
468
+ <OperatorDashboardPanel v-if="ui.operatorDashboardOpen" />
469
+ <ReportsPanel v-if="ui.reportsOpen" />
470
+ <KaizenPanel v-if="ui.kaizenScreenOpen" />
471
+ <DocumentSourceConnectModal v-if="ui.documentConnect" />
472
+ <DocumentImportModal v-if="ui.documentImport" />
473
+ <DocumentTemplatesModal v-if="ui.documentTemplates" />
474
+ <SpawnPreviewModal v-if="ui.spawnPreview" />
475
+ <BootstrapModal v-if="ui.bootstrapOpen" />
476
+ <AddServiceFromRepoModal v-if="ui.addServiceOpen" />
477
+ <GitHubPanel v-if="ui.githubOpen" />
478
+ <SlackPanel v-if="ui.slackOpen" />
479
+ <FragmentLibraryPanel v-if="ui.fragmentLibraryOpen" />
480
+ <FoundationalServicePanel v-if="ui.foundationalServicesOpen" />
481
+ <PipelineHealthModal v-if="ui.pipelineHealthOpen" />
482
+ <RiskPolicyHealthModal v-if="ui.riskPolicyHealthOpen" />
483
+ <ModelPresetHealthModal v-if="ui.modelPresetHealthOpen" />
484
+ <IntegrationsHub v-if="ui.integrationsOpen" />
485
+ <ModelProvidersHub v-if="ui.modelProvidersOpen" />
486
+ <PersonalSetupModal v-if="ui.personalSetupOpen" />
487
+ <WorkspaceSettingsPanel v-if="ui.workspaceSettingsOpen" />
488
+ <AccountSettingsPanel v-if="ui.accountSettingsOpen" />
489
+ <ObservabilityConnectionPanel v-if="ui.observabilityConnectionOpen" />
490
+ <ApiTokensPanel v-if="ui.apiTokensOpen" />
491
+ <InfrastructureWindow v-if="ui.infrastructureOpen" />
492
+ <EnvironmentSetupWizard v-if="ui.environmentWizardOpen" />
493
+ <ModelConfigurationPanel v-if="ui.modelConfigOpen" />
494
+ <LocalModelEndpointsPanel v-if="ui.localModelsOpen" />
495
+ <SandboxPanel v-if="ui.sandboxOpen" />
496
+ <UserSecretsSection v-if="ui.userSecretsOpen" />
497
+ <OpenRouterCatalogPanel v-if="ui.openRouterOpen" />
498
+ <VendorCredentialsModal v-if="ui.vendorCredentialsOpen" />
499
+ <AiProviderOnboardingModal v-if="ui.aiProviderSetupOpen" />
500
+ <AiPresetMismatchDialog v-if="ui.aiPresetMismatchOpen" />
501
+ <TutorialPrompt v-if="tutorial.promptOpen" />
502
+ <TutorialCatalogue v-if="tutorial.catalogueOpen" />
503
+ <TutorialOverlay v-if="tutorial.touring" />
504
+ <!-- Mounted off the PENDING id rather than off whether it may currently be SHOWN: the
505
+ component holds a suppressed offer (a tour is running, a tutorial window is open) and
506
+ renders it once the way is clear, which is the whole reason the offer survives the
507
+ moment it was raised in. -->
508
+ <TutorialNudge v-if="tutorial.pendingNudgeId" />
509
+ </template>
550
510
 
551
- <!-- Backend unreachable / bootstrap failed -->
552
- <div v-else-if="workspace.error" class="m-auto max-w-md p-8 text-center">
553
- <UIcon name="i-lucide-plug-zap" class="mx-auto mb-3 h-10 w-10 text-amber-400" />
554
- <h1 class="mb-1 text-lg font-semibold">{{ $t('app.backendUnreachable') }}</h1>
555
- <p class="mb-4 text-sm text-slate-400">{{ workspace.error }}</p>
556
- <UButton color="primary" icon="i-lucide-rotate-ccw" @click="workspace.init()">
557
- {{ $t('common.retry') }}
558
- </UButton>
559
- </div>
511
+ <!-- Backend unreachable / bootstrap failed -->
512
+ <div v-else-if="workspace.error" class="m-auto max-w-md p-8 text-center">
513
+ <UIcon name="i-lucide-plug-zap" class="mx-auto mb-3 h-10 w-10 text-amber-400" />
514
+ <h1 class="mb-1 text-lg font-semibold">{{ $t('app.backendUnreachable') }}</h1>
515
+ <p class="mb-4 text-sm text-slate-400">{{ workspace.error }}</p>
516
+ <UButton color="primary" icon="i-lucide-rotate-ccw" @click="workspace.init()">
517
+ {{ $t('common.retry') }}
518
+ </UButton>
519
+ </div>
560
520
 
561
- <!-- Initial load -->
562
- <div v-else class="m-auto flex flex-col items-center gap-3 text-slate-400">
563
- <UIcon name="i-lucide-loader" class="h-8 w-8 animate-spin" />
564
- <span class="text-sm">{{ $t('app.loadingBoard') }}</span>
521
+ <!-- Initial load -->
522
+ <div v-else class="m-auto flex flex-col items-center gap-3 text-slate-400">
523
+ <UIcon name="i-lucide-loader" class="h-8 w-8 animate-spin" />
524
+ <span class="text-sm">{{ $t('app.loadingBoard') }}</span>
525
+ </div>
565
526
  </div>
566
527
  </div>
567
528
  </template>
@@ -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