@cat-factory/app 0.256.2 → 0.256.3
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 +4 -0
- package/app/components/board/nodes/BlockNode.vue +3 -7
- package/app/components/board/nodes/InitiativeCard.vue +1 -1
- package/app/components/focus/BlockFocusView.vue +1 -1
- package/app/components/outcome/OutcomeSummaryWindow.vue +1 -2
- package/app/components/panels/InspectorPanel.vue +6 -11
- package/app/components/pipeline/PipelineProgress.vue +11 -10
- package/app/components/settings/KubernetesEngineForm.vue +98 -46
- package/app/components/spec/ServiceSpecWindow.vue +5 -4
- package/app/docs/architecture.md +1 -1
- package/app/modular/panels/inspector.ts +3 -3
- package/app/stores/board/placement.ts +32 -8
- package/app/utils/badge.ts +14 -0
- package/app/utils/catalog.ts +2 -1
- package/app/utils/initiative.ts +1 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -161,6 +161,10 @@ The failure is silent, which is why this is a rule rather than a preference. An
|
|
|
161
161
|
|
|
162
162
|
`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.
|
|
163
163
|
|
|
164
|
+
### Type a chip map with `BadgeColor`, never `string`
|
|
165
|
+
|
|
166
|
+
A status → chip map feeding a `<UBadge :color="…">` types its values as `BadgeColor` (`utils/badge.ts`), which is derived from `UBadge`'s own prop type rather than restated as a literal union. Typed `string`, the binding does not compile and the reflex is `as any` at each call site: seven of them had accumulated. That cast also accepts a colour Nuxt UI does not define, which renders as an unstyled badge with nothing failing.
|
|
167
|
+
|
|
164
168
|
## Interface modes (basic / advanced)
|
|
165
169
|
|
|
166
170
|
The SPA renders at one of two **interface tiers**. `basic` (the default) is the everyday
|
|
@@ -392,13 +392,9 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
392
392
|
</div>
|
|
393
393
|
</div>
|
|
394
394
|
<div class="flex items-center gap-1">
|
|
395
|
-
<UBadge
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
size="sm"
|
|
399
|
-
:title="statusHint"
|
|
400
|
-
>{{ statusLabel }}</UBadge
|
|
401
|
-
>
|
|
395
|
+
<UBadge :color="statusMeta.chip" variant="subtle" size="sm" :title="statusHint">{{
|
|
396
|
+
statusLabel
|
|
397
|
+
}}</UBadge>
|
|
402
398
|
<!-- Board-authoring buttons (create task / from issue / recurring / initiative)
|
|
403
399
|
are `board.write`, hidden for a read-only viewer, who keeps the status badge
|
|
404
400
|
(the one view-only affordance here). -->
|
|
@@ -97,7 +97,7 @@ function onHandle(e: PointerEvent) {
|
|
|
97
97
|
<UIcon name="i-lucide-milestone" class="h-4 w-4 shrink-0 text-indigo-400" />
|
|
98
98
|
<div class="text-xs font-semibold text-white">{{ block.title }}</div>
|
|
99
99
|
</div>
|
|
100
|
-
<UBadge :color="INITIATIVE_STATUS_CHIPS[status]
|
|
100
|
+
<UBadge :color="INITIATIVE_STATUS_CHIPS[status]" variant="subtle" size="sm">
|
|
101
101
|
{{ statusLabel }}
|
|
102
102
|
</UBadge>
|
|
103
103
|
</div>
|
|
@@ -124,7 +124,7 @@ function openApprovalFor(approvalId: string) {
|
|
|
124
124
|
{{ t('focus.typeSubtitle', { type: typeMeta.label }) }}
|
|
125
125
|
</div>
|
|
126
126
|
</div>
|
|
127
|
-
<UBadge :color="statusMeta.chip
|
|
127
|
+
<UBadge :color="statusMeta.chip" variant="subtle" class="ms-2">
|
|
128
128
|
{{ statusMeta.label }}
|
|
129
129
|
</UBadge>
|
|
130
130
|
<div class="ms-auto flex items-center gap-2">
|
|
@@ -39,6 +39,7 @@ import ArtifactLightbox from '~/components/media/ArtifactLightbox.vue'
|
|
|
39
39
|
import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
|
|
40
40
|
import MarkdownProse from '~/components/common/MarkdownProse.vue'
|
|
41
41
|
import EmptyState from '~/components/common/EmptyState.vue'
|
|
42
|
+
import type { BadgeColor } from '~/utils/badge'
|
|
42
43
|
|
|
43
44
|
const board = useBoardStore()
|
|
44
45
|
const documents = useDocumentsStore()
|
|
@@ -105,8 +106,6 @@ const DISPOSITION_KEYS: Record<OutcomeDisposition, string> = {
|
|
|
105
106
|
not_run: 'outcome.disposition.not_run',
|
|
106
107
|
unknown: 'outcome.disposition.unknown',
|
|
107
108
|
}
|
|
108
|
-
/** The badge palette, named once so every colour map below is checked against it. */
|
|
109
|
-
type BadgeColor = 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
|
|
110
109
|
|
|
111
110
|
const DISPOSITION_COLOR: Record<OutcomeDisposition, BadgeColor> = {
|
|
112
111
|
merged: 'success',
|
|
@@ -328,7 +328,7 @@ const showOriginalDescription = ref(false)
|
|
|
328
328
|
<div>
|
|
329
329
|
<div class="text-sm font-semibold text-white">{{ block.title }}</div>
|
|
330
330
|
<div class="mt-0.5 flex items-center gap-1.5">
|
|
331
|
-
<UBadge :color="statusMeta.chip
|
|
331
|
+
<UBadge :color="statusMeta.chip" variant="subtle" size="sm">
|
|
332
332
|
{{ statusLabel }}
|
|
333
333
|
</UBadge>
|
|
334
334
|
<span class="text-[10px] uppercase tracking-wide text-slate-500">{{ level }}</span>
|
|
@@ -521,18 +521,13 @@ const showOriginalDescription = ref(false)
|
|
|
521
521
|
wrapper). Replaces the pre-slice-4 `v-if` fan; `subject-key` is the block
|
|
522
522
|
id, so switching selections remounts panel content (matching the old
|
|
523
523
|
per-panel `:key`). A consumer contributes its own panels to the SAME
|
|
524
|
-
group via `registerAppModule`.
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
`default: null`, which Volar narrows to `null`, so passing a typed
|
|
529
|
-
`Block | null` is rejected at compile time. `unknown` is the real
|
|
530
|
-
runtime contract; `as any` is the minimal unblock until the binding
|
|
531
|
-
types the prop explicitly (filed upstream — see the slice-4 residuals
|
|
532
|
-
in backend/docs/adr/0049-modular-vue-adoption.md). -->
|
|
524
|
+
group via `registerAppModule`. `subject` used to need an `as any`: the
|
|
525
|
+
outlet's `default: null` narrowed the declared `PropType<unknown>` to
|
|
526
|
+
`null`, rejecting a typed `Block | null`. The published prop type now
|
|
527
|
+
resolves to `unknown`, so the binding passes through unasserted. -->
|
|
533
528
|
<PanelsOutlet
|
|
534
529
|
:group="inspectorPanels"
|
|
535
|
-
:subject="
|
|
530
|
+
:subject="block ?? null"
|
|
536
531
|
:subject-key="block?.id ?? ''"
|
|
537
532
|
/>
|
|
538
533
|
|
|
@@ -16,6 +16,7 @@ import { prReviewPhase } from '~/utils/prReviewProgress'
|
|
|
16
16
|
import StepMetricsBar from '~/components/observability/StepMetricsBar.vue'
|
|
17
17
|
import PrReviewPhaseBadge from '~/components/prReview/PrReviewPhaseBadge.vue'
|
|
18
18
|
import { useNowTick, stepDurationLabel } from '~/composables/useStepTimer'
|
|
19
|
+
import type { BadgeColor } from '~/utils/badge'
|
|
19
20
|
|
|
20
21
|
const props = defineProps<{ instance: ExecutionInstance }>()
|
|
21
22
|
const emit = defineEmits<{
|
|
@@ -147,15 +148,15 @@ const STATE_META = computed<Record<AgentState, { label: string; color: string; i
|
|
|
147
148
|
)
|
|
148
149
|
|
|
149
150
|
/** Visual language for the pipeline instance as a whole. */
|
|
150
|
-
const STATUS_META = computed<
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
)
|
|
151
|
+
const STATUS_META = computed<
|
|
152
|
+
Record<ExecutionInstance['status'], { label: string; chip: BadgeColor }>
|
|
153
|
+
>(() => ({
|
|
154
|
+
running: { label: t('pipeline.progress.status.running'), chip: 'primary' },
|
|
155
|
+
blocked: { label: t('pipeline.progress.status.blocked'), chip: 'warning' },
|
|
156
|
+
paused: { label: t('pipeline.progress.status.paused'), chip: 'neutral' },
|
|
157
|
+
done: { label: t('pipeline.progress.status.done'), chip: 'success' },
|
|
158
|
+
failed: { label: t('pipeline.progress.status.failed'), chip: 'error' },
|
|
159
|
+
}))
|
|
159
160
|
|
|
160
161
|
const steps = computed(() => props.instance.steps)
|
|
161
162
|
const total = computed(() => steps.value.length)
|
|
@@ -254,7 +255,7 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
254
255
|
<!-- summary -->
|
|
255
256
|
<div class="rounded-xl border border-slate-800 bg-slate-900/60 p-4">
|
|
256
257
|
<div class="flex flex-wrap items-center gap-3">
|
|
257
|
-
<UBadge :color="statusMeta.chip
|
|
258
|
+
<UBadge :color="statusMeta.chip" variant="subtle">{{ statusMeta.label }}</UBadge>
|
|
258
259
|
<span class="text-sm text-slate-300">
|
|
259
260
|
<i18n-t keypath="pipeline.progress.agentsComplete" tag="span" scope="global">
|
|
260
261
|
<template #completed>
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
InfraEngine,
|
|
15
15
|
InfraHandlerConfig,
|
|
16
16
|
} from '@cat-factory/contracts'
|
|
17
|
+
import { isKubernetesUrlSource } from '@cat-factory/contracts'
|
|
17
18
|
import type { K3sSetupPrefill } from '~/stores/ui'
|
|
18
19
|
|
|
19
20
|
// The kube branch of the discriminated handler config this form produces (the `local-k3s` /
|
|
@@ -22,6 +23,10 @@ import type { K3sSetupPrefill } from '~/stores/ui'
|
|
|
22
23
|
// `as never` cast, so a wrong config shape is caught at the call site instead of server-side.
|
|
23
24
|
type KubeHandlerConfig = Extract<InfraHandlerConfig, { engine: 'local-k3s' | 'remote-kubernetes' }>
|
|
24
25
|
type KubeHandlerPayload = { config: KubeHandlerConfig; secrets: Record<string, string> }
|
|
26
|
+
/** The engine connection block itself, read off the variant so it cannot drift from it. */
|
|
27
|
+
type KubeEngineConfig = KubeHandlerConfig['kubernetes']
|
|
28
|
+
/** How the environment URL is derived: its own discriminated union, keyed by `source`. */
|
|
29
|
+
type KubeUrlSource = KubeEngineConfig['url']
|
|
25
30
|
|
|
26
31
|
const props = defineProps<{
|
|
27
32
|
/** `local-k3s` or `remote-kubernetes` — the engine this handler is registered under. */
|
|
@@ -46,12 +51,9 @@ const emit = defineEmits<{
|
|
|
46
51
|
|
|
47
52
|
const { t } = useI18n()
|
|
48
53
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
| 'serviceStatus'
|
|
53
|
-
| 'gatewayStatus'
|
|
54
|
-
| 'httpRouteStatus'
|
|
54
|
+
/** The `source` discriminants, read off the contract union: a source added there makes
|
|
55
|
+
* `buildUrl`'s switch non-exhaustive rather than leaving this list quietly short. */
|
|
56
|
+
type UrlSource = KubeUrlSource['source']
|
|
55
57
|
|
|
56
58
|
const form = reactive({
|
|
57
59
|
label: '',
|
|
@@ -101,23 +103,34 @@ watch(
|
|
|
101
103
|
(h) => {
|
|
102
104
|
const cfg = h?.config
|
|
103
105
|
if (!cfg || (cfg.engine !== 'local-k3s' && cfg.engine !== 'remote-kubernetes')) return
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
// Narrowing `cfg.engine` above types `kubernetes` as the engine config, so these read the
|
|
107
|
+
// contract directly. They used to widen it to a `Record` and `typeof`-guard every field,
|
|
108
|
+
// which re-derived at runtime what the discriminated union already states.
|
|
109
|
+
const k = cfg.kubernetes
|
|
110
|
+
form.label = k.label
|
|
111
|
+
form.apiServerUrl = k.apiServerUrl
|
|
112
|
+
form.caCertPem = k.caCertPem ?? ''
|
|
108
113
|
form.insecureSkipTlsVerify = k.insecureSkipTlsVerify === true
|
|
109
|
-
form.namespaceTemplate =
|
|
110
|
-
form.imageTemplate =
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
form
|
|
116
|
-
|
|
117
|
-
form
|
|
118
|
-
form
|
|
119
|
-
|
|
120
|
-
|
|
114
|
+
form.namespaceTemplate = k.namespaceTemplate ?? ''
|
|
115
|
+
form.imageTemplate = k.imageTemplate ?? ''
|
|
116
|
+
// Each url field is read off the ONE variant that carries it, so a field belonging to a
|
|
117
|
+
// different `source` cannot silently populate the form.
|
|
118
|
+
//
|
|
119
|
+
// Typed as present with an on-union `source`, read as neither: both were true when the
|
|
120
|
+
// connect form admitted this config, and the value has been through storage since — which is
|
|
121
|
+
// exactly why the backend re-parses a stored `providerConfig` rather than asserting it, and
|
|
122
|
+
// this form is where an operator REPAIRS one that drifted. An unrecognised source falls back
|
|
123
|
+
// to the form's default, because `buildUrl` has no branch to build a config out of one.
|
|
124
|
+
const url: KubeUrlSource | undefined = k.url
|
|
125
|
+
const source = url?.source
|
|
126
|
+
form.urlSource = isKubernetesUrlSource(source) ? source : 'ingressTemplate'
|
|
127
|
+
form.hostTemplate = url?.source === 'ingressTemplate' ? url.hostTemplate : ''
|
|
128
|
+
form.ingressName = url?.source === 'ingressStatus' ? (url.ingressName ?? '') : ''
|
|
129
|
+
form.serviceName = url?.source === 'serviceStatus' ? url.serviceName : ''
|
|
130
|
+
form.servicePort = url?.source === 'serviceStatus' && url.port != null ? String(url.port) : ''
|
|
131
|
+
form.gatewayName = url?.source === 'gatewayStatus' ? (url.gatewayName ?? '') : ''
|
|
132
|
+
form.httpRouteName = url?.source === 'httpRouteStatus' ? (url.httpRouteName ?? '') : ''
|
|
133
|
+
form.urlScheme = url?.scheme ?? 'default'
|
|
121
134
|
},
|
|
122
135
|
{ immediate: true },
|
|
123
136
|
)
|
|
@@ -222,42 +235,81 @@ const connectBlockedReason = computed(() => {
|
|
|
222
235
|
return t('settings.infrastructure.kubernetesEngine.invalidPort')
|
|
223
236
|
})
|
|
224
237
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
238
|
+
/**
|
|
239
|
+
* The URL-derivation block, built as the contract's discriminated union rather than a `Record`.
|
|
240
|
+
* Each branch returns its OWN variant, so the fields a source carries are checked against that
|
|
241
|
+
* source: setting `hostTemplate` on a `serviceStatus` url stops compiling instead of shipping a
|
|
242
|
+
* config the backend rejects. `urlScheme` is the one field every variant shares, and the
|
|
243
|
+
* 'default' sentinel means "omit it and let the derivation decide".
|
|
244
|
+
*/
|
|
245
|
+
function buildUrl(): KubeUrlSource {
|
|
246
|
+
const scheme = form.urlScheme === 'default' ? {} : { scheme: form.urlScheme }
|
|
247
|
+
switch (form.urlSource) {
|
|
248
|
+
case 'ingressTemplate':
|
|
249
|
+
return { source: 'ingressTemplate', hostTemplate: form.hostTemplate.trim(), ...scheme }
|
|
250
|
+
case 'ingressStatus': {
|
|
251
|
+
const ingressName = form.ingressName.trim()
|
|
252
|
+
return { source: 'ingressStatus', ...(ingressName ? { ingressName } : {}), ...scheme }
|
|
253
|
+
}
|
|
254
|
+
case 'serviceStatus': {
|
|
255
|
+
const port = Number(form.servicePort)
|
|
256
|
+
return {
|
|
257
|
+
source: 'serviceStatus',
|
|
258
|
+
serviceName: form.serviceName.trim(),
|
|
259
|
+
...(form.servicePort.trim() && Number.isInteger(port) ? { port } : {}),
|
|
260
|
+
...scheme,
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
case 'gatewayStatus': {
|
|
264
|
+
const gatewayName = form.gatewayName.trim()
|
|
265
|
+
return { source: 'gatewayStatus', ...(gatewayName ? { gatewayName } : {}), ...scheme }
|
|
266
|
+
}
|
|
267
|
+
case 'httpRouteStatus': {
|
|
268
|
+
const httpRouteName = form.httpRouteName.trim()
|
|
269
|
+
return { source: 'httpRouteStatus', ...(httpRouteName ? { httpRouteName } : {}), ...scheme }
|
|
270
|
+
}
|
|
271
|
+
default:
|
|
272
|
+
return refuseUnknownUrlSource(form.urlSource)
|
|
239
273
|
}
|
|
240
|
-
|
|
241
|
-
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* A `source` outside the contract union, which the switch above therefore cannot build.
|
|
278
|
+
*
|
|
279
|
+
* The parameter is `never`, so this keeps BOTH properties at once: a source added to the contract
|
|
280
|
+
* without a case above still fails the typecheck (the argument stops being `never`), while a value
|
|
281
|
+
* the union never had is refused at runtime instead of falling off the end of the switch. That end
|
|
282
|
+
* is what the `default` exists to close: it returned `undefined`, which `buildPayload` then sent as
|
|
283
|
+
* the config's `url` for the backend to reject as a missing block.
|
|
284
|
+
*
|
|
285
|
+
* Deliberately NOT mapped onto a current source. Nothing here knows which one was meant, and a
|
|
286
|
+
* guess would silently rewrite the operator's URL derivation to something they never picked.
|
|
287
|
+
*/
|
|
288
|
+
function refuseUnknownUrlSource(source: never): never {
|
|
289
|
+
throw new Error(`Unsupported Kubernetes URL source '${String(source)}'`)
|
|
242
290
|
}
|
|
243
291
|
|
|
244
292
|
function buildPayload(): KubeHandlerPayload {
|
|
245
|
-
|
|
293
|
+
// Built as the contract type rather than assembled into a `Record` and asserted: an optional
|
|
294
|
+
// field is a conditional SPREAD, so a key the config does not declare (or a value of the
|
|
295
|
+
// wrong type) fails the build here instead of surfacing as a server-side validation refusal.
|
|
296
|
+
const caCertPem = form.caCertPem.trim()
|
|
297
|
+
const namespaceTemplate = form.namespaceTemplate.trim()
|
|
298
|
+
const imageTemplate = form.imageTemplate.trim()
|
|
299
|
+
const kubernetes: KubeEngineConfig = {
|
|
246
300
|
label: form.label.trim(),
|
|
247
301
|
apiServerUrl: form.apiServerUrl.trim(),
|
|
248
302
|
url: buildUrl(),
|
|
303
|
+
...(caCertPem ? { caCertPem } : {}),
|
|
304
|
+
...(form.insecureSkipTlsVerify ? { insecureSkipTlsVerify: true } : {}),
|
|
305
|
+
...(namespaceTemplate ? { namespaceTemplate } : {}),
|
|
306
|
+
...(imageTemplate ? { imageTemplate } : {}),
|
|
249
307
|
}
|
|
250
|
-
if (form.caCertPem.trim()) kubernetes.caCertPem = form.caCertPem.trim()
|
|
251
|
-
if (form.insecureSkipTlsVerify) kubernetes.insecureSkipTlsVerify = true
|
|
252
|
-
if (form.namespaceTemplate.trim()) kubernetes.namespaceTemplate = form.namespaceTemplate.trim()
|
|
253
|
-
if (form.imageTemplate.trim()) kubernetes.imageTemplate = form.imageTemplate.trim()
|
|
254
|
-
// One honest assertion at the boundary that actually builds the shape (the reactive form is
|
|
255
|
-
// dynamically assembled, then validated server-side); the emitted config flows typed onward.
|
|
256
308
|
// OMIT the token when the field is blank so the backend preserves the saved one (a blank
|
|
257
309
|
// secret means "keep it") — only a typed value is sent, and it replaces the stored token.
|
|
258
310
|
const token = apiToken.value.trim()
|
|
259
311
|
return {
|
|
260
|
-
config: { engine: props.engine, kubernetes }
|
|
312
|
+
config: { engine: props.engine, kubernetes },
|
|
261
313
|
secrets: token ? { [KUBERNETES_ENV_TOKEN_SECRET_KEY]: token } : {},
|
|
262
314
|
}
|
|
263
315
|
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
summarizeSpecStates,
|
|
22
22
|
type RequirementStateFilter,
|
|
23
23
|
} from './ServiceSpecWindow.logic'
|
|
24
|
+
import type { BadgeColor } from '~/utils/badge'
|
|
24
25
|
|
|
25
26
|
const { t } = useI18n()
|
|
26
27
|
const board = useBoardStore()
|
|
@@ -115,7 +116,7 @@ function retry() {
|
|
|
115
116
|
|
|
116
117
|
// Exhaustive priority → label/chip map. Literal `t()` keys keep the typed-key drift
|
|
117
118
|
// guard live, vs a runtime-built `spec.priority.${value}`.
|
|
118
|
-
const PRIORITY_META: Record<RequirementPriority, { label: string; chip:
|
|
119
|
+
const PRIORITY_META: Record<RequirementPriority, { label: string; chip: BadgeColor }> = {
|
|
119
120
|
must: { label: t('spec.priority.must'), chip: 'error' },
|
|
120
121
|
should: { label: t('spec.priority.should'), chip: 'warning' },
|
|
121
122
|
could: { label: t('spec.priority.could'), chip: 'neutral' },
|
|
@@ -131,7 +132,7 @@ const KIND_LABELS: Record<RequirementKind, string> = {
|
|
|
131
132
|
// Exhaustive implementation-state → presentation map. `established` is the only state that
|
|
132
133
|
// means "the service is observed to do this"; everything else is a behaviour that has been
|
|
133
134
|
// agreed and not yet seen to hold, which must never read as standing behaviour.
|
|
134
|
-
const STATE_META: Record<RequirementState, { label: string; chip:
|
|
135
|
+
const STATE_META: Record<RequirementState, { label: string; chip: BadgeColor; icon: string }> = {
|
|
135
136
|
established: {
|
|
136
137
|
label: t('spec.state.established'),
|
|
137
138
|
chip: 'success',
|
|
@@ -448,7 +449,7 @@ function kindLabel(item: RequirementItem): string {
|
|
|
448
449
|
<!-- implementation state: agreed vs observed to hold. The distinction the
|
|
449
450
|
build prompt and the tester act on, so a reader must see it too. -->
|
|
450
451
|
<UBadge
|
|
451
|
-
:color="stateMeta(req).chip
|
|
452
|
+
:color="stateMeta(req).chip"
|
|
452
453
|
variant="subtle"
|
|
453
454
|
size="sm"
|
|
454
455
|
:icon="stateMeta(req).icon"
|
|
@@ -456,7 +457,7 @@ function kindLabel(item: RequirementItem): string {
|
|
|
456
457
|
>
|
|
457
458
|
{{ stateMeta(req).label }}
|
|
458
459
|
</UBadge>
|
|
459
|
-
<UBadge :color="priorityMeta(req).chip
|
|
460
|
+
<UBadge :color="priorityMeta(req).chip" variant="subtle" size="sm">
|
|
460
461
|
{{ priorityMeta(req).label }}
|
|
461
462
|
</UBadge>
|
|
462
463
|
<UBadge color="neutral" variant="subtle" size="sm">{{ kindLabel(req) }}</UBadge>
|
package/app/docs/architecture.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
How the SPA stays in sync with the backend. The app is a **thin client**: it holds
|
|
4
4
|
no business logic, calls the Worker for every mutation, and hydrates its stores
|
|
5
5
|
from server snapshots plus pushed events. For the high-level tour see
|
|
6
|
-
[
|
|
6
|
+
[`frontend/app/README.md`](../../README.md).
|
|
7
7
|
|
|
8
8
|
## The three paths
|
|
9
9
|
|
|
@@ -4,8 +4,8 @@ import type { PanelEntry } from '@modular-vue/core'
|
|
|
4
4
|
import type { Block } from '~/types/domain'
|
|
5
5
|
|
|
6
6
|
/** The engine's opaque component type on a `PanelEntry` (the neutral `UiComponent`,
|
|
7
|
-
* which isn't exported by name).
|
|
8
|
-
*
|
|
7
|
+
* which isn't exported by name). Referenced structurally off `PanelEntry` so a
|
|
8
|
+
* `defineComponent` result is checked against it rather than asserted into it. */
|
|
9
9
|
type PanelComponent = PanelEntry<Block>['component']
|
|
10
10
|
import {
|
|
11
11
|
INSPECTOR_PANELS_SLOT,
|
|
@@ -61,7 +61,7 @@ function blockPanel(component: Component, id: InspectorPanelId): PanelComponent
|
|
|
61
61
|
const block = usePanelSubject<Block>()
|
|
62
62
|
return () => h(component, { block: block.value })
|
|
63
63
|
},
|
|
64
|
-
})
|
|
64
|
+
})
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/** Exhaustive id → sub-panel map. Typed `Record<InspectorPanelId, …>` so adding a
|
|
@@ -3,9 +3,29 @@ import { useServicesStore } from '~/stores/services'
|
|
|
3
3
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
4
|
import { createBoardDependencies } from './dependencies'
|
|
5
5
|
import { moveRefusalKey } from './moveRefusal'
|
|
6
|
+
import type { Block } from '~/types/domain'
|
|
6
7
|
import type { BoardWriteContext } from './context'
|
|
7
8
|
import { UNDO_WINDOW_MS } from './context'
|
|
8
9
|
|
|
10
|
+
/** A field `updateBlock` may patch: the contract's key set, nothing wider. */
|
|
11
|
+
type PatchKey = keyof UpdateBlockInput
|
|
12
|
+
/** The pre-patch values of the fields one call touches, for the rollback. */
|
|
13
|
+
type PatchSnapshot = Partial<Record<PatchKey, unknown>>
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* View a block through the patch key set, for the optimistic write's snapshot + rollback.
|
|
17
|
+
*
|
|
18
|
+
* A plain widening, not an assertion: the rollback is inherently keyed by whatever the caller
|
|
19
|
+
* put in the patch, so it has to index the block dynamically, and this bounds that indexing to
|
|
20
|
+
* the contract instead of the `Record<string, unknown>` it used to widen to. Two patch keys
|
|
21
|
+
* (`customTaskTypeFields` / `builtinTaskTypeFields`) are request-only, since the server folds
|
|
22
|
+
* them into the block's `taskTypeFields`, so the view is `Partial`: they read as absent going in
|
|
23
|
+
* and are cleared again by a rollback, which is what the untyped version did.
|
|
24
|
+
*/
|
|
25
|
+
function blockAsPatchable(block: Block): PatchSnapshot {
|
|
26
|
+
return block
|
|
27
|
+
}
|
|
28
|
+
|
|
9
29
|
/**
|
|
10
30
|
* The board's placement (drag/drop/reparent) and per-block edit operations — the writes that
|
|
11
31
|
* move a block or patch its fields, including the dependency edges. Extracted from
|
|
@@ -194,10 +214,13 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
194
214
|
// Snapshot ONLY the fields this patch touches so a rejected write restores them exactly
|
|
195
215
|
// (a patch may set several at once) rather than leaving a stale optimistic value stuck on
|
|
196
216
|
// screen with no feedback — the same rollback contract the other mutations here follow.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
217
|
+
// `Object.keys` is typed `string[]`, so the key set is narrowed to the patch contract once
|
|
218
|
+
// here; every read and write below then goes through `PatchKey`, and a key outside the
|
|
219
|
+
// contract cannot reach the block.
|
|
220
|
+
const keys = Object.keys(patch) as PatchKey[]
|
|
221
|
+
const prev: PatchSnapshot = {}
|
|
222
|
+
const before = blockAsPatchable(b)
|
|
223
|
+
for (const key of keys) prev[key] = before[key]
|
|
201
224
|
Object.assign(b, patch) // optimistic
|
|
202
225
|
try {
|
|
203
226
|
upsert(await api.updateBlock(useWorkspaceStore().requireId(), id, patch))
|
|
@@ -206,10 +229,11 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
206
229
|
// swaps in a fresh one) while the write was in flight, so `b` can be stale. Only revert
|
|
207
230
|
// fields that still hold OUR optimistic value, so a newer server value that landed
|
|
208
231
|
// mid-flight isn't clobbered by the rollback.
|
|
209
|
-
const
|
|
210
|
-
if (
|
|
211
|
-
|
|
212
|
-
|
|
232
|
+
const live = getBlock(id)
|
|
233
|
+
if (live) {
|
|
234
|
+
const cur = blockAsPatchable(live)
|
|
235
|
+
for (const key of keys) {
|
|
236
|
+
if (cur[key] === patch[key]) cur[key] = prev[key]
|
|
213
237
|
}
|
|
214
238
|
}
|
|
215
239
|
toast.add({
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { BadgeProps } from '@nuxt/ui'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The colour names a `UBadge` accepts, derived from the component's own prop type rather
|
|
5
|
+
* than restated as a literal union. Nuxt UI resolves the prop from the app config's badge
|
|
6
|
+
* theme, so deriving keeps a chip map honest if the deployment's palette gains or loses a
|
|
7
|
+
* colour, where a hand-written copy would just drift.
|
|
8
|
+
*
|
|
9
|
+
* A status → chip map types its values against this, which is what lets a `:color="…"`
|
|
10
|
+
* binding pass the value straight through. The maps used to be typed `string`, so every
|
|
11
|
+
* binding needed an `as any` to get past the prop's union; that cast also silently accepted
|
|
12
|
+
* a typo'd colour, which renders as an unstyled badge rather than failing the build.
|
|
13
|
+
*/
|
|
14
|
+
export type BadgeColor = NonNullable<BadgeProps['color']>
|
package/app/utils/catalog.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
BlockType,
|
|
8
8
|
TaskTypeMeta,
|
|
9
9
|
} from '~/types/domain'
|
|
10
|
+
import type { BadgeColor } from '~/utils/badge'
|
|
10
11
|
|
|
11
12
|
/** Simple unique id helper (fine for a client-only prototype). */
|
|
12
13
|
export function uid(prefix = 'id'): string {
|
|
@@ -1022,7 +1023,7 @@ export function blockTypeMeta(type: BlockType): BlockTypeMeta {
|
|
|
1022
1023
|
/** Color + iconography for each block status. */
|
|
1023
1024
|
export const STATUS_META: Record<
|
|
1024
1025
|
BlockStatus,
|
|
1025
|
-
{ label: string; color: string; chip:
|
|
1026
|
+
{ label: string; color: string; chip: BadgeColor; icon: string }
|
|
1026
1027
|
> = {
|
|
1027
1028
|
planned: {
|
|
1028
1029
|
label: 'Planned',
|
package/app/utils/initiative.ts
CHANGED
|
@@ -7,16 +7,13 @@ import type {
|
|
|
7
7
|
InitiativeQa,
|
|
8
8
|
InitiativeStatus,
|
|
9
9
|
} from '~/types/domain'
|
|
10
|
+
import type { BadgeColor } from '~/utils/badge'
|
|
10
11
|
|
|
11
12
|
// Shared initiative presentation vocabulary, so the board card, the inspector body and
|
|
12
13
|
// the tracker window render statuses/progress from ONE source. The exhaustive
|
|
13
14
|
// `Record<Enum, …>` maps keep the tier-2 typecheck guard live (a new status without
|
|
14
15
|
// a label/chip fails the build) without triplicating it across the components.
|
|
15
16
|
|
|
16
|
-
/** Nuxt UI badge/chip colour names — mirrors `UBadge`'s `color` prop union, so a chip map
|
|
17
|
-
* types its values against it and the `:color` binding needs no cast. */
|
|
18
|
-
type BadgeColor = 'error' | 'info' | 'primary' | 'secondary' | 'success' | 'warning' | 'neutral'
|
|
19
|
-
|
|
20
17
|
/** Initiative lifecycle status → i18n label key. */
|
|
21
18
|
export const INITIATIVE_STATUS_LABEL_KEYS: Record<InitiativeStatus, string> = {
|
|
22
19
|
planning: 'initiative.status.planning',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.256.
|
|
3
|
+
"version": "0.256.3",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"valibot": "^1.4.2",
|
|
41
41
|
"vue": "3.5.41",
|
|
42
42
|
"wretch": "^3.0.9",
|
|
43
|
-
"@cat-factory/contracts": "0.
|
|
43
|
+
"@cat-factory/contracts": "0.284.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@toad-contracts/testing": "0.3.2",
|