@cat-factory/app 0.190.1 → 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.
@@ -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
@@ -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
@@ -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
+ }
@@ -236,3 +236,132 @@ describe('workspace store cold-open speculative snapshot', () => {
236
236
  expect(ws.access).toBeNull()
237
237
  })
238
238
  })
239
+
240
+ // The reachability watcher pushes ONE area's transition as an `infraSetup` event, which the stream
241
+ // applies through `patchInfraSetup`. Two properties matter and neither is visible from the backend:
242
+ // the patch is TARGETED (a full refresh here would pay the whole snapshot aggregate for a one-field
243
+ // delta), and recovering an area drops its SESSION dismissal so a health state re-nags when it
244
+ // recurs — without which dismissing one outage would silence every later one for the session.
245
+ describe('workspace store infraSetup patching', () => {
246
+ /** A ui-store stub recording which areas had their session dismissal cleared. */
247
+ function stubUiStore() {
248
+ const cleared: string[] = []
249
+ vi.stubGlobal('useUiStore', () => ({
250
+ clearInfraSetupSessionDismissal: (area: string) => cleared.push(area),
251
+ }))
252
+ return cleared
253
+ }
254
+
255
+ async function openBoard(infraSetup: Record<string, string>) {
256
+ const snap = {
257
+ ...snapshot('ws1', [block('f1')]),
258
+ infraSetup,
259
+ } as unknown as WorkspaceSnapshot
260
+ const getWorkspace = vi.fn().mockResolvedValue(snap)
261
+ vi.stubGlobal('useApi', () => ({ getWorkspace }))
262
+ const ws = useWorkspaceStore()
263
+ await ws.switchTo('ws1')
264
+ return { ws, getWorkspace }
265
+ }
266
+
267
+ it('patches one area without refetching the snapshot', async () => {
268
+ stubUiStore()
269
+ const { ws, getWorkspace } = await openBoard({
270
+ agentExecutor: 'configured',
271
+ ephemeralEnvironments: 'configured',
272
+ binaryStorage: 'not_defined',
273
+ })
274
+ ws.patchInfraSetup('agentExecutor', 'unreachable')
275
+ expect(ws.infraSetup).toEqual({
276
+ agentExecutor: 'unreachable',
277
+ ephemeralEnvironments: 'configured',
278
+ binaryStorage: 'not_defined',
279
+ })
280
+ // One fetch: the initial switchTo. The live patch triggered no snapshot refresh.
281
+ expect(getWorkspace).toHaveBeenCalledTimes(1)
282
+ })
283
+
284
+ it('clears the area session dismissal on RECOVERY so the next outage re-nags', async () => {
285
+ const cleared = stubUiStore()
286
+ const { ws } = await openBoard({
287
+ agentExecutor: 'unreachable',
288
+ ephemeralEnvironments: 'configured',
289
+ binaryStorage: 'configured',
290
+ })
291
+ ws.patchInfraSetup('agentExecutor', 'configured')
292
+ expect(ws.infraSetup?.agentExecutor).toBe('configured')
293
+ expect(cleared).toEqual(['agentExecutor'])
294
+ })
295
+
296
+ it('leaves the session dismissal alone while the area is still unreachable', async () => {
297
+ const cleared = stubUiStore()
298
+ const { ws } = await openBoard({
299
+ agentExecutor: 'configured',
300
+ ephemeralEnvironments: 'configured',
301
+ binaryStorage: 'configured',
302
+ })
303
+ ws.patchInfraSetup('agentExecutor', 'unreachable')
304
+ expect(cleared).toEqual([])
305
+ })
306
+
307
+ it('is a no-op before the first snapshot has landed', () => {
308
+ // An event can arrive before the projection exists; `hydrate` is about to set the authoritative
309
+ // one (which already folds the recorded outage), so inventing a partial projection here would
310
+ // render a banner from a single field with the other areas unknown.
311
+ stubUiStore()
312
+ const ws = useWorkspaceStore()
313
+ ws.patchInfraSetup('agentExecutor', 'unreachable')
314
+ expect(ws.infraSetup).toBeNull()
315
+ })
316
+
317
+ it('refuses to mark an area unreachable that the projection does not call configured', async () => {
318
+ // The live patch honours the SAME rule as the backend's snapshot fold
319
+ // (`applyInfraSetupTransition`). Assigning unconditionally rendered a red "check that the
320
+ // service is running" banner over a `not_applicable`/`not_defined` area — which then vanished on
321
+ // the next reload, because the projection out-ranks the probe. A banner that contradicts the
322
+ // projection is worse than a late one.
323
+ stubUiStore()
324
+ const { ws } = await openBoard({
325
+ agentExecutor: 'not_applicable',
326
+ ephemeralEnvironments: 'not_defined',
327
+ binaryStorage: 'configured',
328
+ })
329
+ ws.patchInfraSetup('agentExecutor', 'unreachable')
330
+ ws.patchInfraSetup('ephemeralEnvironments', 'unreachable')
331
+ expect(ws.infraSetup?.agentExecutor).toBe('not_applicable')
332
+ expect(ws.infraSetup?.ephemeralEnvironments).toBe('not_defined')
333
+ // A refused patch must not caption its reason onto a status it does not describe.
334
+ expect(ws.infraSetupDetails).toEqual({})
335
+ })
336
+
337
+ it('keeps the probe reason for the banner, and drops it on recovery', async () => {
338
+ // The reason is the one thing on the banner that says WHY (a refused connection reads very
339
+ // differently from a rejected token), and it rides the live event only — the notification card
340
+ // is content-deduped, so persisting it there would re-toast the inbox for the whole outage.
341
+ stubUiStore()
342
+ const { ws } = await openBoard({
343
+ agentExecutor: 'configured',
344
+ ephemeralEnvironments: 'configured',
345
+ binaryStorage: 'configured',
346
+ })
347
+ ws.patchInfraSetup('agentExecutor', 'unreachable', 'connect ECONNREFUSED 10.0.0.4:6443')
348
+ expect(ws.infraSetupDetails.agentExecutor).toBe('connect ECONNREFUSED 10.0.0.4:6443')
349
+ ws.patchInfraSetup('agentExecutor', 'configured')
350
+ expect(ws.infraSetupDetails.agentExecutor).toBeUndefined()
351
+ })
352
+
353
+ it('drops live probe reasons when an authoritative snapshot lands', async () => {
354
+ stubUiStore()
355
+ const { ws } = await openBoard({
356
+ agentExecutor: 'configured',
357
+ ephemeralEnvironments: 'configured',
358
+ binaryStorage: 'configured',
359
+ })
360
+ ws.patchInfraSetup('agentExecutor', 'unreachable', 'HTTP 502')
361
+ expect(ws.infraSetupDetails.agentExecutor).toBe('HTTP 502')
362
+ await ws.refresh()
363
+ // A reload mid-outage renders the banner with no reason line, which is honest: this session
364
+ // never saw the probe, and captioning a fresh status with a stale reason would not be.
365
+ expect(ws.infraSetupDetails).toEqual({})
366
+ })
367
+ })
@@ -2,7 +2,6 @@ import { defineStore } from 'pinia'
2
2
  import { computed, ref } from 'vue'
3
3
  import type {
4
4
  BudgetCaps,
5
- InfraSetup,
6
5
  SpendStatus,
7
6
  WorkspaceAccess,
8
7
  WorkspaceListItem,
@@ -11,6 +10,7 @@ import type {
11
10
  import { useAccountsStore } from '~/stores/accounts'
12
11
  import { useBoardStore } from '~/stores/board'
13
12
  import { applySnapshotToStores, resetPerBoardCaches } from '~/stores/workspace/hydrate'
13
+ import { createInfraSetupState } from '~/stores/workspace/infraSetup'
14
14
  import { markBoot } from '~/utils/bootMarks'
15
15
  import { retryWhileBackendUnreachable } from '~/utils/backendReady'
16
16
 
@@ -50,12 +50,14 @@ export const useWorkspaceStore = defineStore(
50
50
  const userSpend = ref<SpendStatus | null>(null)
51
51
  /** Operator hard ceilings on the account/user budget tiers (null until first load). */
52
52
  const budgetCaps = ref<BudgetCaps | null>(null)
53
- /**
54
- * Per-area infrastructure-setup status (ephemeral environments / agent executor / binary
55
- * storage) from the snapshot, driving the infra-setup banner. Null on an older backend that
56
- * doesn't compute it (⇒ no banner).
57
- */
58
- const infraSetup = ref<InfraSetup | null>(null)
53
+ // The infra-setup slice (the banner's projection + the live reachability patch), extracted
54
+ // because it is the one slice with RULES rather than a plain assign-from-snapshot.
55
+ const {
56
+ infraSetup,
57
+ infraSetupDetails,
58
+ hydrate: hydrateInfraSetup,
59
+ patchInfraSetup,
60
+ } = createInfraSetupState()
59
61
  /**
60
62
  * The signed-in caller's resolved workspace-RBAC access to the ACTIVE board — their
61
63
  * effective role + the permission set it grants, from the auth gate's resolution
@@ -92,7 +94,7 @@ export const useWorkspaceStore = defineStore(
92
94
  accountSpend.value = snapshot.accountSpend ?? null
93
95
  userSpend.value = snapshot.userSpend ?? null
94
96
  budgetCaps.value = snapshot.budgetCaps ?? null
95
- infraSetup.value = snapshot.infraSetup ?? null
97
+ hydrateInfraSetup(snapshot.infraSetup)
96
98
  access.value = snapshot.access ?? null
97
99
  // Keep the board list in step (e.g. a freshly created board, or a rename). The
98
100
  // snapshot's `workspace` carries no `viewerRole` (that's a `GET /workspaces` list
@@ -283,6 +285,8 @@ export const useWorkspaceStore = defineStore(
283
285
  userSpend,
284
286
  budgetCaps,
285
287
  infraSetup,
288
+ infraSetupDetails,
289
+ patchInfraSetup,
286
290
  access,
287
291
  init,
288
292
  switchTo,
@@ -0,0 +1,29 @@
1
+ import type { InfraSetupArea } from '~/types/domain'
2
+
3
+ // Shared vocabulary for the infra-setup banner's two card kinds, used by the banner itself and by
4
+ // the ui store's session-dismissal book-keeping.
5
+
6
+ /**
7
+ * Which CLAIM an infra-setup card is making about an area. They share one banner surface but they
8
+ * are not interchangeable, and every dismissal rule keys off the difference:
9
+ * - `setup` — "you never configured this". A stable operator decision, so it may be dismissed
10
+ * permanently as well as for the session.
11
+ * - `outage` — "you DID configure it, and a live probe cannot reach it". A health state, so it is
12
+ * session-dismissible only and must re-nag on recurrence.
13
+ */
14
+ export type InfraSetupCardKind = 'setup' | 'outage'
15
+
16
+ /** A session-dismissal key: one area's one KIND of card. */
17
+ export type InfraSetupDismissalKey = `${InfraSetupArea}:${InfraSetupCardKind}`
18
+
19
+ /**
20
+ * The session-dismissal key for one area's card kind. A composite key rather than a bare area,
21
+ * because dismissing the setup nag must not silence the outage card that a later failure raises for
22
+ * the same area — a different claim about a different state.
23
+ */
24
+ export function infraSetupDismissalKey(
25
+ area: InfraSetupArea,
26
+ kind: InfraSetupCardKind,
27
+ ): InfraSetupDismissalKey {
28
+ return `${area}:${kind}`
29
+ }
@@ -1979,6 +1979,7 @@
1979
1979
  "clarity_review": "Als gelesen markieren",
1980
1980
  "release_regression": "Bestätigen",
1981
1981
  "platform_health": "Bestätigen",
1982
+ "infra_unreachable": "Bestätigen",
1982
1983
  "decision_required": "Als gelesen markieren",
1983
1984
  "human_test_ready": "Als gelesen markieren",
1984
1985
  "visual_confirmation_ready": "Als gelesen markieren",
@@ -2027,11 +2028,13 @@
2027
2028
  "infraSetupBanner": {
2028
2029
  "agentExecutor": {
2029
2030
  "title": "Agent-Executor nicht konfiguriert",
2031
+ "unreachableTitle": "Agent-Executor ist nicht erreichbar",
2030
2032
  "body": "Dieses Deployment führt Agenten auf einem selbst gehosteten Runner-Pool aus, aber es ist keiner registriert, sodass kein Agent laufen kann, bis Sie einen verbinden.",
2031
2033
  "action": "Runner-Pool konfigurieren"
2032
2034
  },
2033
2035
  "ephemeralEnvironments": {
2034
2036
  "title": "Testumgebung nicht konfiguriert",
2037
+ "unreachableTitle": "Anbieter für Testumgebungen ist nicht erreichbar",
2035
2038
  "body": "Es ist kein Anbieter für ephemere Umgebungen registriert, sodass Test-Agenten, die eine aktive Vorschau-Umgebung benötigen, nicht laufen können. Öffnen Sie unten „Testumgebungen“ und verbinden Sie einen Kubernetes-Cluster oder einen benutzerdefinierten HTTP-Umgebungsanbieter, um sie zu aktivieren.",
2036
2039
  "action": "Environment konfigurieren"
2037
2040
  },
@@ -2043,6 +2046,11 @@
2043
2046
  "dismiss": {
2044
2047
  "session": "Für diese Sitzung verwerfen",
2045
2048
  "permanent": "Ich bin mit den Einschränkungen einverstanden, nicht erneut benachrichtigen"
2049
+ },
2050
+ "unreachable": {
2051
+ "body": "Die Verbindung ist eingerichtet, aber eine Live-Prüfung konnte sie gerade nicht erreichen. Damit ist das ein Ausfall und keine fehlende Einrichtung: Agenten, die darauf angewiesen sind, schlagen fehl, bis wieder eine Antwort kommt. Prüfen Sie, ob der Dienst läuft, und testen Sie die Verbindung dann erneut.",
2052
+ "reason": "Letzte Prüfung: {detail}",
2053
+ "action": "Verbindung prüfen"
2046
2054
  }
2047
2055
  },
2048
2056
  "spendWarningBanner": {
@@ -4737,6 +4745,7 @@
4737
4745
  },
4738
4746
  "routable": {
4739
4747
  "platform_health": "Plattformzustand",
4748
+ "infra_unreachable": "Infrastrukturausfälle",
4740
4749
  "merge_review": "Merge-Review",
4741
4750
  "pipeline_complete": "Pipeline abgeschlossen",
4742
4751
  "ci_failed": "CI fehlgeschlagen",
@@ -1962,6 +1962,7 @@
1962
1962
  "clarity_review": "Mark read",
1963
1963
  "release_regression": "Acknowledge",
1964
1964
  "platform_health": "Acknowledge",
1965
+ "infra_unreachable": "Acknowledge",
1965
1966
  "decision_required": "Mark read",
1966
1967
  "human_test_ready": "Mark read",
1967
1968
  "visual_confirmation_ready": "Mark read",
@@ -2010,11 +2011,13 @@
2010
2011
  "infraSetupBanner": {
2011
2012
  "agentExecutor": {
2012
2013
  "title": "Agent executor not configured",
2014
+ "unreachableTitle": "Agent executor is unreachable",
2013
2015
  "body": "This deployment runs agents on a self-hosted runner pool, but none is registered, so no agent can run until you connect one.",
2014
2016
  "action": "Configure runner pool"
2015
2017
  },
2016
2018
  "ephemeralEnvironments": {
2017
2019
  "title": "Test environment not configured",
2020
+ "unreachableTitle": "Test environment provider is unreachable",
2018
2021
  "body": "No ephemeral environment provider is registered, so testing agents that need a live preview environment can't run. Open Test environments below and connect a Kubernetes cluster or a custom HTTP environment provider to enable them.",
2019
2022
  "action": "Configure environment"
2020
2023
  },
@@ -2026,6 +2029,11 @@
2026
2029
  "dismiss": {
2027
2030
  "session": "Dismiss for this session",
2028
2031
  "permanent": "I'm OK with the limitations, don't notify me again"
2032
+ },
2033
+ "unreachable": {
2034
+ "body": "The connection is set up, but a live check could not reach it just now. That makes this an outage rather than a missing setup: agents that depend on it will fail until it answers again. Check that the service is running, then retest the connection.",
2035
+ "reason": "Last probe: {detail}",
2036
+ "action": "Check connection"
2029
2037
  }
2030
2038
  },
2031
2039
  "spendWarningBanner": {
@@ -3695,6 +3703,7 @@
3695
3703
  },
3696
3704
  "routable": {
3697
3705
  "platform_health": "Platform health",
3706
+ "infra_unreachable": "Infrastructure outages",
3698
3707
  "merge_review": "Merge review",
3699
3708
  "pipeline_complete": "Pipeline complete",
3700
3709
  "ci_failed": "CI failed",
@@ -1886,6 +1886,7 @@
1886
1886
  "clarity_review": "Marcar como leída",
1887
1887
  "release_regression": "Confirmar recepción",
1888
1888
  "platform_health": "Confirmar recepción",
1889
+ "infra_unreachable": "Confirmar",
1889
1890
  "decision_required": "Marcar como leída",
1890
1891
  "human_test_ready": "Marcar como leída",
1891
1892
  "visual_confirmation_ready": "Marcar como leída",
@@ -1940,11 +1941,13 @@
1940
1941
  "infraSetupBanner": {
1941
1942
  "agentExecutor": {
1942
1943
  "title": "Ejecutor de agentes no configurado",
1944
+ "unreachableTitle": "El ejecutor de agentes no está accesible",
1943
1945
  "body": "Este despliegue ejecuta los agentes en un pool de runners autoalojado, pero no hay ninguno registrado, así que ningún agente podrá ejecutarse hasta que conectes uno.",
1944
1946
  "action": "Configurar pool de runners"
1945
1947
  },
1946
1948
  "ephemeralEnvironments": {
1947
1949
  "title": "Entorno de pruebas no configurado",
1950
+ "unreachableTitle": "El proveedor de entornos de prueba no está accesible",
1948
1951
  "body": "No hay ningún proveedor de entornos efímeros registrado, así que los agentes de pruebas que necesitan un entorno de vista previa activo no pueden ejecutarse. Abre Entornos de prueba abajo y conecta un clúster de Kubernetes o un proveedor de entornos HTTP personalizado para habilitarlos.",
1949
1952
  "action": "Configurar entorno"
1950
1953
  },
@@ -1956,6 +1959,11 @@
1956
1959
  "dismiss": {
1957
1960
  "session": "Descartar durante esta sesión",
1958
1961
  "permanent": "Acepto las limitaciones, no volver a avisarme"
1962
+ },
1963
+ "unreachable": {
1964
+ "body": "La conexión está configurada, pero una comprobación en vivo no ha podido alcanzarla ahora mismo. Por eso se trata de una interrupción y no de una configuración pendiente: los agentes que dependen de ella fallarán hasta que vuelva a responder. Comprueba que el servicio esté en marcha y vuelve a probar la conexión.",
1965
+ "reason": "Última comprobación: {detail}",
1966
+ "action": "Comprobar conexión"
1959
1967
  }
1960
1968
  },
1961
1969
  "spendWarningBanner": {
@@ -3586,6 +3594,7 @@
3586
3594
  },
3587
3595
  "routable": {
3588
3596
  "platform_health": "Estado de la plataforma",
3597
+ "infra_unreachable": "Interrupciones de infraestructura",
3589
3598
  "merge_review": "Revision de fusion",
3590
3599
  "pipeline_complete": "Pipeline completado",
3591
3600
  "ci_failed": "CI fallido",
@@ -1886,6 +1886,7 @@
1886
1886
  "clarity_review": "Marquer comme lu",
1887
1887
  "release_regression": "Accuser réception",
1888
1888
  "platform_health": "Accuser réception",
1889
+ "infra_unreachable": "Accuser réception",
1889
1890
  "decision_required": "Marquer comme lu",
1890
1891
  "human_test_ready": "Marquer comme lu",
1891
1892
  "visual_confirmation_ready": "Marquer comme lu",
@@ -1940,11 +1941,13 @@
1940
1941
  "infraSetupBanner": {
1941
1942
  "agentExecutor": {
1942
1943
  "title": "Exécuteur d'agents non configuré",
1944
+ "unreachableTitle": "L'exécuteur d'agents est inaccessible",
1943
1945
  "body": "Ce déploiement exécute les agents sur un pool de runners auto-hébergé, mais aucun n'est enregistré, donc aucun agent ne pourra s'exécuter tant que vous n'en connectez pas un.",
1944
1946
  "action": "Configurer le pool de runners"
1945
1947
  },
1946
1948
  "ephemeralEnvironments": {
1947
1949
  "title": "Environnement de test non configuré",
1950
+ "unreachableTitle": "Le fournisseur d'environnements de test est inaccessible",
1948
1951
  "body": "Aucun fournisseur d'environnements éphémères n'est enregistré, donc les agents de test qui nécessitent un environnement de prévisualisation actif ne peuvent pas s'exécuter. Ouvrez Environnements de test ci-dessous et connectez un cluster Kubernetes ou un fournisseur d'environnements HTTP personnalisé pour les activer.",
1949
1952
  "action": "Configurer l'environnement"
1950
1953
  },
@@ -1956,6 +1959,11 @@
1956
1959
  "dismiss": {
1957
1960
  "session": "Ignorer pour cette session",
1958
1961
  "permanent": "J'accepte les limitations, ne plus me notifier"
1962
+ },
1963
+ "unreachable": {
1964
+ "body": "La connexion est configurée, mais une vérification en direct n'a pas pu l'atteindre à l'instant. Il s'agit donc d'une panne et non d'une configuration manquante : les agents qui en dépendent échoueront jusqu'à ce qu'une réponse revienne. Vérifiez que le service fonctionne, puis testez la connexion à nouveau.",
1965
+ "reason": "Dernière vérification : {detail}",
1966
+ "action": "Vérifier la connexion"
1959
1967
  }
1960
1968
  },
1961
1969
  "spendWarningBanner": {
@@ -3586,6 +3594,7 @@
3586
3594
  },
3587
3595
  "routable": {
3588
3596
  "platform_health": "Santé de la plateforme",
3597
+ "infra_unreachable": "Pannes d'infrastructure",
3589
3598
  "merge_review": "Revue de fusion",
3590
3599
  "pipeline_complete": "Pipeline termine",
3591
3600
  "ci_failed": "Echec de CI",
@@ -1886,6 +1886,7 @@
1886
1886
  "clarity_review": "סמן כנקרא",
1887
1887
  "release_regression": "אשר",
1888
1888
  "platform_health": "אשר",
1889
+ "infra_unreachable": "אישור",
1889
1890
  "decision_required": "סמן כנקרא",
1890
1891
  "human_test_ready": "סמן כנקרא",
1891
1892
  "visual_confirmation_ready": "סמן כנקרא",
@@ -1940,11 +1941,13 @@
1940
1941
  "infraSetupBanner": {
1941
1942
  "agentExecutor": {
1942
1943
  "title": "מנוע הסוכנים אינו מוגדר",
1944
+ "unreachableTitle": "מריץ הסוכנים אינו נגיש",
1943
1945
  "body": "פריסה זו מריצה סוכנים במאגר ראנרים בניהול עצמי, אך אף מאגר אינו רשום, ולכן אף סוכן לא יוכל לפעול עד שתחברו אחד.",
1944
1946
  "action": "הגדרת מאגר ראנרים"
1945
1947
  },
1946
1948
  "ephemeralEnvironments": {
1947
1949
  "title": "סביבת בדיקות אינה מוגדרת",
1950
+ "unreachableTitle": "ספק סביבות הבדיקה אינו נגיש",
1948
1951
  "body": "לא רשום שום ספק סביבות ארעיות, ולכן סוכני בדיקה הזקוקים לסביבת תצוגה מקדימה פעילה אינם יכולים לפעול. פתחו את 'סביבות בדיקה' למטה וחברו אשכול Kubernetes או ספק סביבות HTTP מותאם אישית כדי להפעיל אותם.",
1949
1952
  "action": "הגדרת סביבה"
1950
1953
  },
@@ -1956,6 +1959,11 @@
1956
1959
  "dismiss": {
1957
1960
  "session": "התעלם להפעלה זו",
1958
1961
  "permanent": "אני מסכים למגבלות, אל תודיעו לי שוב"
1962
+ },
1963
+ "unreachable": {
1964
+ "body": "החיבור מוגדר, אך בדיקה חיה לא הצליחה להגיע אליו כרגע. לכן מדובר בתקלה ולא בהגדרה חסרה: סוכנים שתלויים בו ייכשלו עד שתחזור תשובה. בדקו שהשירות פועל, ולאחר מכן בדקו את החיבור מחדש.",
1965
+ "reason": "בדיקה אחרונה: {detail}",
1966
+ "action": "בדיקת החיבור"
1959
1967
  }
1960
1968
  },
1961
1969
  "spendWarningBanner": {
@@ -3597,6 +3605,7 @@
3597
3605
  },
3598
3606
  "routable": {
3599
3607
  "platform_health": "תקינות הפלטפורמה",
3608
+ "infra_unreachable": "תקלות תשתית",
3600
3609
  "merge_review": "סקירת מיזוג",
3601
3610
  "pipeline_complete": "הצינור הושלם",
3602
3611
  "ci_failed": "CI נכשל",
@@ -1979,6 +1979,7 @@
1979
1979
  "clarity_review": "Segna come letto",
1980
1980
  "release_regression": "Conferma presa visione",
1981
1981
  "platform_health": "Conferma presa visione",
1982
+ "infra_unreachable": "Conferma",
1982
1983
  "decision_required": "Segna come letto",
1983
1984
  "human_test_ready": "Segna come letto",
1984
1985
  "visual_confirmation_ready": "Segna come letto",
@@ -2027,11 +2028,13 @@
2027
2028
  "infraSetupBanner": {
2028
2029
  "agentExecutor": {
2029
2030
  "title": "Esecutore degli agenti non configurato",
2031
+ "unreachableTitle": "L'esecutore degli agenti non è raggiungibile",
2030
2032
  "body": "Questo deployment esegue gli agenti su un runner pool self-hosted, ma nessuno è registrato, quindi nessun agente può funzionare finché non ne connetti uno.",
2031
2033
  "action": "Configura il runner pool"
2032
2034
  },
2033
2035
  "ephemeralEnvironments": {
2034
2036
  "title": "Ambiente di test non configurato",
2037
+ "unreachableTitle": "Il provider di ambienti di test non è raggiungibile",
2035
2038
  "body": "Nessun provider di ambiente effimero è registrato, quindi gli agenti di test che necessitano di un ambiente di anteprima live non possono funzionare. Apri Ambienti di test qui sotto e connetti un cluster Kubernetes o un provider di ambienti HTTP personalizzato per abilitarli.",
2036
2039
  "action": "Configura l'ambiente"
2037
2040
  },
@@ -2043,6 +2046,11 @@
2043
2046
  "dismiss": {
2044
2047
  "session": "Ignora per questa sessione",
2045
2048
  "permanent": "Accetto le limitazioni, non notificarmi più"
2049
+ },
2050
+ "unreachable": {
2051
+ "body": "La connessione è configurata, ma una verifica in tempo reale non è riuscita a raggiungerla in questo momento. Si tratta quindi di un'interruzione e non di una configurazione mancante: gli agenti che ne dipendono falliranno finché non arriverà di nuovo una risposta. Controlla che il servizio sia attivo, poi riprova la connessione.",
2052
+ "reason": "Ultima verifica: {detail}",
2053
+ "action": "Verifica connessione"
2046
2054
  }
2047
2055
  },
2048
2056
  "spendWarningBanner": {
@@ -4737,6 +4745,7 @@
4737
4745
  },
4738
4746
  "routable": {
4739
4747
  "platform_health": "Salute della piattaforma",
4748
+ "infra_unreachable": "Interruzioni dell’infrastruttura",
4740
4749
  "merge_review": "Revisione del merge",
4741
4750
  "pipeline_complete": "Pipeline completata",
4742
4751
  "ci_failed": "CI non riuscita",
@@ -1886,6 +1886,7 @@
1886
1886
  "clarity_review": "既読にする",
1887
1887
  "release_regression": "確認",
1888
1888
  "platform_health": "確認",
1889
+ "infra_unreachable": "確認",
1889
1890
  "decision_required": "既読にする",
1890
1891
  "human_test_ready": "既読にする",
1891
1892
  "visual_confirmation_ready": "既読にする",
@@ -1940,11 +1941,13 @@
1940
1941
  "infraSetupBanner": {
1941
1942
  "agentExecutor": {
1942
1943
  "title": "エージェント実行環境が未設定です",
1944
+ "unreachableTitle": "エージェント実行環境に到達できません",
1943
1945
  "body": "このデプロイはセルフホストのランナープールでエージェントを実行しますが、登録されているものがないため、接続するまでエージェントを実行できません。",
1944
1946
  "action": "ランナープールを設定"
1945
1947
  },
1946
1948
  "ephemeralEnvironments": {
1947
1949
  "title": "テスト環境が未設定です",
1950
+ "unreachableTitle": "テスト環境プロバイダーに到達できません",
1948
1951
  "body": "エフェメラル環境プロバイダーが登録されていないため、稼働中のプレビュー環境を必要とするテストエージェントを実行できません。下の「テスト環境」を開き、Kubernetes クラスターまたはカスタム HTTP 環境プロバイダーを接続して有効にしてください。",
1949
1952
  "action": "環境を設定"
1950
1953
  },
@@ -1956,6 +1959,11 @@
1956
1959
  "dismiss": {
1957
1960
  "session": "このセッションでは非表示にする",
1958
1961
  "permanent": "制限を理解しました。今後通知しない"
1962
+ },
1963
+ "unreachable": {
1964
+ "body": "接続は設定済みですが、ライブチェックでは到達できませんでした。つまり設定漏れではなく障害です。応答が戻るまで、これに依存するエージェントは失敗します。サービスが稼働しているか確認したうえで、接続を再テストしてください。",
1965
+ "reason": "最後のチェック: {detail}",
1966
+ "action": "接続を確認"
1959
1967
  }
1960
1968
  },
1961
1969
  "spendWarningBanner": {
@@ -3598,6 +3606,7 @@
3598
3606
  },
3599
3607
  "routable": {
3600
3608
  "platform_health": "プラットフォームの健全性",
3609
+ "infra_unreachable": "インフラ障害",
3601
3610
  "merge_review": "マージレビュー",
3602
3611
  "pipeline_complete": "パイプライン完了",
3603
3612
  "ci_failed": "CI失敗",
@@ -1886,6 +1886,7 @@
1886
1886
  "clarity_review": "Oznacz jako przeczytane",
1887
1887
  "release_regression": "Potwierdź",
1888
1888
  "platform_health": "Potwierdź",
1889
+ "infra_unreachable": "Potwierdź",
1889
1890
  "decision_required": "Oznacz jako przeczytane",
1890
1891
  "human_test_ready": "Oznacz jako przeczytane",
1891
1892
  "visual_confirmation_ready": "Oznacz jako przeczytane",
@@ -1940,11 +1941,13 @@
1940
1941
  "infraSetupBanner": {
1941
1942
  "agentExecutor": {
1942
1943
  "title": "Wykonawca agentów nie jest skonfigurowany",
1944
+ "unreachableTitle": "Wykonawca agentów jest nieosiągalny",
1943
1945
  "body": "To wdrożenie uruchamia agentów w samodzielnie hostowanej puli runnerów, ale żadna nie jest zarejestrowana, więc żaden agent nie będzie mógł działać, dopóki jej nie podłączysz.",
1944
1946
  "action": "Skonfiguruj pulę runnerów"
1945
1947
  },
1946
1948
  "ephemeralEnvironments": {
1947
1949
  "title": "Środowisko testowe nie jest skonfigurowane",
1950
+ "unreachableTitle": "Dostawca środowisk testowych jest nieosiągalny",
1948
1951
  "body": "Nie zarejestrowano żadnego dostawcy środowisk efemerycznych, więc agenci testowi wymagający działającego środowiska podglądu nie mogą działać. Otwórz poniżej „Środowiska testowe” i podłącz klaster Kubernetes lub niestandardowego dostawcę środowisk HTTP, aby je włączyć.",
1949
1952
  "action": "Skonfiguruj środowisko"
1950
1953
  },
@@ -1956,6 +1959,11 @@
1956
1959
  "dismiss": {
1957
1960
  "session": "Odrzuć na tę sesję",
1958
1961
  "permanent": "Akceptuję ograniczenia, nie powiadamiaj mnie ponownie"
1962
+ },
1963
+ "unreachable": {
1964
+ "body": "Połączenie jest skonfigurowane, ale kontrola na żywo nie mogła go teraz uzyskać. To awaria, a nie brak konfiguracji: agenci, którzy od tego zależą, będą kończyć się błędem, dopóki nie pojawi się znowu odpowiedź. Sprawdź, czy usługa działa, a następnie ponownie przetestuj połączenie.",
1965
+ "reason": "Ostatnia kontrola: {detail}",
1966
+ "action": "Sprawdź połączenie"
1959
1967
  }
1960
1968
  },
1961
1969
  "spendWarningBanner": {
@@ -3586,6 +3594,7 @@
3586
3594
  },
3587
3595
  "routable": {
3588
3596
  "platform_health": "Kondycja platformy",
3597
+ "infra_unreachable": "Awarie infrastruktury",
3589
3598
  "merge_review": "Przeglad scalenia",
3590
3599
  "pipeline_complete": "Pipeline ukonczony",
3591
3600
  "ci_failed": "Niepowodzenie CI",
@@ -1886,6 +1886,7 @@
1886
1886
  "clarity_review": "Okundu olarak işaretle",
1887
1887
  "release_regression": "Onayla",
1888
1888
  "platform_health": "Onayla",
1889
+ "infra_unreachable": "Onayla",
1889
1890
  "decision_required": "Okundu işaretle",
1890
1891
  "human_test_ready": "Okundu işaretle",
1891
1892
  "visual_confirmation_ready": "Okundu işaretle",
@@ -1940,11 +1941,13 @@
1940
1941
  "infraSetupBanner": {
1941
1942
  "agentExecutor": {
1942
1943
  "title": "Aracı yürütücüsü yapılandırılmadı",
1944
+ "unreachableTitle": "Aracı yürütücüsüne erişilemiyor",
1943
1945
  "body": "Bu dağıtım aracıları kendi barındırdığınız bir runner havuzunda çalıştırır, ancak kayıtlı bir havuz yok; bu yüzden bir tane bağlayana kadar hiçbir aracı çalışamaz.",
1944
1946
  "action": "Runner havuzunu yapılandır"
1945
1947
  },
1946
1948
  "ephemeralEnvironments": {
1947
1949
  "title": "Test ortamı yapılandırılmadı",
1950
+ "unreachableTitle": "Test ortamı sağlayıcısına erişilemiyor",
1948
1951
  "body": "Kayıtlı bir geçici ortam sağlayıcısı yok; bu nedenle çalışan bir önizleme ortamına ihtiyaç duyan test aracıları çalışamaz. Aşağıdaki Test ortamları sekmesini açın ve etkinleştirmek için bir Kubernetes kümesi ya da özel bir HTTP ortam sağlayıcısı bağlayın.",
1949
1952
  "action": "Ortamı yapılandır"
1950
1953
  },
@@ -1956,6 +1959,11 @@
1956
1959
  "dismiss": {
1957
1960
  "session": "Bu oturum için kapat",
1958
1961
  "permanent": "Kısıtlamaları kabul ediyorum, beni tekrar bilgilendirme"
1962
+ },
1963
+ "unreachable": {
1964
+ "body": "Bağlantı yapılandırılmış durumda, ancak canlı bir denetim şu anda ona ulaşamadı. Bu yüzden eksik bir yapılandırma değil bir kesinti söz konusu: buna bağlı aracılar yeniden yanıt gelene kadar başarısız olacak. Hizmetin çalıştığını doğrulayın, ardından bağlantıyı yeniden test edin.",
1965
+ "reason": "Son denetim: {detail}",
1966
+ "action": "Bağlantıyı denetle"
1959
1967
  }
1960
1968
  },
1961
1969
  "spendWarningBanner": {
@@ -3598,6 +3606,7 @@
3598
3606
  },
3599
3607
  "routable": {
3600
3608
  "platform_health": "Platform sağlığı",
3609
+ "infra_unreachable": "Altyapı kesintileri",
3601
3610
  "merge_review": "Birleştirme incelemesi",
3602
3611
  "pipeline_complete": "Pipeline tamamlandı",
3603
3612
  "ci_failed": "CI başarısız",
@@ -1886,6 +1886,7 @@
1886
1886
  "clarity_review": "Позначити прочитаним",
1887
1887
  "release_regression": "Підтвердити отримання",
1888
1888
  "platform_health": "Підтвердити отримання",
1889
+ "infra_unreachable": "Підтвердити",
1889
1890
  "decision_required": "Позначити прочитаним",
1890
1891
  "human_test_ready": "Позначити прочитаним",
1891
1892
  "visual_confirmation_ready": "Позначити прочитаним",
@@ -1940,11 +1941,13 @@
1940
1941
  "infraSetupBanner": {
1941
1942
  "agentExecutor": {
1942
1943
  "title": "Виконавець агентів не налаштований",
1944
+ "unreachableTitle": "Виконавець агентів недоступний",
1943
1945
  "body": "Це розгортання запускає агентів у самостійно розміщеному пулі раннерів, але жоден не зареєстрований, тож жоден агент не зможе працювати, доки ви не підключите його.",
1944
1946
  "action": "Налаштувати пул раннерів"
1945
1947
  },
1946
1948
  "ephemeralEnvironments": {
1947
1949
  "title": "Тестове середовище не налаштоване",
1950
+ "unreachableTitle": "Постачальник тестових середовищ недоступний",
1948
1951
  "body": "Не зареєстровано жодного постачальника ефемерних середовищ, тож тестові агенти, яким потрібне робоче середовище попереднього перегляду, не можуть працювати. Відкрийте нижче «Тестові середовища» та підключіть кластер Kubernetes або власного постачальника середовищ HTTP, щоб увімкнути їх.",
1949
1952
  "action": "Налаштувати середовище"
1950
1953
  },
@@ -1956,6 +1959,11 @@
1956
1959
  "dismiss": {
1957
1960
  "session": "Відхилити на цю сесію",
1958
1961
  "permanent": "Мене влаштовують обмеження, більше не сповіщати"
1962
+ },
1963
+ "unreachable": {
1964
+ "body": "З’єднання налаштоване, але жива перевірка щойно не змогла до нього дістатися. Тому це збій, а не відсутнє налаштування: агенти, які від нього залежать, завершуватимуться з помилкою, доки не з’явиться відповідь. Перевірте, чи працює служба, а потім протестуйте з’єднання ще раз.",
1965
+ "reason": "Остання перевірка: {detail}",
1966
+ "action": "Перевірити з’єднання"
1959
1967
  }
1960
1968
  },
1961
1969
  "spendWarningBanner": {
@@ -3586,6 +3594,7 @@
3586
3594
  },
3587
3595
  "routable": {
3588
3596
  "platform_health": "Стан платформи",
3597
+ "infra_unreachable": "Збої інфраструктури",
3589
3598
  "merge_review": "Перевірка злиття",
3590
3599
  "pipeline_complete": "Конвеєр завершено",
3591
3600
  "ci_failed": "Збій CI",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.190.1",
3
+ "version": "0.191.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,7 @@
40
40
  "valibot": "^1.4.2",
41
41
  "vue": "3.5.40",
42
42
  "wretch": "^3.0.9",
43
- "@cat-factory/contracts": "0.197.0"
43
+ "@cat-factory/contracts": "0.198.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",