@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.
- package/app/components/layout/InfraSetupBanner.vue +139 -35
- package/app/components/layout/NotificationsInbox.vue +5 -0
- package/app/components/prReview/PrReviewWindow.vue +55 -1
- package/app/components/slack/SlackPanel.vue +2 -0
- package/app/composables/api/prReview.ts +10 -0
- package/app/composables/useWorkspaceStream.ts +6 -0
- package/app/stores/execution.spec.ts +87 -0
- package/app/stores/execution.ts +39 -0
- package/app/stores/followUps.ts +14 -8
- package/app/stores/forkDecision.ts +35 -15
- package/app/stores/judge.ts +25 -13
- package/app/stores/prReview.ts +63 -24
- package/app/stores/ui/modals.ts +31 -8
- package/app/stores/workspace/infraSetup.ts +77 -0
- package/app/stores/workspace.spec.ts +129 -0
- package/app/stores/workspace.ts +12 -8
- package/app/utils/infraSetup.ts +29 -0
- package/i18n/locales/de.json +13 -0
- package/i18n/locales/en.json +13 -0
- package/i18n/locales/es.json +13 -0
- package/i18n/locales/fr.json +13 -0
- package/i18n/locales/he.json +13 -0
- package/i18n/locales/it.json +13 -0
- package/i18n/locales/ja.json +13 -0
- package/i18n/locales/pl.json +13 -0
- package/i18n/locales/tr.json +13 -0
- package/i18n/locales/uk.json +13 -0
- package/package.json +2 -2
|
@@ -26,17 +26,20 @@ export const useForkDecisionStore = defineStore('forkDecision', () => {
|
|
|
26
26
|
const error = ref<string | null>(null)
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
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.
|
|
34
|
-
*
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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),
|
|
89
|
-
*
|
|
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
|
-
|
|
96
|
-
|
|
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
|
package/app/stores/judge.ts
CHANGED
|
@@ -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.
|
|
31
|
-
*
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
package/app/stores/prReview.ts
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
|
31
|
-
*
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
findingIds,
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
114
|
-
workspace.requireId(),
|
|
153
|
+
await execution.echoAfter(
|
|
115
154
|
executionId,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
})
|
package/app/stores/ui/modals.ts
CHANGED
|
@@ -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,
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
+
})
|
package/app/stores/workspace.ts
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
+
}
|