@cat-factory/app 0.190.0 → 0.191.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/app/components/environments/EnvironmentSetupWizard.vue +1 -0
- package/app/components/layout/InfraSetupBanner.vue +139 -35
- package/app/components/layout/NotificationsInbox.vue +5 -0
- package/app/components/panels/AgentStepDetail.vue +2 -0
- package/app/components/panels/ReportsPanel.vue +1 -0
- package/app/components/panels/inspector/TaskRunSettings.vue +1 -0
- package/app/components/pipeline/PipelinePreview.vue +1 -0
- package/app/components/prReview/PrReviewWindow.vue +1 -0
- package/app/components/settings/RiskPolicyPanel.vue +1 -0
- package/app/components/slack/SlackPanel.vue +2 -0
- package/app/composables/api/client.spec.ts +50 -0
- package/app/composables/api/client.ts +26 -1
- package/app/composables/useWorkspaceStream.ts +6 -0
- 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 +9 -0
- package/i18n/locales/en.json +9 -0
- package/i18n/locales/es.json +9 -0
- package/i18n/locales/fr.json +9 -0
- package/i18n/locales/he.json +9 -0
- package/i18n/locales/it.json +9 -0
- package/i18n/locales/ja.json +9 -0
- package/i18n/locales/pl.json +9 -0
- package/i18n/locales/tr.json +9 -0
- package/i18n/locales/uk.json +9 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -54,6 +54,16 @@ over the WebSocket. How that sync works is written up in
|
|
|
54
54
|
| `types/` | TypeScript domain unions (`domain.ts`) and wire types mirroring the contracts. |
|
|
55
55
|
| `utils/` | Small pure helpers. |
|
|
56
56
|
|
|
57
|
+
### Always import a layer component explicitly
|
|
58
|
+
|
|
59
|
+
**Import a component under `components/` by path before using it in a template.** Do not lean on Nuxt's auto-registration. This layer sets no `components` config, so the default `pathPrefix: true` applies and a component is registered under its path-prefixed name: `components/panels/StepEffortReport.vue` becomes `PanelsStepEffortReport`, and a bare `<StepEffortReport>` matches nothing.
|
|
60
|
+
|
|
61
|
+
Some bare tags do work, which is exactly what makes this worth writing down. Nuxt drops a directory segment the filename already repeats, so `pipeline/PipelinePicker.vue` registers as `PipelinePicker` and resolves bare, while `pipeline/AgentKindIcon.vue` in the same folder registers as `PipelineAgentKindIcon` and does not. Whether a tag resolves therefore depends on a coincidence between a folder name and a filename, and renaming either end breaks the tag with no error. An explicit import does not care.
|
|
62
|
+
|
|
63
|
+
The failure is silent, which is why this is a rule rather than a preference. An unresolved tag warns in dev and then renders nothing, so a built SPA has a hole where the component should be. Nothing catches it: not typecheck, not the unit tests, not the e2e suite, and not the user, who reads it as a backend returning no data. Seven components had shipped this way.
|
|
64
|
+
|
|
65
|
+
`scripts/check-component-imports.mjs` enforces it (CI's `repo-guards` job). If a panel section is missing and the data looks right, check the import first.
|
|
66
|
+
|
|
57
67
|
## Interface modes (basic / advanced)
|
|
58
68
|
|
|
59
69
|
The SPA renders at one of two **interface tiers**. `basic` (the default) is the everyday
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { computed } from 'vue'
|
|
18
18
|
import { JourneyHost, JourneyOutlet } from '@modular-vue/journeys'
|
|
19
19
|
import { environmentSetupHandle } from '~/modular/journeys/environmentSetup'
|
|
20
|
+
import EnvSetupStepper from '~/components/environments/EnvSetupStepper.vue'
|
|
20
21
|
|
|
21
22
|
const ui = useUiStore()
|
|
22
23
|
const { t } = useI18n()
|
|
@@ -22,17 +22,36 @@
|
|
|
22
22
|
// account that also has no storage — an accepted trade-off (the setting stays reachable from
|
|
23
23
|
// account settings, and the SESSION dismissal re-nags on the next load regardless).
|
|
24
24
|
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
109
|
-
function
|
|
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="
|
|
140
|
-
:key="area"
|
|
141
|
-
class="pointer-events-auto w-full max-w-3xl rounded-2xl border-2
|
|
142
|
-
:
|
|
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
|
|
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
|
|
149
|
-
|
|
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(
|
|
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
|
|
163
|
-
|
|
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
|
-
{{
|
|
273
|
+
{{
|
|
274
|
+
card.kind === 'outage'
|
|
275
|
+
? t('layout.infraSetupBanner.unreachable.action')
|
|
276
|
+
: t(AREA_META[card.area].actionKey)
|
|
277
|
+
}}
|
|
174
278
|
</UButton>
|
|
175
279
|
</div>
|
|
176
280
|
</div>
|
|
@@ -88,6 +88,10 @@ const META: Record<Notification['type'], { icon: string; color: Accent }> = {
|
|
|
88
88
|
// sealed). Not block-scoped; "act" drops the listed stale ciphertexts so they can be re-entered
|
|
89
89
|
// (or restore the previous key to recover them instead).
|
|
90
90
|
key_drift: { icon: 'i-lucide-key-round', color: 'error' },
|
|
91
|
+
// A configured infrastructure connection stopped answering its live probe. Not block-scoped and
|
|
92
|
+
// nothing to act on from here — the fix is on the provider's side, and the card clears itself when
|
|
93
|
+
// the reachability watcher sees it answer again; "act" just marks it read.
|
|
94
|
+
infra_unreachable: { icon: 'i-lucide-plug-zap', color: 'error' },
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
// Per-type primary-action label. An exhaustive Record keyed off the notification
|
|
@@ -114,6 +118,7 @@ const ACTION_KEYS: Record<Notification['type'], string> = {
|
|
|
114
118
|
platform_health: 'layout.notifications.action.platform_health',
|
|
115
119
|
budget_paused: 'layout.notifications.action.budget_paused',
|
|
116
120
|
key_drift: 'layout.notifications.action.key_drift',
|
|
121
|
+
infra_unreachable: 'layout.notifications.action.infra_unreachable',
|
|
117
122
|
}
|
|
118
123
|
|
|
119
124
|
/** The localized primary-action label for a notification (te()-guarded against a
|
|
@@ -6,6 +6,8 @@ import { agentKindMeta } from '~/utils/catalog'
|
|
|
6
6
|
import StepRestartControl from '~/components/panels/StepRestartControl.vue'
|
|
7
7
|
import StepMetadataCard from '~/components/panels/StepMetadataCard.vue'
|
|
8
8
|
import StepTestReport from '~/components/panels/StepTestReport.vue'
|
|
9
|
+
import StepEffortReport from '~/components/panels/StepEffortReport.vue'
|
|
10
|
+
import StepFragmentAdherence from '~/components/panels/StepFragmentAdherence.vue'
|
|
9
11
|
import EnvironmentStatusPanel from '~/components/environments/EnvironmentStatusPanel.vue'
|
|
10
12
|
import FrontendBindingsResolved from '~/components/panels/inspector/FrontendBindingsResolved.vue'
|
|
11
13
|
import { UI_TESTER_AGENT_KIND } from '@cat-factory/contracts'
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
segmentPct,
|
|
17
17
|
trendMagnitude,
|
|
18
18
|
} from './ReportsPanel.logic'
|
|
19
|
+
import ReportsSpendBreakdown from '~/components/panels/ReportsSpendBreakdown.vue'
|
|
19
20
|
|
|
20
21
|
// Reports: cross-cutting usage analytics for the active account — where the spend and the
|
|
21
22
|
// work actually go. Spend per model and agent kind, spend + run activity per workspace /
|
|
@@ -8,6 +8,7 @@ import { showOverrideField } from '~/utils/uiMode'
|
|
|
8
8
|
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
9
9
|
import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
|
|
10
10
|
import TaskAprioriBranches from '~/components/panels/inspector/TaskAprioriBranches.vue'
|
|
11
|
+
import DocReferenceRepos from '~/components/panels/inspector/DocReferenceRepos.vue'
|
|
11
12
|
|
|
12
13
|
const props = defineProps<{ block: Block }>()
|
|
13
14
|
|
|
@@ -12,6 +12,7 @@ import { computed } from 'vue'
|
|
|
12
12
|
import type { Pipeline } from '~/types/domain'
|
|
13
13
|
import { agentKindMeta } from '~/utils/catalog'
|
|
14
14
|
import { pipelineDisplaySteps, pipelineGateCount } from '~/utils/pipeline'
|
|
15
|
+
import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
|
|
15
16
|
|
|
16
17
|
const props = defineProps<{ pipeline: Pipeline }>()
|
|
17
18
|
const { t } = useI18n()
|
|
@@ -24,6 +24,7 @@ import { subtaskIconClass } from '~/utils/pipelineRender'
|
|
|
24
24
|
import { activeChunkLabels, chunkReviewPercent, hasNoSlicePlan } from '~/utils/prReviewProgress'
|
|
25
25
|
import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
|
|
26
26
|
import StepRunMeta from '~/components/panels/StepRunMeta.vue'
|
|
27
|
+
import StepFragmentAdherence from '~/components/panels/StepFragmentAdherence.vue'
|
|
27
28
|
|
|
28
29
|
const execution = useExecutionStore()
|
|
29
30
|
const board = useBoardStore()
|
|
@@ -43,6 +43,7 @@ const ROUTABLE = computed<{ type: NotificationType; label: string }[]>(() => [
|
|
|
43
43
|
{ type: 'pr_review_ready', label: t('slack.routable.pr_review_ready') },
|
|
44
44
|
{ type: 'initiative', label: t('slack.routable.initiative') },
|
|
45
45
|
{ type: 'platform_health', label: t('slack.routable.platform_health') },
|
|
46
|
+
{ type: 'infra_unreachable', label: t('slack.routable.infra_unreachable') },
|
|
46
47
|
])
|
|
47
48
|
|
|
48
49
|
/** Notification-role options for a mapped member (drives who gets @-mentioned). */
|
|
@@ -75,6 +76,7 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
|
|
|
75
76
|
budget_paused: { enabled: false, channel: '' },
|
|
76
77
|
// In-app only (not in ROUTABLE), but the map is exhaustive over the type.
|
|
77
78
|
key_drift: { enabled: false, channel: '' },
|
|
79
|
+
infra_unreachable: { enabled: false, channel: '' },
|
|
78
80
|
})
|
|
79
81
|
const mentionsEnabled = ref(false)
|
|
80
82
|
// Editable member rows carry a client-only stable `uid` (see `slackMemberMapping`) so
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ApiContract } from '@toad-contracts/core'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { type SendParams, withoutUndefinedQueryParams } from '~/composables/api/client'
|
|
4
|
+
|
|
5
|
+
// An omitted optional query param used to reach the server as `key=`: the contract client
|
|
6
|
+
// serialises with `fast-querystring`, whose `stringify({ blockId: undefined })` is `'blockId='`,
|
|
7
|
+
// and request validation waves the key through because `v.optional(...)` accepts `undefined`. On
|
|
8
|
+
// `listTasksContract`, whose `blockId` carries a `minLength(1)`, that made every unscoped
|
|
9
|
+
// `listTasks()` a guaranteed 400. These lock the strip in.
|
|
10
|
+
|
|
11
|
+
// The helper is contract-generic and only ever reads `queryParams`, so the cases below describe
|
|
12
|
+
// request params structurally rather than picking a real contract per shape.
|
|
13
|
+
type Params = Record<string, unknown>
|
|
14
|
+
const strip = (params: Params): Params =>
|
|
15
|
+
withoutUndefinedQueryParams(params as SendParams<ApiContract>) as Params
|
|
16
|
+
|
|
17
|
+
describe('withoutUndefinedQueryParams', () => {
|
|
18
|
+
it('drops undefined-valued query keys so they never reach the query string', () => {
|
|
19
|
+
const out = strip({ pathPrefix: '/workspaces/ws_1', queryParams: { blockId: undefined } })
|
|
20
|
+
expect(out.queryParams).toEqual({})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// The guard against "fix" it with a falsy check: 0, false and '' are all values a caller
|
|
24
|
+
// deliberately sent, and only `undefined` means absent.
|
|
25
|
+
it('keeps defined values, including falsy ones a caller meant to send', () => {
|
|
26
|
+
const out = strip({ queryParams: { blockId: 'blk_1', page: 0, all: false, q: '' } })
|
|
27
|
+
expect(out.queryParams).toEqual({ blockId: 'blk_1', page: 0, all: false, q: '' })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('drops only the undefined keys from a mixed set', () => {
|
|
31
|
+
const out = strip({ queryParams: { window: '7d', workspaceId: undefined } })
|
|
32
|
+
expect(out.queryParams).toEqual({ window: '7d' })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('leaves other request params untouched', () => {
|
|
36
|
+
const params = {
|
|
37
|
+
pathPrefix: '/workspaces/ws_1',
|
|
38
|
+
pathParams: { source: 'jira' },
|
|
39
|
+
body: { a: 1 },
|
|
40
|
+
}
|
|
41
|
+
expect(strip(params)).toEqual(params)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('returns the same object when there is nothing to strip', () => {
|
|
45
|
+
const params = { queryParams: { blockId: 'blk_1' } }
|
|
46
|
+
expect(strip(params)).toBe(params)
|
|
47
|
+
const noQuery = { pathPrefix: '/workspaces/ws_1' }
|
|
48
|
+
expect(strip(noQuery)).toBe(noQuery)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -71,6 +71,31 @@ export function createApiClient(): WretchInstance {
|
|
|
71
71
|
])
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Drop query keys whose value is `undefined`, so an omitted optional param is absent from the
|
|
76
|
+
* query string instead of present-but-empty.
|
|
77
|
+
*
|
|
78
|
+
* `sendByApiContract` serialises with `fast-querystring`, and `stringify({ blockId: undefined })`
|
|
79
|
+
* is `'blockId='`. Request validation does not catch it, because `v.optional(...)` accepts
|
|
80
|
+
* `undefined` and the key only becomes empty on the way out. The server then validates the parsed
|
|
81
|
+
* `''`, so any optional param carrying a `minLength(1)` rejects the whole request with a 400. That
|
|
82
|
+
* is what made an unscoped `listTasks()` uncallable. Params with no length check are luckier but
|
|
83
|
+
* still wrong: the handler reads `''` where it asked for absence.
|
|
84
|
+
*
|
|
85
|
+
* Stripping at this one chokepoint fixes every contract at once, which is the point. Writing
|
|
86
|
+
* `queryParams: { foo }` for an optional `foo` is the obvious thing to write and it should work,
|
|
87
|
+
* rather than each call site remembering to spread the key in conditionally.
|
|
88
|
+
*/
|
|
89
|
+
export function withoutUndefinedQueryParams<T extends ApiContract>(
|
|
90
|
+
params: SendParams<T>,
|
|
91
|
+
): SendParams<T> {
|
|
92
|
+
const query = (params as { queryParams?: Record<string, unknown> }).queryParams
|
|
93
|
+
if (!query) return params
|
|
94
|
+
const present = Object.entries(query).filter(([, value]) => value !== undefined)
|
|
95
|
+
if (present.length === Object.keys(query).length) return params
|
|
96
|
+
return { ...params, queryParams: Object.fromEntries(present) }
|
|
97
|
+
}
|
|
98
|
+
|
|
74
99
|
/**
|
|
75
100
|
* Send a contract request and unwrap to the success body (or throw the typed error).
|
|
76
101
|
* The public signature preserves per-contract inference for callers; inside,
|
|
@@ -82,7 +107,7 @@ export async function sendContract<T extends ApiContract>(
|
|
|
82
107
|
contract: T,
|
|
83
108
|
params: SendParams<T>,
|
|
84
109
|
): Promise<SuccessBodyOf<T>> {
|
|
85
|
-
const outcome = await sendByApiContract(client, contract, params)
|
|
110
|
+
const outcome = await sendByApiContract(client, contract, withoutUndefinedQueryParams(params))
|
|
86
111
|
if (outcome.error) {
|
|
87
112
|
const error = outcome.error
|
|
88
113
|
// A contract-declared non-2xx is reported as a plain `{ statusCode, headers, body }`
|
|
@@ -111,6 +111,12 @@ export function useWorkspaceStream() {
|
|
|
111
111
|
// inspector's "Test environment creation" control shows the live stage + final
|
|
112
112
|
// outcome in place without a refetch. No board block.
|
|
113
113
|
environmentTest.upsert(event.run)
|
|
114
|
+
} else if (event.type === 'infraSetup') {
|
|
115
|
+
// The reachability watcher found a configured infrastructure area dead (or answering again) —
|
|
116
|
+
// patch that one area so the setup banner appears/clears immediately. A full refresh would
|
|
117
|
+
// pay the whole snapshot aggregate for a one-field delta, and the projection the snapshot
|
|
118
|
+
// recomputes already folds the same recorded state.
|
|
119
|
+
workspace.patchInfraSetup(event.area, event.status, event.detail)
|
|
114
120
|
} else if (event.type === 'notification') {
|
|
115
121
|
// A PR needs a merge decision, a pipeline finished, or CI gave up — patch the
|
|
116
122
|
// inbox + per-block badge in place (resolved ones drop out of the inbox).
|
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
|
+
}
|