@cat-factory/app 0.190.1 → 0.192.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.
@@ -26,17 +26,20 @@ export const useForkDecisionStore = defineStore('forkDecision', () => {
26
26
  const error = ref<string | null>(null)
27
27
 
28
28
  /**
29
- * Reflect an authoritative fork-decision state onto the run's Coder step. A pipeline may
29
+ * Apply an authoritative fork-decision state to the run's Coder step. A pipeline may
30
30
  * carry more than one `coder` step, so target the step this decision is about rather than
31
31
  * the first one that happens to hold fork state: prefer the step that is still live
32
32
  * (proposing / awaiting the choice / answering), then the current step, and only then fall
33
- * back to the first step carrying fork state. The stream corrects any mismatch, but this
34
- * keeps the immediate optimistic echo on the right step.
33
+ * back to the first step carrying fork state.
34
+ *
35
+ * Only ever called through {@link ExecutionStore.echoAfter}, which drops the echo when the event
36
+ * stream already delivered a newer revision — without that guard this assignment silently
37
+ * regressed the chat thread (see `echoAfter` for the failure it caused).
35
38
  */
36
- function reflect(executionId: string, state: ForkDecisionStepState | null): void {
37
- if (!state) return
38
- const instance = execution.getInstance(executionId)
39
- if (!instance) return
39
+ function assign(
40
+ instance: ReturnType<typeof execution.getInstance> & object,
41
+ state: ForkDecisionStepState,
42
+ ): void {
40
43
  const isLive = (s: (typeof instance.steps)[number]) =>
41
44
  s.agentKind === 'coder' &&
42
45
  (s.forkDecision?.status === 'awaiting_choice' ||
@@ -54,8 +57,13 @@ export const useForkDecisionStore = defineStore('forkDecision', () => {
54
57
  async function load(executionId: string): Promise<void> {
55
58
  error.value = null
56
59
  try {
57
- const state = await api.getForkDecision(workspace.requireId(), executionId)
58
- reflect(executionId, state as ForkDecisionStepState | null)
60
+ await execution.echoAfter(
61
+ executionId,
62
+ () => api.getForkDecision(workspace.requireId(), executionId),
63
+ (state, instance) => {
64
+ if (state) assign(instance, state as ForkDecisionStepState)
65
+ },
66
+ )
59
67
  } catch (e) {
60
68
  error.value = e instanceof Error ? e.message : 'Failed to load'
61
69
  }
@@ -72,8 +80,11 @@ export const useForkDecisionStore = defineStore('forkDecision', () => {
72
80
  error.value = null
73
81
  choosing.value = true
74
82
  try {
75
- const state = await api.chooseFork(workspace.requireId(), executionId, choice)
76
- reflect(executionId, state as ForkDecisionStepState)
83
+ await execution.echoAfter(
84
+ executionId,
85
+ () => api.chooseFork(workspace.requireId(), executionId, choice),
86
+ (state, instance) => assign(instance, state as ForkDecisionStepState),
87
+ )
77
88
  } catch (e) {
78
89
  error.value = e instanceof Error ? e.message : 'Failed to choose'
79
90
  throw e
@@ -85,15 +96,24 @@ export const useForkDecisionStore = defineStore('forkDecision', () => {
85
96
  /**
86
97
  * Send a grounded chat message about the surfaced forks. The reply is computed inline in the
87
98
  * durable driver and arrives via the execution stream; the immediate response is the
88
- * `answering` state (the human message already appended), which we reflect so the thread shows
89
- * the sent turn + a "thinking…" bubble without waiting for the stream.
99
+ * `answering` state (the human message already appended), echoed so the thread shows the sent
100
+ * turn + a "thinking…" bubble without waiting for the stream.
101
+ *
102
+ * The echo is the RACIEST one in the app and must stay guarded: `chat` emits the one-message
103
+ * `answering` state and then wakes the driver, which appends the reply and emits again, so with a
104
+ * canned (no-model) reply the two-message thread frequently reaches the browser first. Applying
105
+ * this response unconditionally dropped the reply and left the bubble spinning forever, since a
106
+ * parked run emits nothing more.
90
107
  */
91
108
  async function chat(executionId: string, text: string): Promise<void> {
92
109
  error.value = null
93
110
  chatting.value = true
94
111
  try {
95
- const state = await api.forkChat(workspace.requireId(), executionId, text)
96
- reflect(executionId, state as ForkDecisionStepState)
112
+ await execution.echoAfter(
113
+ executionId,
114
+ () => api.forkChat(workspace.requireId(), executionId, text),
115
+ (state, instance) => assign(instance, state as ForkDecisionStepState),
116
+ )
97
117
  } catch (e) {
98
118
  error.value = e instanceof Error ? e.message : 'Failed to send message'
99
119
  throw e
@@ -27,13 +27,16 @@ export const useJudgeStore = defineStore('judge', () => {
27
27
  * Reflect an authoritative judge state onto the run's judge step. A pipeline may place more
28
28
  * than one judge, so target the step this verdict is about rather than the first one holding
29
29
  * judge state: prefer the step still awaiting a decision, then the current step, and only then
30
- * fall back to the first step carrying judge state. The stream corrects any mismatch; this
31
- * keeps the immediate optimistic echo on the right step.
30
+ * fall back to the first step carrying judge state.
31
+ *
32
+ * Only ever called through {@link ExecutionStore.echoAfter}, which drops the echo when the event
33
+ * stream already delivered a newer revision — a `bounce` re-arms the producing step, so the
34
+ * driver is emitting fresh state while this response is still in flight.
32
35
  */
33
- function reflect(executionId: string, state: JudgeStepState | null): void {
34
- if (!state) return
35
- const instance = execution.getInstance(executionId)
36
- if (!instance) return
36
+ function assign(
37
+ instance: ReturnType<typeof execution.getInstance> & object,
38
+ state: JudgeStepState,
39
+ ): void {
37
40
  const current = instance.steps[instance.currentStep]
38
41
  const step =
39
42
  instance.steps.find((s) => s.judge?.status === 'awaiting_decision') ??
@@ -46,8 +49,13 @@ export const useJudgeStore = defineStore('judge', () => {
46
49
  async function load(executionId: string): Promise<void> {
47
50
  error.value = null
48
51
  try {
49
- const state = await api.getJudgeState(workspace.requireId(), executionId)
50
- reflect(executionId, state as JudgeStepState | null)
52
+ await execution.echoAfter(
53
+ executionId,
54
+ () => api.getJudgeState(workspace.requireId(), executionId),
55
+ (state, instance) => {
56
+ if (state) assign(instance, state as JudgeStepState)
57
+ },
58
+ )
51
59
  } catch (e) {
52
60
  error.value = e instanceof Error ? e.message : 'Failed to load'
53
61
  }
@@ -66,11 +74,15 @@ export const useJudgeStore = defineStore('judge', () => {
66
74
  error.value = null
67
75
  resolving.value = true
68
76
  try {
69
- const state = await api.resolveJudge(workspace.requireId(), executionId, {
70
- choice,
71
- ...(feedback ? { feedback } : {}),
72
- })
73
- reflect(executionId, state as JudgeStepState)
77
+ await execution.echoAfter(
78
+ executionId,
79
+ () =>
80
+ api.resolveJudge(workspace.requireId(), executionId, {
81
+ choice,
82
+ ...(feedback ? { feedback } : {}),
83
+ }),
84
+ (state, instance) => assign(instance, state as JudgeStepState),
85
+ )
74
86
  } catch (e) {
75
87
  error.value = e instanceof Error ? e.message : 'Failed to resolve'
76
88
  throw e
@@ -20,20 +20,30 @@ export const usePrReviewStore = defineStore('prReview', () => {
20
20
 
21
21
  /** True while a resolve call is in flight (drives the Finish button spinner / disabled state). */
22
22
  const resolving = ref(false)
23
+ /**
24
+ * True while a RESUME call is in flight. Kept separate from `resolving` rather than folded into
25
+ * it: a resume acts during the `reviewing` phase and a resolve during `awaiting_selection`, so
26
+ * sharing one flag would let either action's spinner appear on the other's controls.
27
+ */
28
+ const resuming = ref(false)
23
29
  /** The last error message from an action, surfaced inline; cleared on the next action. */
24
30
  const error = ref<string | null>(null)
25
31
 
26
32
  /**
27
- * Reflect an authoritative PR-review state onto the run's `pr-reviewer` step. A pipeline could
33
+ * Apply an authoritative PR-review state to the run's `pr-reviewer` step. A pipeline could
28
34
  * carry more than one such step, so target the step this review is about: prefer the step that
29
35
  * is still awaiting a selection, then the current step, and only then the first step carrying
30
- * review state. The stream corrects any mismatch; this keeps the immediate optimistic echo on
31
- * the right step.
36
+ * review state.
37
+ *
38
+ * Only ever called through {@link ExecutionStore.echoAfter}, which drops the echo when the event
39
+ * stream already delivered a newer revision. `resume` needs that guard most: it returns a
40
+ * `reviewing` state and then the re-dispatched reviewer starts publishing slice reviews, so an
41
+ * unguarded echo could put the freshly-captured reports back to what the resume saw.
32
42
  */
33
- function reflect(executionId: string, state: PrReviewStepState | null): void {
34
- if (!state) return
35
- const instance = execution.getInstance(executionId)
36
- if (!instance) return
43
+ function assign(
44
+ instance: ReturnType<typeof execution.getInstance> & object,
45
+ state: PrReviewStepState,
46
+ ): void {
37
47
  const isLive = (s: (typeof instance.steps)[number]) =>
38
48
  s.agentKind === 'pr-reviewer' && s.prReview?.status === 'awaiting_selection'
39
49
  const current = instance.steps[instance.currentStep]
@@ -48,8 +58,13 @@ export const usePrReviewStore = defineStore('prReview', () => {
48
58
  async function load(executionId: string): Promise<void> {
49
59
  error.value = null
50
60
  try {
51
- const state = await api.getPrReview(workspace.requireId(), executionId)
52
- reflect(executionId, state as PrReviewStepState | null)
61
+ await execution.echoAfter(
62
+ executionId,
63
+ () => api.getPrReview(workspace.requireId(), executionId),
64
+ (state, instance) => {
65
+ if (state) assign(instance, state as PrReviewStepState)
66
+ },
67
+ )
53
68
  } catch (e) {
54
69
  error.value = e instanceof Error ? e.message : 'Failed to load'
55
70
  }
@@ -69,11 +84,11 @@ export const usePrReviewStore = defineStore('prReview', () => {
69
84
  error.value = null
70
85
  resolving.value = true
71
86
  try {
72
- const state = await api.resolvePrReview(workspace.requireId(), executionId, {
73
- action,
74
- findingIds,
75
- })
76
- reflect(executionId, state as PrReviewStepState)
87
+ await execution.echoAfter(
88
+ executionId,
89
+ () => api.resolvePrReview(workspace.requireId(), executionId, { action, findingIds }),
90
+ (state, instance) => assign(instance, state as PrReviewStepState),
91
+ )
77
92
  } catch (e) {
78
93
  error.value = e instanceof Error ? e.message : 'Failed to resolve review'
79
94
  throw e
@@ -82,13 +97,38 @@ export const usePrReviewStore = defineStore('prReview', () => {
82
97
  }
83
98
  }
84
99
 
100
+ /**
101
+ * Resume a review stuck mid-`reviewing`: the reviewer is re-dispatched for only the slices that
102
+ * never reported, and the already-captured reports are fed back in so the finished slices are
103
+ * re-aggregated rather than re-reviewed. Rejected (409) unless the review is still `reviewing`.
104
+ */
105
+ async function resume(executionId: string): Promise<void> {
106
+ error.value = null
107
+ resuming.value = true
108
+ try {
109
+ await execution.echoAfter(
110
+ executionId,
111
+ () => api.resumePrReview(workspace.requireId(), executionId),
112
+ (state, instance) => assign(instance, state as PrReviewStepState),
113
+ )
114
+ } catch (e) {
115
+ error.value = e instanceof Error ? e.message : 'Failed to resume review'
116
+ throw e
117
+ } finally {
118
+ resuming.value = false
119
+ }
120
+ }
121
+
85
122
  /** Dismiss a finding entirely: it's removed from the review (and the selection). Stays parked. */
86
123
  async function dismiss(executionId: string, findingId: string): Promise<void> {
87
124
  error.value = null
88
125
  resolving.value = true
89
126
  try {
90
- const state = await api.dismissPrReviewFinding(workspace.requireId(), executionId, findingId)
91
- reflect(executionId, state as PrReviewStepState)
127
+ await execution.echoAfter(
128
+ executionId,
129
+ () => api.dismissPrReviewFinding(workspace.requireId(), executionId, findingId),
130
+ (state, instance) => assign(instance, state as PrReviewStepState),
131
+ )
92
132
  } catch (e) {
93
133
  error.value = e instanceof Error ? e.message : 'Failed to dismiss finding'
94
134
  throw e
@@ -110,15 +150,14 @@ export const usePrReviewStore = defineStore('prReview', () => {
110
150
  error.value = null
111
151
  resolving.value = true
112
152
  try {
113
- const state = await api.challengePrReviewFinding(
114
- workspace.requireId(),
153
+ await execution.echoAfter(
115
154
  executionId,
116
- findingId,
117
- {
118
- question,
119
- },
155
+ () =>
156
+ api.challengePrReviewFinding(workspace.requireId(), executionId, findingId, {
157
+ question,
158
+ }),
159
+ (state, instance) => assign(instance, state as PrReviewStepState),
120
160
  )
121
- reflect(executionId, state as PrReviewStepState)
122
161
  } catch (e) {
123
162
  error.value = e instanceof Error ? e.message : 'Failed to challenge finding'
124
163
  throw e
@@ -127,5 +166,5 @@ export const usePrReviewStore = defineStore('prReview', () => {
127
166
  }
128
167
  }
129
168
 
130
- return { resolving, error, load, resolve, dismiss, challenge }
169
+ return { resolving, resuming, error, load, resolve, resume, dismiss, challenge }
131
170
  })
@@ -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
+ }