@cat-factory/app 0.190.0 → 0.191.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,6 +54,16 @@ over the WebSocket. How that sync works is written up in
54
54
  | `types/` | TypeScript domain unions (`domain.ts`) and wire types mirroring the contracts. |
55
55
  | `utils/` | Small pure helpers. |
56
56
 
57
+ ### Always import a layer component explicitly
58
+
59
+ **Import a component under `components/` by path before using it in a template.** Do not lean on Nuxt's auto-registration. This layer sets no `components` config, so the default `pathPrefix: true` applies and a component is registered under its path-prefixed name: `components/panels/StepEffortReport.vue` becomes `PanelsStepEffortReport`, and a bare `<StepEffortReport>` matches nothing.
60
+
61
+ Some bare tags do work, which is exactly what makes this worth writing down. Nuxt drops a directory segment the filename already repeats, so `pipeline/PipelinePicker.vue` registers as `PipelinePicker` and resolves bare, while `pipeline/AgentKindIcon.vue` in the same folder registers as `PipelineAgentKindIcon` and does not. Whether a tag resolves therefore depends on a coincidence between a folder name and a filename, and renaming either end breaks the tag with no error. An explicit import does not care.
62
+
63
+ The failure is silent, which is why this is a rule rather than a preference. An unresolved tag warns in dev and then renders nothing, so a built SPA has a hole where the component should be. Nothing catches it: not typecheck, not the unit tests, not the e2e suite, and not the user, who reads it as a backend returning no data. Seven components had shipped this way.
64
+
65
+ `scripts/check-component-imports.mjs` enforces it (CI's `repo-guards` job). If a panel section is missing and the data looks right, check the import first.
66
+
57
67
  ## Interface modes (basic / advanced)
58
68
 
59
69
  The SPA renders at one of two **interface tiers**. `basic` (the default) is the everyday
@@ -17,6 +17,7 @@
17
17
  import { computed } from 'vue'
18
18
  import { JourneyHost, JourneyOutlet } from '@modular-vue/journeys'
19
19
  import { environmentSetupHandle } from '~/modular/journeys/environmentSetup'
20
+ import EnvSetupStepper from '~/components/environments/EnvSetupStepper.vue'
20
21
 
21
22
  const ui = useUiStore()
22
23
  const { t } = useI18n()
@@ -22,17 +22,36 @@
22
22
  // account that also has no storage — an accepted trade-off (the setting stays reachable from
23
23
  // account settings, and the SESSION dismissal re-nags on the next load regardless).
24
24
  //
25
- // Freshness note: `infraSetup` is a server projection recomputed only on snapshot (re)load, so a
26
- // banner clears on the next board load after the operator configures the area via the deep-link,
27
- // not the instant the config panel saves.
25
+ // Two KINDS of card share this surface, and the difference drives the dismissal fork:
26
+ // - a setup gap (`not_defined`) is a stable operator decision, so both dismissals are offered;
27
+ // - an OUTAGE (`unreachable` — configured, but the reachability watcher's live probe can't reach
28
+ // it) is a health state, so ONLY the session dismissal is offered. A permanent "don't notify me
29
+ // again" on a transient failure would let one click silence every future outage, and the outage
30
+ // that matters is always the next one. `isInfraSetupHealthStatus` (contracts) is the single
31
+ // definition both this component and the store's re-nag logic key off.
32
+ //
33
+ // BOTH dismissals are keyed by the CLAIM, never by the area alone, because the two cards say
34
+ // different things about the same area: silencing "you haven't configured this" must not also
35
+ // silence the outage card raised after the operator configures it and the provider then dies.
36
+ //
37
+ // Freshness note: a setup gap clears on the next board load after the operator configures the area
38
+ // via the deep-link, not the instant the config panel saves — the projection is recomputed on
39
+ // snapshot (re)load. An OUTAGE is different: it arrives and clears live, pushed as an `infraSetup`
40
+ // event by the watcher and applied by `workspace.patchInfraSetup`.
28
41
  import { useLocalStorage } from '@vueuse/core'
29
42
  import { computed } from 'vue'
30
43
  // The localStorage key holding the permanent per-user dismissals lives in `@cat-factory/contracts`
31
44
  // (a dependency-free package the SPA and the e2e suite both import), so the key + shape can't drift
32
45
  // between this component and the e2e seed in `backend/internal/e2e/tests/helpers.ts` (`pinWorkspace`).
33
- import { INFRA_SETUP_DISMISSED_STORAGE_KEY } from '@cat-factory/contracts'
46
+ import {
47
+ INFRA_SETUP_DISMISSED_STORAGE_KEY,
48
+ type InfraSetupProbedArea,
49
+ isInfraSetupHealthStatus,
50
+ isInfraSetupProbedArea,
51
+ } from '@cat-factory/contracts'
34
52
  import type { DropdownMenuItem } from '@nuxt/ui'
35
- import type { InfraSetupArea } from '~/types/domain'
53
+ import type { InfraSetupArea, InfraSetupStatus } from '~/types/domain'
54
+ import { infraSetupDismissalKey, type InfraSetupCardKind } from '~/utils/infraSetup'
36
55
 
37
56
  const { t } = useI18n()
38
57
  const ui = useUiStore()
@@ -75,6 +94,18 @@ const AREA_META: Record<
75
94
  },
76
95
  }
77
96
 
97
+ // Outage titles are keyed over the PROBED areas only, not over every area: `binaryStorage` has no
98
+ // reachability probe, so an entry for it would be copy that ships in every locale and can never
99
+ // render. Each probed area carries its OWN title rather than interpolating the area name into a
100
+ // shared one — a predicate adjective ("is unreachable") agrees with its subject's gender in most of
101
+ // the locales we ship, so `{area} is unreachable` cannot be translated correctly as one string. The
102
+ // outage BODY and action ARE shared, because neither refers back to the area; each locale's body
103
+ // opens with its own fixed subject noun for exactly that reason.
104
+ const UNREACHABLE_TITLE_KEYS: Record<InfraSetupProbedArea, string> = {
105
+ agentExecutor: 'layout.infraSetupBanner.agentExecutor.unreachableTitle',
106
+ ephemeralEnvironments: 'layout.infraSetupBanner.ephemeralEnvironments.unreachableTitle',
107
+ }
108
+
78
109
  // Permanent, per-user dismissals: one shared localStorage record keyed BY user id (so it's
79
110
  // scoped to the signed-in user and doesn't leak across accounts on a shared browser). No
80
111
  // signed-in user (local/auth-off single-user mode) ⇒ the `local` bucket.
@@ -94,30 +125,64 @@ function dismissPermanently(area: InfraSetupArea) {
94
125
  }
95
126
  }
96
127
 
97
- const visible = computed<InfraSetupArea[]>(() => {
128
+ /** One rendered card: the area plus which CLAIM it is making about it. */
129
+ interface AreaCard {
130
+ area: InfraSetupArea
131
+ /** `outage` for a live-health status — drives the copy, the severity styling and the dismissals. */
132
+ kind: InfraSetupCardKind
133
+ /** The failing probe's reason, when this session saw the transition that raised the card. */
134
+ detail?: string
135
+ }
136
+
137
+ const visible = computed<AreaCard[]>(() => {
98
138
  const status = workspace.infraSetup
99
139
  if (!status) return []
100
- return AREAS.filter(
101
- (area) =>
102
- status[area] === 'not_defined' &&
103
- !ui.infraSetupSessionDismissed.includes(area) &&
104
- !dismissedForUser.value.includes(area),
105
- )
140
+ return AREAS.filter((area) => {
141
+ const kind = cardKind(status[area])
142
+ if (!kind) return false
143
+ // Both dismissals are keyed by the CLAIM, not by the area: silencing "you haven't configured
144
+ // this" must not also silence "you configured it and it is now down". The permanent dismissal
145
+ // only ever covers a setup gap (`dismissPermanently` is offered nowhere else).
146
+ if (ui.infraSetupSessionDismissed.includes(infraSetupDismissalKey(area, kind))) return false
147
+ return !(kind === 'setup' && dismissedForUser.value.includes(area))
148
+ }).map((area) => ({
149
+ area,
150
+ kind: cardKind(status[area])!,
151
+ ...(workspace.infraSetupDetails[area] ? { detail: workspace.infraSetupDetails[area] } : {}),
152
+ }))
106
153
  })
107
154
 
108
- // The dismiss dropdown: the product wants the user asked WHICH kind of dismissal on close.
109
- function dismissMenu(area: InfraSetupArea): DropdownMenuItem[][] {
155
+ /** Which card an area's status raises, or null when it raises none (`configured`/`not_applicable`). */
156
+ function cardKind(status: InfraSetupStatus): InfraSetupCardKind | null {
157
+ if (isInfraSetupHealthStatus(status)) return 'outage'
158
+ return status === 'not_defined' ? 'setup' : null
159
+ }
160
+
161
+ /** The card's title key: the per-area outage title for an outage, else the setup-gap title. */
162
+ function titleKey(card: AreaCard): string {
163
+ return card.kind === 'outage' && isInfraSetupProbedArea(card.area)
164
+ ? UNREACHABLE_TITLE_KEYS[card.area]
165
+ : AREA_META[card.area].titleKey
166
+ }
167
+
168
+ /**
169
+ * The dismiss dropdown: the product wants the user asked WHICH kind of dismissal on close. An
170
+ * outage offers the session option ONLY — see the fork note at the top of this file.
171
+ */
172
+ function dismissMenu(card: AreaCard): DropdownMenuItem[][] {
173
+ const session = {
174
+ label: t('layout.infraSetupBanner.dismiss.session'),
175
+ icon: 'i-lucide-clock',
176
+ onSelect: () => ui.dismissInfraSetupForSession(card.area, card.kind),
177
+ }
178
+ if (card.kind === 'outage') return [[session]]
110
179
  return [
111
180
  [
112
- {
113
- label: t('layout.infraSetupBanner.dismiss.session'),
114
- icon: 'i-lucide-clock',
115
- onSelect: () => ui.dismissInfraSetupForSession(area),
116
- },
181
+ session,
117
182
  {
118
183
  label: t('layout.infraSetupBanner.dismiss.permanent'),
119
184
  icon: 'i-lucide-bell-off',
120
- onSelect: () => dismissPermanently(area),
185
+ onSelect: () => dismissPermanently(card.area),
121
186
  },
122
187
  ],
123
188
  ]
@@ -135,42 +200,81 @@ function dismissMenu(area: InfraSetupArea): DropdownMenuItem[][] {
135
200
  role="status"
136
201
  aria-live="polite"
137
202
  >
203
+ <!-- An OUTAGE reads red, a setup gap amber: one is something breaking now, the other is
204
+ something never switched on, and a reader has to be able to tell at a glance. -->
138
205
  <div
139
- v-for="area in visible"
140
- :key="area"
141
- class="pointer-events-auto w-full max-w-3xl rounded-2xl border-2 border-amber-500/70 bg-amber-950/95 p-5 shadow-2xl backdrop-blur"
142
- :data-testid="`infra-setup-banner-${area}`"
206
+ v-for="card in visible"
207
+ :key="card.area"
208
+ class="pointer-events-auto w-full max-w-3xl rounded-2xl border-2 p-5 shadow-2xl backdrop-blur"
209
+ :class="
210
+ card.kind === 'outage'
211
+ ? 'border-red-500/70 bg-red-950/95'
212
+ : 'border-amber-500/70 bg-amber-950/95'
213
+ "
214
+ :data-testid="`infra-setup-banner-${card.area}`"
215
+ :data-infra-status="card.kind === 'outage' ? 'unreachable' : 'not_defined'"
143
216
  >
144
217
  <div class="flex items-start gap-4">
145
- <UIcon :name="AREA_META[area].icon" class="mt-0.5 h-9 w-9 shrink-0 text-amber-400" />
218
+ <UIcon
219
+ :name="card.kind === 'outage' ? 'i-lucide-plug-zap' : AREA_META[card.area].icon"
220
+ class="mt-0.5 h-9 w-9 shrink-0"
221
+ :class="card.kind === 'outage' ? 'text-red-400' : 'text-amber-400'"
222
+ />
146
223
  <div class="min-w-0 flex-1">
147
224
  <div class="flex items-start justify-between gap-3">
148
- <h2 class="text-lg font-semibold text-amber-100">
149
- {{ t(AREA_META[area].titleKey) }}
225
+ <h2
226
+ class="text-lg font-semibold"
227
+ :class="card.kind === 'outage' ? 'text-red-100' : 'text-amber-100'"
228
+ >
229
+ {{ t(titleKey(card)) }}
150
230
  </h2>
151
- <UDropdownMenu :items="dismissMenu(area)" :content="{ align: 'end' }">
231
+ <UDropdownMenu :items="dismissMenu(card)" :content="{ align: 'end' }">
152
232
  <UButton
153
233
  color="neutral"
154
234
  variant="ghost"
155
235
  size="xs"
156
236
  icon="i-lucide-x"
157
237
  :aria-label="t('common.close')"
158
- :data-testid="`infra-setup-dismiss-${area}`"
238
+ :data-testid="`infra-setup-dismiss-${card.area}`"
159
239
  />
160
240
  </UDropdownMenu>
161
241
  </div>
162
- <p class="mt-1 text-sm text-amber-200/90">
163
- {{ t(AREA_META[area].bodyKey) }}
242
+ <p
243
+ class="mt-1 text-sm"
244
+ :class="card.kind === 'outage' ? 'text-red-200/90' : 'text-amber-200/90'"
245
+ >
246
+ {{
247
+ card.kind === 'outage'
248
+ ? t('layout.infraSetupBanner.unreachable.body')
249
+ : t(AREA_META[card.area].bodyKey)
250
+ }}
251
+ </p>
252
+ <!-- The failing probe's OWN reason, when this session saw the transition that raised the
253
+ card: a refused connection, a rejected token and a timeout need different fixes, and
254
+ the generic body cannot tell them apart. Absent after a reload (it rides the live
255
+ event, never the deduped notification), so it is an addition to the copy above and
256
+ never the only thing that explains the card. -->
257
+ <p
258
+ v-if="card.detail"
259
+ class="mt-2 truncate font-mono text-xs text-red-300/80"
260
+ :title="card.detail"
261
+ :data-testid="`infra-setup-detail-${card.area}`"
262
+ >
263
+ {{ t('layout.infraSetupBanner.unreachable.reason', { detail: card.detail }) }}
164
264
  </p>
165
265
  <div class="mt-4">
166
266
  <UButton
167
- color="warning"
267
+ :color="card.kind === 'outage' ? 'error' : 'warning'"
168
268
  variant="solid"
169
269
  icon="i-lucide-settings"
170
- :data-testid="`infra-setup-configure-${area}`"
171
- @click="AREA_META[area].onConfigure()"
270
+ :data-testid="`infra-setup-configure-${card.area}`"
271
+ @click="AREA_META[card.area].onConfigure()"
172
272
  >
173
- {{ t(AREA_META[area].actionKey) }}
273
+ {{
274
+ card.kind === 'outage'
275
+ ? t('layout.infraSetupBanner.unreachable.action')
276
+ : t(AREA_META[card.area].actionKey)
277
+ }}
174
278
  </UButton>
175
279
  </div>
176
280
  </div>
@@ -88,6 +88,10 @@ const META: Record<Notification['type'], { icon: string; color: Accent }> = {
88
88
  // sealed). Not block-scoped; "act" drops the listed stale ciphertexts so they can be re-entered
89
89
  // (or restore the previous key to recover them instead).
90
90
  key_drift: { icon: 'i-lucide-key-round', color: 'error' },
91
+ // A configured infrastructure connection stopped answering its live probe. Not block-scoped and
92
+ // nothing to act on from here — the fix is on the provider's side, and the card clears itself when
93
+ // the reachability watcher sees it answer again; "act" just marks it read.
94
+ infra_unreachable: { icon: 'i-lucide-plug-zap', color: 'error' },
91
95
  }
92
96
 
93
97
  // Per-type primary-action label. An exhaustive Record keyed off the notification
@@ -114,6 +118,7 @@ const ACTION_KEYS: Record<Notification['type'], string> = {
114
118
  platform_health: 'layout.notifications.action.platform_health',
115
119
  budget_paused: 'layout.notifications.action.budget_paused',
116
120
  key_drift: 'layout.notifications.action.key_drift',
121
+ infra_unreachable: 'layout.notifications.action.infra_unreachable',
117
122
  }
118
123
 
119
124
  /** The localized primary-action label for a notification (te()-guarded against a
@@ -6,6 +6,8 @@ import { agentKindMeta } from '~/utils/catalog'
6
6
  import StepRestartControl from '~/components/panels/StepRestartControl.vue'
7
7
  import StepMetadataCard from '~/components/panels/StepMetadataCard.vue'
8
8
  import StepTestReport from '~/components/panels/StepTestReport.vue'
9
+ import StepEffortReport from '~/components/panels/StepEffortReport.vue'
10
+ import StepFragmentAdherence from '~/components/panels/StepFragmentAdherence.vue'
9
11
  import EnvironmentStatusPanel from '~/components/environments/EnvironmentStatusPanel.vue'
10
12
  import FrontendBindingsResolved from '~/components/panels/inspector/FrontendBindingsResolved.vue'
11
13
  import { UI_TESTER_AGENT_KIND } from '@cat-factory/contracts'
@@ -16,6 +16,7 @@ import {
16
16
  segmentPct,
17
17
  trendMagnitude,
18
18
  } from './ReportsPanel.logic'
19
+ import ReportsSpendBreakdown from '~/components/panels/ReportsSpendBreakdown.vue'
19
20
 
20
21
  // Reports: cross-cutting usage analytics for the active account — where the spend and the
21
22
  // work actually go. Spend per model and agent kind, spend + run activity per workspace /
@@ -8,6 +8,7 @@ import { showOverrideField } from '~/utils/uiMode'
8
8
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
9
9
  import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
10
10
  import TaskAprioriBranches from '~/components/panels/inspector/TaskAprioriBranches.vue'
11
+ import DocReferenceRepos from '~/components/panels/inspector/DocReferenceRepos.vue'
11
12
 
12
13
  const props = defineProps<{ block: Block }>()
13
14
 
@@ -12,6 +12,7 @@ import { computed } from 'vue'
12
12
  import type { Pipeline } from '~/types/domain'
13
13
  import { agentKindMeta } from '~/utils/catalog'
14
14
  import { pipelineDisplaySteps, pipelineGateCount } from '~/utils/pipeline'
15
+ import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
15
16
 
16
17
  const props = defineProps<{ pipeline: Pipeline }>()
17
18
  const { t } = useI18n()
@@ -24,6 +24,7 @@ import { subtaskIconClass } from '~/utils/pipelineRender'
24
24
  import { activeChunkLabels, chunkReviewPercent, hasNoSlicePlan } from '~/utils/prReviewProgress'
25
25
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
26
26
  import StepRunMeta from '~/components/panels/StepRunMeta.vue'
27
+ import StepFragmentAdherence from '~/components/panels/StepFragmentAdherence.vue'
27
28
 
28
29
  const execution = useExecutionStore()
29
30
  const board = useBoardStore()
@@ -12,6 +12,7 @@ import {
12
12
  RISK_POLICY_CEILING_FIELD,
13
13
  type RiskPolicyAxis,
14
14
  } from '~/utils/riskPolicy'
15
+ import MergeClassRulesEditor from '~/components/settings/MergeClassRulesEditor.vue'
15
16
 
16
17
  const { t } = useI18n()
17
18
 
@@ -43,6 +43,7 @@ const ROUTABLE = computed<{ type: NotificationType; label: string }[]>(() => [
43
43
  { type: 'pr_review_ready', label: t('slack.routable.pr_review_ready') },
44
44
  { type: 'initiative', label: t('slack.routable.initiative') },
45
45
  { type: 'platform_health', label: t('slack.routable.platform_health') },
46
+ { type: 'infra_unreachable', label: t('slack.routable.infra_unreachable') },
46
47
  ])
47
48
 
48
49
  /** Notification-role options for a mapped member (drives who gets @-mentioned). */
@@ -75,6 +76,7 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
75
76
  budget_paused: { enabled: false, channel: '' },
76
77
  // In-app only (not in ROUTABLE), but the map is exhaustive over the type.
77
78
  key_drift: { enabled: false, channel: '' },
79
+ infra_unreachable: { enabled: false, channel: '' },
78
80
  })
79
81
  const mentionsEnabled = ref(false)
80
82
  // Editable member rows carry a client-only stable `uid` (see `slackMemberMapping`) so
@@ -0,0 +1,50 @@
1
+ import type { ApiContract } from '@toad-contracts/core'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { type SendParams, withoutUndefinedQueryParams } from '~/composables/api/client'
4
+
5
+ // An omitted optional query param used to reach the server as `key=`: the contract client
6
+ // serialises with `fast-querystring`, whose `stringify({ blockId: undefined })` is `'blockId='`,
7
+ // and request validation waves the key through because `v.optional(...)` accepts `undefined`. On
8
+ // `listTasksContract`, whose `blockId` carries a `minLength(1)`, that made every unscoped
9
+ // `listTasks()` a guaranteed 400. These lock the strip in.
10
+
11
+ // The helper is contract-generic and only ever reads `queryParams`, so the cases below describe
12
+ // request params structurally rather than picking a real contract per shape.
13
+ type Params = Record<string, unknown>
14
+ const strip = (params: Params): Params =>
15
+ withoutUndefinedQueryParams(params as SendParams<ApiContract>) as Params
16
+
17
+ describe('withoutUndefinedQueryParams', () => {
18
+ it('drops undefined-valued query keys so they never reach the query string', () => {
19
+ const out = strip({ pathPrefix: '/workspaces/ws_1', queryParams: { blockId: undefined } })
20
+ expect(out.queryParams).toEqual({})
21
+ })
22
+
23
+ // The guard against "fix" it with a falsy check: 0, false and '' are all values a caller
24
+ // deliberately sent, and only `undefined` means absent.
25
+ it('keeps defined values, including falsy ones a caller meant to send', () => {
26
+ const out = strip({ queryParams: { blockId: 'blk_1', page: 0, all: false, q: '' } })
27
+ expect(out.queryParams).toEqual({ blockId: 'blk_1', page: 0, all: false, q: '' })
28
+ })
29
+
30
+ it('drops only the undefined keys from a mixed set', () => {
31
+ const out = strip({ queryParams: { window: '7d', workspaceId: undefined } })
32
+ expect(out.queryParams).toEqual({ window: '7d' })
33
+ })
34
+
35
+ it('leaves other request params untouched', () => {
36
+ const params = {
37
+ pathPrefix: '/workspaces/ws_1',
38
+ pathParams: { source: 'jira' },
39
+ body: { a: 1 },
40
+ }
41
+ expect(strip(params)).toEqual(params)
42
+ })
43
+
44
+ it('returns the same object when there is nothing to strip', () => {
45
+ const params = { queryParams: { blockId: 'blk_1' } }
46
+ expect(strip(params)).toBe(params)
47
+ const noQuery = { pathPrefix: '/workspaces/ws_1' }
48
+ expect(strip(noQuery)).toBe(noQuery)
49
+ })
50
+ })
@@ -71,6 +71,31 @@ export function createApiClient(): WretchInstance {
71
71
  ])
72
72
  }
73
73
 
74
+ /**
75
+ * Drop query keys whose value is `undefined`, so an omitted optional param is absent from the
76
+ * query string instead of present-but-empty.
77
+ *
78
+ * `sendByApiContract` serialises with `fast-querystring`, and `stringify({ blockId: undefined })`
79
+ * is `'blockId='`. Request validation does not catch it, because `v.optional(...)` accepts
80
+ * `undefined` and the key only becomes empty on the way out. The server then validates the parsed
81
+ * `''`, so any optional param carrying a `minLength(1)` rejects the whole request with a 400. That
82
+ * is what made an unscoped `listTasks()` uncallable. Params with no length check are luckier but
83
+ * still wrong: the handler reads `''` where it asked for absence.
84
+ *
85
+ * Stripping at this one chokepoint fixes every contract at once, which is the point. Writing
86
+ * `queryParams: { foo }` for an optional `foo` is the obvious thing to write and it should work,
87
+ * rather than each call site remembering to spread the key in conditionally.
88
+ */
89
+ export function withoutUndefinedQueryParams<T extends ApiContract>(
90
+ params: SendParams<T>,
91
+ ): SendParams<T> {
92
+ const query = (params as { queryParams?: Record<string, unknown> }).queryParams
93
+ if (!query) return params
94
+ const present = Object.entries(query).filter(([, value]) => value !== undefined)
95
+ if (present.length === Object.keys(query).length) return params
96
+ return { ...params, queryParams: Object.fromEntries(present) }
97
+ }
98
+
74
99
  /**
75
100
  * Send a contract request and unwrap to the success body (or throw the typed error).
76
101
  * The public signature preserves per-contract inference for callers; inside,
@@ -82,7 +107,7 @@ export async function sendContract<T extends ApiContract>(
82
107
  contract: T,
83
108
  params: SendParams<T>,
84
109
  ): Promise<SuccessBodyOf<T>> {
85
- const outcome = await sendByApiContract(client, contract, params)
110
+ const outcome = await sendByApiContract(client, contract, withoutUndefinedQueryParams(params))
86
111
  if (outcome.error) {
87
112
  const error = outcome.error
88
113
  // A contract-declared non-2xx is reported as a plain `{ statusCode, headers, body }`
@@ -111,6 +111,12 @@ export function useWorkspaceStream() {
111
111
  // inspector's "Test environment creation" control shows the live stage + final
112
112
  // outcome in place without a refetch. No board block.
113
113
  environmentTest.upsert(event.run)
114
+ } else if (event.type === 'infraSetup') {
115
+ // The reachability watcher found a configured infrastructure area dead (or answering again) —
116
+ // patch that one area so the setup banner appears/clears immediately. A full refresh would
117
+ // pay the whole snapshot aggregate for a one-field delta, and the projection the snapshot
118
+ // recomputes already folds the same recorded state.
119
+ workspace.patchInfraSetup(event.area, event.status, event.detail)
114
120
  } else if (event.type === 'notification') {
115
121
  // A PR needs a merge decision, a pipeline finished, or CI gave up — patch the
116
122
  // inbox + per-block badge in place (resolved ones drop out of the inbox).
@@ -2,6 +2,11 @@ import { ref } from 'vue'
2
2
  import type { DocumentSourceKind, InfraSetupArea, TaskSourceKind } from '~/types/domain'
3
3
  import type { InfrastructureTab, ProviderConnectionKind } from '~/types/providerConnections'
4
4
  import type { PendingContext } from '~/composables/useContextLinking'
5
+ import {
6
+ infraSetupDismissalKey,
7
+ type InfraSetupCardKind,
8
+ type InfraSetupDismissalKey,
9
+ } from '~/utils/infraSetup'
5
10
  import {
6
11
  DEFAULT_PROVISION_DEEP_LINK_PARAM,
7
12
  DEFAULT_PROVISION_DEEP_LINK_VALUE,
@@ -820,18 +825,35 @@ function createAiOnboardingModals() {
820
825
  const aiSetupDismissed = ref(false)
821
826
  const aiPresetDismissed = ref(false)
822
827
 
823
- // Infra-setup banner: per-SESSION dismissals, one flag per area, cleared on workspace switch
824
- // exactly like the AI-onboarding flags (a dismissal in one workspace must not suppress the
825
- // independent prompt for another). The PERMANENT "don't notify me again" dismissal is per-USER
826
- // and persists in localStorage from the banner component; this only covers "hide for now".
827
- const infraSetupSessionDismissed = ref<InfraSetupArea[]>([])
828
- function dismissInfraSetupForSession(area: InfraSetupArea) {
829
- if (!infraSetupSessionDismissed.value.includes(area))
830
- infraSetupSessionDismissed.value = [...infraSetupSessionDismissed.value, area]
828
+ // Infra-setup banner: per-SESSION dismissals, cleared on workspace switch exactly like the
829
+ // AI-onboarding flags (a dismissal in one workspace must not suppress the independent prompt for
830
+ // another). The PERMANENT "don't notify me again" dismissal is per-USER and persists in
831
+ // localStorage from the banner component; this only covers "hide for now".
832
+ //
833
+ // Keyed by area AND KIND, not by area alone: the two cards an area can raise are different
834
+ // claims. Dismissing "you haven't configured this" for the session must not also silence the
835
+ // OUTAGE card that appears after the operator configures it and the provider then dies — the same
836
+ // asymmetry that makes the permanent dismissal setup-gap-only, one tier down.
837
+ const infraSetupSessionDismissed = ref<InfraSetupDismissalKey[]>([])
838
+ function dismissInfraSetupForSession(area: InfraSetupArea, kind: InfraSetupCardKind) {
839
+ const key = infraSetupDismissalKey(area, kind)
840
+ if (!infraSetupSessionDismissed.value.includes(key))
841
+ infraSetupSessionDismissed.value = [...infraSetupSessionDismissed.value, key]
831
842
  }
832
843
  function resetInfraSetupDismissals() {
833
844
  infraSetupSessionDismissed.value = []
834
845
  }
846
+ /**
847
+ * Drop an area's OUTAGE session dismissal — called when that area RECOVERS, so a transient health
848
+ * state (`unreachable`) re-nags the next time it fails. Without it, "hide for now" on one outage
849
+ * would quietly cover every later outage for the rest of the session, which is precisely the
850
+ * semantics `isInfraSetupHealthStatus` exists to keep away from a health state. The area's
851
+ * setup-gap dismissal is left alone: recovery says nothing about that claim.
852
+ */
853
+ function clearInfraSetupSessionDismissal(area: InfraSetupArea) {
854
+ const key = infraSetupDismissalKey(area, 'outage')
855
+ infraSetupSessionDismissed.value = infraSetupSessionDismissed.value.filter((k) => k !== key)
856
+ }
835
857
 
836
858
  // Default-test-environment banner: a single per-SESSION dismissal, cleared on workspace switch
837
859
  // like the flags above. There is deliberately no PERMANENT dismissal here (unlike the
@@ -889,6 +911,7 @@ function createAiOnboardingModals() {
889
911
  infraSetupSessionDismissed,
890
912
  dismissInfraSetupForSession,
891
913
  resetInfraSetupDismissals,
914
+ clearInfraSetupSessionDismissal,
892
915
  defaultProvisionDismissed,
893
916
  dismissDefaultProvision,
894
917
  resetDefaultProvisionDismissal,
@@ -0,0 +1,77 @@
1
+ import { ref } from 'vue'
2
+ import { applyInfraSetupTransition, isInfraSetupHealthStatus } from '@cat-factory/contracts'
3
+ import type { InfraSetup, InfraSetupArea, InfraSetupStatus } from '~/types/domain'
4
+
5
+ // The workspace store's infra-setup slice: the per-area setup/health projection the setup banner
6
+ // renders, and the live `infraSetup` event patch the reachability watcher pushes into it. Its own
7
+ // collaborator rather than more lines in the store body, because it is the one slice with RULES
8
+ // (which prior state a probe verdict may overwrite, and what a recovery does to a dismissal) instead
9
+ // of a plain assign-from-snapshot.
10
+
11
+ /**
12
+ * Create the infra-setup slice. `hydrate` takes the authoritative projection off a snapshot;
13
+ * `patchInfraSetup` applies one live transition.
14
+ */
15
+ export function createInfraSetupState() {
16
+ /**
17
+ * Per-area infrastructure-setup status (ephemeral environments / agent executor / binary
18
+ * storage) from the snapshot, driving the infra-setup banner. Null on an older backend that
19
+ * doesn't compute it (⇒ no banner).
20
+ */
21
+ const infraSetup = ref<InfraSetup | null>(null)
22
+ /**
23
+ * The failing probe's own operator-facing reason per area ("connect ECONNREFUSED …", "HTTP 401"),
24
+ * from the live `infraSetup` event only — a refused connection reads very differently from a
25
+ * rejected token, and it is the one thing on the banner that says WHY.
26
+ *
27
+ * Deliberately NOT on the snapshot: the reason varies between passes and the notification card is
28
+ * content-deduped, so persisting it there would re-toast the inbox for the whole outage. The
29
+ * consequence is that a reload mid-outage renders the banner with no reason line, which is honest
30
+ * (this session never saw the probe) and is why the reason is an ADDITION to the copy rather than
31
+ * a replacement for it.
32
+ */
33
+ const infraSetupDetails = ref<Partial<Record<InfraSetupArea, string>>>({})
34
+
35
+ function hydrate(next: InfraSetup | null | undefined) {
36
+ infraSetup.value = next ?? null
37
+ // The reasons belong to the live pushes this session observed, so an authoritative snapshot
38
+ // supersedes them: keeping one would caption a freshly-read status with a stale probe.
39
+ infraSetupDetails.value = {}
40
+ }
41
+
42
+ /**
43
+ * Patch ONE infra area's status from a live `infraSetup` event, so the setup banner appears (or
44
+ * clears) the moment the reachability watcher notices rather than on the next snapshot load.
45
+ *
46
+ * A targeted upsert, deliberately not a `refresh()`: this is a one-field delta on a projection
47
+ * the snapshot recomputes wholesale, and a coalesced full refresh here would pay the ~18-read
48
+ * aggregate for it. A no-op before the first snapshot has landed — `hydrate` is about to set the
49
+ * authoritative projection, which already carries the recorded outage.
50
+ *
51
+ * The write goes through contracts' `applyInfraSetupTransition`, the SAME rule the backend's
52
+ * snapshot fold uses, so live and reloaded state cannot disagree: only a `configured` area may
53
+ * become `unreachable`. Assigning unconditionally (as this once did) rendered a red "check that
54
+ * the service is running" banner over a `not_applicable`/`not_defined` area, which then vanished
55
+ * on the next reload — a banner that contradicts the projection is worse than a late one.
56
+ *
57
+ * Recovering an area also drops its OUTAGE session dismissal, so a health state re-nags when it
58
+ * recurs (see `isInfraSetupHealthStatus`): without this, dismissing one outage would silence the
59
+ * next one for the rest of the session.
60
+ */
61
+ function patchInfraSetup(area: InfraSetupArea, status: InfraSetupStatus, detail?: string) {
62
+ const current = infraSetup.value
63
+ if (!current) return
64
+ const next = applyInfraSetupTransition(current, area, status)
65
+ // Refused by the shared rule (the projection out-ranks this probe), so nothing about the area
66
+ // changed and its reason must not be captioned onto a status it does not describe.
67
+ if (next === current) return
68
+ infraSetup.value = next
69
+ infraSetupDetails.value = { ...infraSetupDetails.value, [area]: detail }
70
+ // `useUiStore` is resolved through the auto-import at CALL time, as it was in the store body
71
+ // this moved out of: the ui store is only needed on a recovery, and reaching for it lazily keeps
72
+ // this slice constructible before pinia has that store (and stubbable in the store unit tests).
73
+ if (!isInfraSetupHealthStatus(status)) useUiStore().clearInfraSetupSessionDismissal(area)
74
+ }
75
+
76
+ return { infraSetup, infraSetupDetails, hydrate, patchInfraSetup }
77
+ }