@cat-factory/app 0.38.0 → 0.40.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.
@@ -43,6 +43,13 @@ const headroomTone = computed(() => headroomColor(headroom.value, m.value.trunca
43
43
  <span class="tabular-nums text-slate-400" title="Prompt / completion tokens">
44
44
  {{ formatTokens(m.promptTokens) }}↑ {{ formatTokens(m.completionTokens) }}↓
45
45
  </span>
46
+ <span
47
+ v-if="(m.cachedPromptTokens ?? 0) > 0"
48
+ class="tabular-nums text-emerald-400/80"
49
+ title="Prompt tokens served from the provider's cache"
50
+ >
51
+ ({{ formatTokens(m.cachedPromptTokens ?? 0) }} cached)
52
+ </span>
46
53
  <div class="ml-auto flex items-center gap-1">
47
54
  <UBadge v-if="m.errors > 0" color="error" variant="subtle" size="sm">
48
55
  {{ m.errors }} error{{ m.errors === 1 ? '' : 's' }}
@@ -0,0 +1,201 @@
1
+ <script setup lang="ts">
2
+ // Startup advisory for unhealthy pipelines. Opened once per session from the board page when
3
+ // `usePipelineHealth` reports any issue. Lists:
4
+ // • invalid pipelines (unknown agent kind / bad shape) — DELETE a custom one, RESEED a built-in;
5
+ // • outdated built-ins (a newer catalog definition is available) — RESEED to adopt it.
6
+ // Detection is client-side (see usePipelineHealth); the actions hit the pipelines store.
7
+ const ui = useUiStore()
8
+ const pipelines = usePipelinesStore()
9
+ const { invalid, outdated, hasIssues } = usePipelineHealth()
10
+ const toast = useToast()
11
+
12
+ const open = computed({
13
+ get: () => ui.pipelineHealthOpen,
14
+ set: (v: boolean) => {
15
+ if (!v) ui.closePipelineHealth()
16
+ },
17
+ })
18
+
19
+ // Per-pipeline in-flight ids, so each row's button shows its own spinner.
20
+ const busy = ref<Set<string>>(new Set())
21
+ const isBusy = (id: string) => busy.value.has(id)
22
+ const anyBusy = computed(() => busy.value.size > 0)
23
+
24
+ async function run(id: string, action: () => Promise<unknown>, failTitle: string) {
25
+ busy.value = new Set(busy.value).add(id)
26
+ try {
27
+ await action()
28
+ } catch (e) {
29
+ toast.add({
30
+ title: failTitle,
31
+ description: e instanceof Error ? e.message : String(e),
32
+ icon: 'i-lucide-triangle-alert',
33
+ color: 'error',
34
+ })
35
+ } finally {
36
+ const next = new Set(busy.value)
37
+ next.delete(id)
38
+ busy.value = next
39
+ }
40
+ }
41
+
42
+ const reseed = (id: string) => run(id, () => pipelines.reseed(id), 'Could not reseed pipeline')
43
+ const remove = (id: string) =>
44
+ run(id, () => pipelines.removePipeline(id), 'Could not delete pipeline')
45
+
46
+ /** Reseed every reseedable pipeline (outdated built-ins + invalid built-ins) in one go. */
47
+ async function reseedAll() {
48
+ const ids = [...invalid.value.filter((h) => h.pipeline.builtin), ...outdated.value].map(
49
+ (h) => h.pipeline.id,
50
+ )
51
+ for (const id of new Set(ids)) await reseed(id)
52
+ }
53
+
54
+ const reseedableCount = computed(
55
+ () =>
56
+ new Set([
57
+ ...invalid.value.filter((h) => h.pipeline.builtin).map((h) => h.pipeline.id),
58
+ ...outdated.value.map((h) => h.pipeline.id),
59
+ ]).size,
60
+ )
61
+ </script>
62
+
63
+ <template>
64
+ <UModal v-model:open="open" title="Pipeline health" :ui="{ content: 'max-w-2xl' }">
65
+ <template #body>
66
+ <div v-if="!hasIssues" class="py-6 text-center text-sm text-slate-400">
67
+ <UIcon name="i-lucide-check-circle-2" class="mx-auto mb-2 h-8 w-8 text-emerald-400" />
68
+ All pipelines are valid and up to date.
69
+ </div>
70
+
71
+ <div v-else class="space-y-5">
72
+ <!-- Invalid: unknown agent kinds or a broken shape. -->
73
+ <section v-if="invalid.length" class="space-y-2">
74
+ <div class="flex items-center gap-2">
75
+ <UIcon name="i-lucide-triangle-alert" class="h-4 w-4 text-rose-400" />
76
+ <h3 class="text-sm font-semibold text-slate-200">Invalid pipelines</h3>
77
+ </div>
78
+ <p class="text-[11px] text-slate-500">
79
+ These reference a missing agent or are misconfigured, so they would fail (or misrun) at
80
+ start. Delete a custom pipeline, or reseed a built-in to restore its catalog definition.
81
+ </p>
82
+ <ul class="space-y-2">
83
+ <li
84
+ v-for="h in invalid"
85
+ :key="h.pipeline.id"
86
+ class="rounded-lg border border-slate-800 bg-slate-900/40 p-3"
87
+ >
88
+ <div class="flex items-start justify-between gap-3">
89
+ <div class="min-w-0">
90
+ <div class="flex items-center gap-2">
91
+ <span class="truncate text-sm font-medium text-slate-100">
92
+ {{ h.pipeline.name }}
93
+ </span>
94
+ <UBadge v-if="h.pipeline.builtin" color="neutral" variant="subtle" size="xs">
95
+ built-in
96
+ </UBadge>
97
+ </div>
98
+ <ul class="mt-1 space-y-0.5">
99
+ <li
100
+ v-for="(p, i) in h.problems"
101
+ :key="i"
102
+ class="text-[11px]"
103
+ :class="p.type === 'outdated' ? 'text-amber-400/80' : 'text-rose-400/90'"
104
+ >
105
+ {{ p.message }}
106
+ </li>
107
+ </ul>
108
+ </div>
109
+ <UButton
110
+ v-if="h.pipeline.builtin"
111
+ size="xs"
112
+ color="primary"
113
+ variant="subtle"
114
+ icon="i-lucide-rotate-ccw"
115
+ :loading="isBusy(h.pipeline.id)"
116
+ :disabled="anyBusy"
117
+ @click="reseed(h.pipeline.id)"
118
+ >
119
+ Reseed
120
+ </UButton>
121
+ <UButton
122
+ v-else
123
+ size="xs"
124
+ color="error"
125
+ variant="subtle"
126
+ icon="i-lucide-trash-2"
127
+ :loading="isBusy(h.pipeline.id)"
128
+ :disabled="anyBusy"
129
+ @click="remove(h.pipeline.id)"
130
+ >
131
+ Delete
132
+ </UButton>
133
+ </div>
134
+ </li>
135
+ </ul>
136
+ </section>
137
+
138
+ <!-- Outdated built-ins: a newer catalog version is available. -->
139
+ <section v-if="outdated.length" class="space-y-2">
140
+ <div class="flex items-center gap-2">
141
+ <UIcon name="i-lucide-arrow-up-circle" class="h-4 w-4 text-amber-400" />
142
+ <h3 class="text-sm font-semibold text-slate-200">Updates available</h3>
143
+ </div>
144
+ <p class="text-[11px] text-slate-500">
145
+ A newer version of these built-in pipelines has shipped. Reseed to adopt it (your labels
146
+ and archive state are kept).
147
+ </p>
148
+ <ul class="space-y-2">
149
+ <li
150
+ v-for="h in outdated"
151
+ :key="h.pipeline.id"
152
+ class="flex items-center justify-between gap-3 rounded-lg border border-slate-800 bg-slate-900/40 p-3"
153
+ >
154
+ <div class="min-w-0">
155
+ <span class="truncate text-sm font-medium text-slate-100">{{
156
+ h.pipeline.name
157
+ }}</span>
158
+ <p class="text-[11px] text-amber-400/80">{{ h.problems[0]?.message }}</p>
159
+ </div>
160
+ <UButton
161
+ size="xs"
162
+ color="primary"
163
+ variant="subtle"
164
+ icon="i-lucide-rotate-ccw"
165
+ :loading="isBusy(h.pipeline.id)"
166
+ :disabled="anyBusy"
167
+ @click="reseed(h.pipeline.id)"
168
+ >
169
+ Reseed
170
+ </UButton>
171
+ </li>
172
+ </ul>
173
+ </section>
174
+ </div>
175
+ </template>
176
+
177
+ <template #footer>
178
+ <div class="flex w-full items-center justify-between gap-2">
179
+ <UButton
180
+ v-if="reseedableCount > 1"
181
+ color="primary"
182
+ variant="ghost"
183
+ icon="i-lucide-rotate-ccw"
184
+ :loading="anyBusy"
185
+ @click="reseedAll"
186
+ >
187
+ Reseed all ({{ reseedableCount }})
188
+ </UButton>
189
+ <span v-else />
190
+ <UButton
191
+ color="neutral"
192
+ variant="ghost"
193
+ :disabled="anyBusy"
194
+ @click="ui.closePipelineHealth()"
195
+ >
196
+ {{ hasIssues ? 'Dismiss' : 'Done' }}
197
+ </UButton>
198
+ </div>
199
+ </template>
200
+ </UModal>
201
+ </template>
@@ -34,6 +34,13 @@ interface ProviderMeta {
34
34
  label: string
35
35
  url: string
36
36
  steps: string[]
37
+ /**
38
+ * Whether this provider caches the re-sent prompt prefix. Connecting a key here
39
+ * upgrades its models to the caching `direct` flavour, so a long agentic run stops
40
+ * re-billing its whole growing prompt every turn. Mirrors the backend
41
+ * `providerCachePolicy`; the gateways are pass-through (no caching we rely on yet).
42
+ */
43
+ caches?: boolean
37
44
  }
38
45
 
39
46
  /** Direct vendors: the key reaches that one vendor's own endpoint. */
@@ -46,6 +53,7 @@ const DIRECT_PROVIDERS: ProviderMeta[] = [
46
53
  'Open platform.openai.com → API keys and create a new secret key.',
47
54
  'Copy the key (starts with sk-…); it is shown only once.',
48
55
  ],
56
+ caches: true,
49
57
  },
50
58
  {
51
59
  value: 'anthropic',
@@ -55,6 +63,7 @@ const DIRECT_PROVIDERS: ProviderMeta[] = [
55
63
  'Open console.anthropic.com → Settings → API Keys and create a key.',
56
64
  'Copy the key (starts with sk-ant-…).',
57
65
  ],
66
+ caches: true,
58
67
  },
59
68
  {
60
69
  value: 'qwen',
@@ -64,6 +73,7 @@ const DIRECT_PROVIDERS: ProviderMeta[] = [
64
73
  'Open the DashScope console (international) → API-KEY and create a key.',
65
74
  'Copy the key; it authenticates the OpenAI-compatible Qwen endpoint.',
66
75
  ],
76
+ caches: true,
67
77
  },
68
78
  {
69
79
  value: 'deepseek',
@@ -73,6 +83,7 @@ const DIRECT_PROVIDERS: ProviderMeta[] = [
73
83
  'Open platform.deepseek.com → API keys and create a key.',
74
84
  'Copy the key (starts with sk-…).',
75
85
  ],
86
+ caches: true,
76
87
  },
77
88
  {
78
89
  value: 'moonshot',
@@ -268,6 +279,14 @@ async function remove(k: ApiKey) {
268
279
  </li>
269
280
  </ol>
270
281
 
282
+ <!-- caching capability: connecting a direct key that caches upgrades its models to
283
+ the caching flavour, so long agentic runs stop re-billing the whole prompt. -->
284
+ <p v-if="selected.caches" class="flex items-center gap-1.5 text-[12px] text-emerald-400/90">
285
+ <UIcon name="i-lucide-zap" class="h-3.5 w-3.5 shrink-0" />
286
+ Enables prompt caching for {{ selected.label }} models — a long multi-turn run reuses its
287
+ cached prompt prefix instead of re-sending it every turn.
288
+ </p>
289
+
271
290
  <!-- add form -->
272
291
  <div class="space-y-2">
273
292
  <UFormField label="Label (optional)">
@@ -15,7 +15,7 @@ import { onKeyStroke } from '@vueuse/core'
15
15
  import type { AgentKind } from '~/types/domain'
16
16
  import type { ModelPreset } from '~/types/model-presets'
17
17
  import { MODEL_CONFIGURABLE_SYSTEM_KINDS } from '~/utils/catalog'
18
- import { contextLabel, costLabel, displayFlavor, isSelectable } from '~/stores/models'
18
+ import { cachingLabel, contextLabel, costLabel, displayFlavor, isSelectable } from '~/stores/models'
19
19
 
20
20
  const ui = useUiStore()
21
21
  const models = useModelsStore()
@@ -84,7 +84,10 @@ const selectableModels = computed(() => {
84
84
  const flavor = displayFlavor(m, configured)
85
85
  const ctx = contextLabel(flavor.contextTokens)
86
86
  const price = costLabel(flavor) ?? (flavor.quotaBased ? 'quota' : undefined)
87
- const suffix = [flavor.providerLabel, ctx, price].filter(Boolean).join(' · ')
87
+ // Surface caching in the suffix: a cache-less flavour (the Workers-AI hot path)
88
+ // re-bills its whole growing prompt every turn, which the user can act on.
89
+ const caching = cachingLabel(flavor)
90
+ const suffix = [flavor.providerLabel, ctx, price, caching].filter(Boolean).join(' · ')
88
91
  return {
89
92
  id: m.id,
90
93
  label: m.label,
@@ -13,6 +13,7 @@ import {
13
13
  organizePipelineContract,
14
14
  removeBlockContract,
15
15
  reparentBlockContract,
16
+ reseedPipelineContract,
16
17
  toggleDependencyContract,
17
18
  updateBlockContract,
18
19
  updatePipelineContract,
@@ -129,5 +130,10 @@ export function boardApi({ send, ws }: ApiContext) {
129
130
 
130
131
  removePipeline: (workspaceId: string, pipelineId: string) =>
131
132
  send(deletePipelineContract, { pathPrefix: ws(workspaceId), pathParams: { pipelineId } }),
133
+
134
+ // Restore a built-in pipeline to its current catalog definition (adopt an improved
135
+ // built-in, or repair a drifted/invalid one). Custom pipelines reject this.
136
+ reseedPipeline: (workspaceId: string, pipelineId: string) =>
137
+ send(reseedPipelineContract, { pathPrefix: ws(workspaceId), pathParams: { pipelineId } }),
132
138
  }
133
139
  }
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { Pipeline } from '~/types/domain'
3
+ import { isKnownAgentKind } from '~/utils/catalog'
4
+ import { usePipelinesStore } from '~/stores/pipelines'
5
+ import { usePipelineHealth } from '~/composables/usePipelineHealth'
6
+
7
+ /**
8
+ * Guards the startup pipeline-health advisory against the failure that bit the first cut: a
9
+ * legitimate built-in agent kind missing from the frontend catalog made `isKnownAgentKind`
10
+ * return false, so a stock seeded pipeline (`pl_tech_debt`, which uses `analysis` + `tracker`)
11
+ * was reported "invalid" in every workspace with a Reseed action that could never fix it.
12
+ *
13
+ * The kind lists below mirror the canonical built-ins in
14
+ * `backend/packages/kernel/src/domain/seed.ts`; keep them in step when a seed pipeline gains a
15
+ * new kind. The `every built-in seed kind is known` test then fails loudly if the catalog drifts.
16
+ */
17
+
18
+ let nextId = 0
19
+ function builtin(agentKinds: string[], over: Partial<Pipeline> = {}): Pipeline {
20
+ return {
21
+ id: `pl_test_${nextId++}`,
22
+ name: 'Test',
23
+ agentKinds,
24
+ builtin: true,
25
+ version: 1,
26
+ ...over,
27
+ }
28
+ }
29
+
30
+ /** Seed the store with pipelines + their current catalog versions, then scan. */
31
+ function scan(pipelines: Pipeline[], versions: Record<string, number> = {}) {
32
+ const store = usePipelinesStore()
33
+ const catalogVersions = {
34
+ ...Object.fromEntries(pipelines.filter((p) => p.builtin).map((p) => [p.id, p.version ?? 0])),
35
+ ...versions,
36
+ }
37
+ store.hydrate(pipelines, catalogVersions)
38
+ return usePipelineHealth()
39
+ }
40
+
41
+ // Every agent kind any built-in catalog pipeline references (mirror of seed.ts). The advisory's
42
+ // validity oracle (`isKnownAgentKind`) must recognise all of them, or a stock pipeline is
43
+ // falsely flagged. `analysis`/`tracker` are the two that originally regressed.
44
+ const BUILTIN_SEED_KINDS = [
45
+ 'requirements-review',
46
+ 'spec-writer',
47
+ 'architect',
48
+ 'coder',
49
+ 'reviewer',
50
+ 'blueprints',
51
+ 'mocker',
52
+ 'tester',
53
+ 'conflicts',
54
+ 'ci',
55
+ 'merger',
56
+ 'integrator',
57
+ 'documenter',
58
+ 'analysis',
59
+ 'tracker',
60
+ 'human-test',
61
+ 'human-review',
62
+ ]
63
+
64
+ describe('isKnownAgentKind', () => {
65
+ it('recognises every agent kind used by the built-in seed catalog', () => {
66
+ const unknown = BUILTIN_SEED_KINDS.filter((k) => !isKnownAgentKind(k))
67
+ expect(unknown).toEqual([])
68
+ })
69
+
70
+ it('specifically recognises analysis + tracker (the kinds that regressed)', () => {
71
+ expect(isKnownAgentKind('analysis')).toBe(true)
72
+ expect(isKnownAgentKind('tracker')).toBe(true)
73
+ })
74
+
75
+ it('returns false for a genuinely unknown kind', () => {
76
+ expect(isKnownAgentKind('totally-made-up-kind')).toBe(false)
77
+ })
78
+ })
79
+
80
+ describe('usePipelineHealth', () => {
81
+ it('does not flag the stock tech-debt built-in (analysis + tracker) as invalid', () => {
82
+ const techDebt = builtin(
83
+ [
84
+ 'analysis',
85
+ 'tracker',
86
+ 'coder',
87
+ 'reviewer',
88
+ 'blueprints',
89
+ 'tester',
90
+ 'conflicts',
91
+ 'ci',
92
+ 'merger',
93
+ ],
94
+ { id: 'pl_tech_debt', name: 'Tech debt' },
95
+ )
96
+ const { hasIssues, invalid, outdated } = scan([techDebt])
97
+ expect(hasIssues.value).toBe(false)
98
+ expect(invalid.value).toHaveLength(0)
99
+ expect(outdated.value).toHaveLength(0)
100
+ })
101
+
102
+ it('flags a pipeline that references an unknown agent kind', () => {
103
+ const broken = builtin(['coder', 'bogus-kind'])
104
+ const { invalid } = scan([broken])
105
+ expect(invalid.value).toHaveLength(1)
106
+ expect(invalid.value[0]!.problems.some((p) => p.type === 'unknown-kind')).toBe(true)
107
+ })
108
+
109
+ it('accepts a valid producer + companion chain', () => {
110
+ const { hasIssues } = scan([builtin(['coder', 'reviewer'])])
111
+ expect(hasIssues.value).toBe(false)
112
+ })
113
+
114
+ it('flags a companion with no preceding producer it can review (shape)', () => {
115
+ const { invalid } = scan([builtin(['reviewer'])])
116
+ expect(invalid.value).toHaveLength(1)
117
+ expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
118
+ })
119
+
120
+ it('flags an estimate-gated companion with no task-estimator before it (shape)', () => {
121
+ const gated = builtin(['coder', 'reviewer'], {
122
+ gating: [null, { enabled: true, minComplexity: 0.5, onMissingEstimate: 'run' }],
123
+ })
124
+ const { invalid } = scan([gated])
125
+ expect(invalid.value).toHaveLength(1)
126
+ expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
127
+ })
128
+
129
+ it('reports a built-in whose catalog version moved ahead as outdated (not invalid)', () => {
130
+ const stale = builtin(['coder', 'reviewer'], { id: 'pl_stale', version: 1 })
131
+ const { invalid, outdated } = scan([stale], { pl_stale: 2 })
132
+ expect(invalid.value).toHaveLength(0)
133
+ expect(outdated.value).toHaveLength(1)
134
+ expect(outdated.value[0]!.problems[0]!.type).toBe('outdated')
135
+ })
136
+
137
+ it('keeps an invalid + outdated built-in out of the outdated list (one fix, not two)', () => {
138
+ const both = builtin(['coder', 'bogus-kind'], { id: 'pl_both', version: 1 })
139
+ const { invalid, outdated } = scan([both], { pl_both: 2 })
140
+ expect(invalid.value).toHaveLength(1)
141
+ expect(outdated.value).toHaveLength(0)
142
+ })
143
+ })
@@ -0,0 +1,140 @@
1
+ import { computed } from 'vue'
2
+ import type { Pipeline } from '~/types/domain'
3
+ import type { StepGating } from '~/types/consensus'
4
+ import { COMPANION_FOR_PRODUCER, isKnownAgentKind, isProducerCompanion } from '~/utils/catalog'
5
+ import { usePipelinesStore } from '~/stores/pipelines'
6
+
7
+ /** Estimate-gating consults a `task-estimator` step (mirrors the backend constant). */
8
+ const TASK_ESTIMATOR_KIND = 'task-estimator'
9
+
10
+ export type PipelineProblemType = 'unknown-kind' | 'shape' | 'outdated'
11
+
12
+ export interface PipelineProblem {
13
+ type: PipelineProblemType
14
+ message: string
15
+ }
16
+
17
+ export interface PipelineHealth {
18
+ pipeline: Pipeline
19
+ problems: PipelineProblem[]
20
+ /** Structural / unknown-kind problems — delete (custom) or reseed (built-in) to fix. */
21
+ invalid: boolean
22
+ /** A built-in whose catalog definition is newer than the stored copy — reseed to update. */
23
+ outdated: boolean
24
+ }
25
+
26
+ /** Producers a companion kind is allowed to review (inverse of {@link COMPANION_FOR_PRODUCER}). */
27
+ function companionTargets(companion: string): string[] {
28
+ return Object.entries(COMPANION_FOR_PRODUCER)
29
+ .filter(([, c]) => c === companion)
30
+ .map(([producer]) => producer)
31
+ }
32
+
33
+ const isEnabledAt = (p: Pipeline, i: number) => p.enabled?.[i] !== false
34
+
35
+ /**
36
+ * Client-side mirror of the backend `validatePipelineShape` (companion adjacency + estimate
37
+ * gating, over the ENABLED subset), collecting the first problem instead of throwing. Returns a
38
+ * human message, or null when the shape is valid. Kept in step with
39
+ * `backend/packages/orchestration/src/modules/pipelines/pipelineShape.ts`.
40
+ */
41
+ function shapeProblem(p: Pipeline): string | null {
42
+ const kinds = p.agentKinds
43
+ // No enabled steps ⇒ nothing would run.
44
+ if (kinds.length === 0 || !kinds.some((_, i) => isEnabledAt(p, i))) {
45
+ return 'No enabled steps — the pipeline has nothing to run.'
46
+ }
47
+ // Companion adjacency: an enabled companion's nearest preceding enabled step must be a
48
+ // producer it can review.
49
+ for (let i = 0; i < kinds.length; i++) {
50
+ const kind = kinds[i]
51
+ if (!kind || !isProducerCompanion(kind) || !isEnabledAt(p, i)) continue
52
+ const targets = companionTargets(kind)
53
+ let predecessor: string | undefined
54
+ for (let j = i - 1; j >= 0; j--) {
55
+ if (isEnabledAt(p, j)) {
56
+ predecessor = kinds[j]
57
+ break
58
+ }
59
+ }
60
+ if (predecessor === undefined || !targets.includes(predecessor)) {
61
+ return `Companion '${kind}' must run immediately after an enabled step it can review (${targets.join(', ')}).`
62
+ }
63
+ }
64
+ // Estimate gating: an enabled gated step must be a companion, set ≥1 threshold, and have an
65
+ // enabled task-estimator earlier in the chain.
66
+ const gating = p.gating
67
+ if (gating) {
68
+ for (let i = 0; i < kinds.length; i++) {
69
+ const g = gating[i] as StepGating | null | undefined
70
+ if (!g?.enabled || !isEnabledAt(p, i)) continue
71
+ const kind = kinds[i]
72
+ if (!kind || !isProducerCompanion(kind)) {
73
+ return `Step '${kind}' cannot be estimate-gated — only companion steps may be skipped on the estimate.`
74
+ }
75
+ if (g.minComplexity === undefined && g.minRisk === undefined && g.minImpact === undefined) {
76
+ return `Step '${kind}' is estimate-gated but sets no threshold (complexity / risk / impact).`
77
+ }
78
+ const hasEstimator = kinds
79
+ .slice(0, i)
80
+ .some((k, j) => k === TASK_ESTIMATOR_KIND && isEnabledAt(p, j))
81
+ if (!hasEstimator) {
82
+ return `Step '${kind}' is gated on the estimate but no enabled '${TASK_ESTIMATOR_KIND}' runs before it.`
83
+ }
84
+ }
85
+ }
86
+ return null
87
+ }
88
+
89
+ /**
90
+ * Detect pipelines in an unhealthy state for the startup advisory: those referencing an unknown
91
+ * agent kind or with an invalid shape (offer to delete a custom one / reseed a built-in), and
92
+ * built-ins whose seeded definition has moved ahead of the stored copy (offer to reseed). Reads
93
+ * the pipeline library + the snapshot's catalog versions from the pipelines store. Detection runs
94
+ * entirely client-side because the canonical agent-kind catalog lives here (`AGENT_BY_KIND` +
95
+ * `SYSTEM_AGENT_META` + registered custom kinds); the version comparison uses the catalog
96
+ * versions the snapshot ships.
97
+ */
98
+ export function usePipelineHealth() {
99
+ const store = usePipelinesStore()
100
+
101
+ const health = computed<PipelineHealth[]>(() => {
102
+ const out: PipelineHealth[] = []
103
+ for (const pipeline of store.pipelines) {
104
+ const problems: PipelineProblem[] = []
105
+
106
+ const unknown = [...new Set(pipeline.agentKinds.filter((k) => !isKnownAgentKind(k)))]
107
+ if (unknown.length) {
108
+ problems.push({
109
+ type: 'unknown-kind',
110
+ message: `References unknown agent ${unknown.length > 1 ? 'kinds' : 'kind'}: ${unknown.join(', ')}.`,
111
+ })
112
+ }
113
+
114
+ const shape = shapeProblem(pipeline)
115
+ if (shape) problems.push({ type: 'shape', message: shape })
116
+
117
+ const catalogVersion = pipeline.builtin ? store.catalogVersions[pipeline.id] : undefined
118
+ const outdated = catalogVersion !== undefined && catalogVersion > (pipeline.version ?? 0)
119
+ if (outdated) {
120
+ problems.push({
121
+ type: 'outdated',
122
+ message: `A newer version of this built-in pipeline is available (v${pipeline.version ?? 0} → v${catalogVersion}).`,
123
+ })
124
+ }
125
+
126
+ if (problems.length) {
127
+ out.push({ pipeline, problems, invalid: unknown.length > 0 || shape !== null, outdated })
128
+ }
129
+ }
130
+ return out
131
+ })
132
+
133
+ // An invalid built-in is reseeded (not deleted) and that also clears any "outdated" flag, so
134
+ // exclude it from the outdated list to avoid offering the same fix twice.
135
+ const invalid = computed(() => health.value.filter((h) => h.invalid))
136
+ const outdated = computed(() => health.value.filter((h) => h.outdated && !h.invalid))
137
+ const hasIssues = computed(() => health.value.length > 0)
138
+
139
+ return { health, invalid, outdated, hasIssues }
140
+ }
@@ -49,6 +49,11 @@ const SlackPanel = defineAsyncComponent(() => import('~/components/slack/SlackPa
49
49
  const FragmentLibraryPanel = defineAsyncComponent(
50
50
  () => import('~/components/fragments/FragmentLibraryPanel.vue'),
51
51
  )
52
+ // Startup advisory for invalid / outdated pipelines — only mounted while open (auto-opened
53
+ // at most once per session by the watcher below), so it stays out of the initial bundle.
54
+ const PipelineHealthModal = defineAsyncComponent(
55
+ () => import('~/components/pipeline/PipelineHealthModal.vue'),
56
+ )
52
57
  const IntegrationsHub = defineAsyncComponent(
53
58
  () => import('~/components/layout/IntegrationsHub.vue'),
54
59
  )
@@ -122,11 +127,25 @@ watch(
122
127
  autoOpenedSetup.value = false
123
128
  autoOpenedPreset.value = false
124
129
  ui.resetAiOnboarding()
130
+ // A different board has its own pipeline library, so re-arm the once-per-session advisory.
131
+ ui.pipelineHealthSeen = false
125
132
  }
126
133
  },
127
134
  { immediate: true },
128
135
  )
129
136
 
137
+ // Pipeline-health advisory: once a board is loaded, surface any invalid / outdated pipelines in
138
+ // a startup modal (auto-opened at most once per session per board — later opens are user-driven).
139
+ // Detection is reactive, so this fires as soon as the snapshot hydrates.
140
+ const { hasIssues: pipelineIssues } = usePipelineHealth()
141
+ watch(
142
+ () => [workspace.ready, pipelineIssues.value],
143
+ () => {
144
+ if (workspace.ready && pipelineIssues.value) ui.maybeOpenPipelineHealth()
145
+ },
146
+ { immediate: true },
147
+ )
148
+
130
149
  // Auto-open the right AI-onboarding dialog once per session: the no-source prompt takes
131
150
  // precedence over the preset-mismatch prompt. Honour the per-session dismissed flags so a
132
151
  // user who closed the banner isn't re-interrupted, and only auto-open once each (later opens
@@ -242,6 +261,7 @@ watch(
242
261
  <GitHubPanel v-if="ui.githubOpen" />
243
262
  <SlackPanel v-if="ui.slackOpen" />
244
263
  <FragmentLibraryPanel v-if="ui.fragmentLibraryOpen" />
264
+ <PipelineHealthModal v-if="ui.pipelineHealthOpen" />
245
265
  <IntegrationsHub v-if="ui.integrationsOpen" />
246
266
  <PersonalSetupModal v-if="ui.personalSetupOpen" />
247
267
  <WorkspaceSettingsPanel v-if="ui.workspaceSettingsOpen" />
@@ -12,6 +12,13 @@ export interface DisplayFlavor {
12
12
  /** True ⇒ flat-rate quota; its cost is a quota burn rate, not budget spend. */
13
13
  quotaBased: boolean
14
14
  vendor?: SubscriptionVendor
15
+ /**
16
+ * Whether this flavour's provider caches the re-sent prompt prefix. False on a
17
+ * Cloudflare/Workers-AI flavour (the hot path re-bills the whole prompt every turn);
18
+ * true once a direct key upgrades the model to its caching `direct` flavour. Undefined
19
+ * ⇒ unknown (older catalog). Surfaced as a badge in the picker.
20
+ */
21
+ cachesPrompts?: boolean
15
22
  }
16
23
 
17
24
  /**
@@ -30,6 +37,7 @@ export function displayFlavor(m: ModelOption, configured: Set<SubscriptionVendor
30
37
  cost: m.subscription.cost,
31
38
  quotaBased: true,
32
39
  vendor: m.subscription.vendor,
40
+ cachesPrompts: m.subscription.cachesPrompts,
33
41
  }
34
42
  }
35
43
  return {
@@ -40,6 +48,7 @@ export function displayFlavor(m: ModelOption, configured: Set<SubscriptionVendor
40
48
  cost: m.cost,
41
49
  quotaBased: m.quotaBased ?? false,
42
50
  vendor: m.vendor,
51
+ cachesPrompts: m.cachesPrompts,
43
52
  }
44
53
  }
45
54
 
@@ -69,6 +78,20 @@ export function costLabel(flavor: DisplayFlavor): string | undefined {
69
78
  return flavor.quotaBased ? `quota burn ~${body}` : body
70
79
  }
71
80
 
81
+ /**
82
+ * A short caching label for the picker: whether the flavour's provider caches the
83
+ * re-sent prompt prefix. `null` when unknown (older catalog) so the caller can omit it
84
+ * entirely. A long agentic run on a non-caching flavour re-bills its whole growing
85
+ * prompt every turn (slower, more rate-limited), so we surface it as an informational
86
+ * hint the user can act on (connect a direct key / pick a caching model). The model
87
+ * picker is a text-only dropdown-menu item list, so this is a label token in the option
88
+ * suffix rather than a styled badge.
89
+ */
90
+ export function cachingLabel(flavor: DisplayFlavor): string | null {
91
+ if (flavor.cachesPrompts === undefined) return null
92
+ return flavor.cachesPrompts ? 'Prompt caching' : 'No prompt caching'
93
+ }
94
+
72
95
  /**
73
96
  * The model picker catalog. Served by `GET /models`, where each model is already
74
97
  * resolved to the flavour in use for this deployment (direct when the provider's
@@ -31,6 +31,12 @@ function defaultConsensusConfig(): ConsensusStepConfig {
31
31
  export const usePipelinesStore = defineStore('pipelines', () => {
32
32
  const api = useApi()
33
33
  const pipelines = ref<Pipeline[]>([])
34
+ /**
35
+ * Current built-in catalog versions (`seedPipelines()`), keyed by pipeline id, from the
36
+ * workspace snapshot. A built-in whose stored `version` is below its catalog value here has
37
+ * a newer definition available (see `usePipelineHealth`).
38
+ */
39
+ const catalogVersions = ref<Record<string, number>>({})
34
40
 
35
41
  /** The chain currently being assembled in the builder. */
36
42
  const draft = ref<AgentKind[]>([])
@@ -59,9 +65,10 @@ export const usePipelinesStore = defineStore('pipelines', () => {
59
65
  /** The id of the pipeline being edited, or null when assembling a brand-new one. */
60
66
  const editingId = ref<string | null>(null)
61
67
 
62
- /** Replace the cached pipelines with a server snapshot. */
63
- function hydrate(next: Pipeline[]) {
68
+ /** Replace the cached pipelines (and the current built-in catalog versions) from a snapshot. */
69
+ function hydrate(next: Pipeline[], versions?: Record<string, number>) {
64
70
  pipelines.value = next
71
+ if (versions) catalogVersions.value = versions
65
72
  }
66
73
 
67
74
  function getPipeline(id: string) {
@@ -300,6 +307,19 @@ export const usePipelinesStore = defineStore('pipelines', () => {
300
307
  if (editingId.value === id) clearDraft()
301
308
  }
302
309
 
310
+ /**
311
+ * Reseed a built-in pipeline from the backend's current catalog definition: restores its
312
+ * canonical structure + version (adopting an update, or repairing a drifted/invalid copy)
313
+ * while preserving its labels/archive state. Replaces the pipeline in the cache.
314
+ */
315
+ async function reseed(id: string): Promise<Pipeline> {
316
+ const updated = await api.reseedPipeline(useWorkspaceStore().requireId(), id)
317
+ const i = pipelines.value.findIndex((p) => p.id === updated.id)
318
+ if (i >= 0) pipelines.value[i] = updated
319
+ if (editingId.value === id) clearDraft()
320
+ return updated
321
+ }
322
+
303
323
  /** Set a pipeline's organizational metadata (labels / archive). Works on built-ins too. */
304
324
  async function organize(id: string, body: { labels?: string[]; archived?: boolean }) {
305
325
  const updated = await api.organizePipeline(useWorkspaceStore().requireId(), id, body)
@@ -314,6 +334,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
314
334
 
315
335
  return {
316
336
  pipelines,
337
+ catalogVersions,
317
338
  draft,
318
339
  draftGates,
319
340
  draftEnabled,
@@ -344,6 +365,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
344
365
  saveDraft,
345
366
  clonePipeline,
346
367
  removePipeline,
368
+ reseed,
347
369
  organize,
348
370
  archive,
349
371
  unarchive,
package/app/stores/ui.ts CHANGED
@@ -19,6 +19,11 @@ export const useUiStore = defineStore('ui', () => {
19
19
  const selectedBlockId = ref<string | null>(null)
20
20
  const focusBlockId = ref<string | null>(null)
21
21
  const builderOpen = ref(false)
22
+ // Pipeline-health startup advisory: lists invalid pipelines (delete / reseed) + built-ins
23
+ // with a newer catalog version (reseed). `pipelineHealthSeen` gates auto-open to once per
24
+ // session so it does not re-pop on every snapshot re-hydration.
25
+ const pipelineHealthOpen = ref(false)
26
+ const pipelineHealthSeen = ref(false)
22
27
  const decisionContext = ref<{ instanceId: string; decisionId: string } | null>(null)
23
28
 
24
29
  // Document-source integration modals, keyed by source. `documentImport` and
@@ -217,6 +222,22 @@ export const useUiStore = defineStore('ui', () => {
217
222
  builderOpen.value = true
218
223
  }
219
224
 
225
+ /** Auto-open the pipeline-health advisory once per session (no-op after it's been shown). */
226
+ function maybeOpenPipelineHealth() {
227
+ if (pipelineHealthSeen.value) return
228
+ pipelineHealthSeen.value = true
229
+ pipelineHealthOpen.value = true
230
+ }
231
+
232
+ function openPipelineHealth() {
233
+ pipelineHealthSeen.value = true
234
+ pipelineHealthOpen.value = true
235
+ }
236
+
237
+ function closePipelineHealth() {
238
+ pipelineHealthOpen.value = false
239
+ }
240
+
220
241
  function openDecision(instanceId: string, decisionId: string) {
221
242
  decisionContext.value = { instanceId, decisionId }
222
243
  }
@@ -600,6 +621,8 @@ export const useUiStore = defineStore('ui', () => {
600
621
  selectedBlockId,
601
622
  focusBlockId,
602
623
  builderOpen,
624
+ pipelineHealthOpen,
625
+ pipelineHealthSeen,
603
626
  decisionContext,
604
627
  documentConnect,
605
628
  documentImport,
@@ -651,6 +674,9 @@ export const useUiStore = defineStore('ui', () => {
651
674
  select,
652
675
  focus,
653
676
  openBuilder,
677
+ maybeOpenPipelineHealth,
678
+ openPipelineHealth,
679
+ closePipelineHealth,
654
680
  openDecision,
655
681
  closeDecision,
656
682
  openApprovalDetail,
@@ -82,7 +82,7 @@ export const useWorkspaceStore = defineStore(
82
82
  if (i >= 0) workspaces.value[i] = snapshot.workspace
83
83
  else workspaces.value.unshift(snapshot.workspace)
84
84
  useBoardStore().hydrate(snapshot.blocks)
85
- usePipelinesStore().hydrate(snapshot.pipelines)
85
+ usePipelinesStore().hydrate(snapshot.pipelines, snapshot.pipelineCatalogVersions)
86
86
  useExecutionStore().hydrate(snapshot.executions)
87
87
  useAgentRunsStore().hydrate(snapshot.bootstrapJobs ?? [])
88
88
  useNotificationsStore().hydrate(snapshot.notifications ?? [])
@@ -303,6 +303,28 @@ export const SYSTEM_AGENT_META: Record<string, AgentArchetype> = {
303
303
  color: '#22d3ee',
304
304
  description: 'Maps the repository into the service → modules blueprint.',
305
305
  },
306
+ // A read-only repository audit that emits a prioritized findings report. Not a palette
307
+ // archetype (it is only seeded into the recurring tech-debt pipeline), so it lives here
308
+ // for run-timeline / saved-pipeline display rather than in AGENT_ARCHETYPES.
309
+ analysis: {
310
+ kind: 'analysis',
311
+ label: 'Analyst',
312
+ icon: 'i-lucide-search-code',
313
+ color: '#818cf8',
314
+ description:
315
+ 'Audits the repository read-only and emits a prioritized findings report (drives the tech-debt pipeline).',
316
+ },
317
+ // A one-shot engine step that files a tracker ticket (GitHub issue / Jira) from the
318
+ // preceding analysis before implementation. Runs no model itself; seeded only into the
319
+ // tech-debt pipeline, so it is a display-metadata system kind, not a palette archetype.
320
+ tracker: {
321
+ kind: 'tracker',
322
+ label: 'Issue Tracker',
323
+ icon: 'i-lucide-ticket',
324
+ color: '#fb923c',
325
+ description:
326
+ 'Files a tracker ticket (GitHub issue / Jira) from the analysis before work starts.',
327
+ },
306
328
  conflicts: {
307
329
  kind: 'conflicts',
308
330
  label: 'Conflicts Gate',
@@ -447,6 +469,18 @@ export function agentKindMeta(kind: string): AgentArchetype {
447
469
  )
448
470
  }
449
471
 
472
+ /**
473
+ * Whether an agent kind is actually known to this build — a palette archetype or companion
474
+ * ({@link AGENT_BY_KIND}, which deployment custom kinds are merged into via
475
+ * `useAgentsStore().registerCustomKinds`), or an engine system/gate kind
476
+ * ({@link SYSTEM_AGENT_META}). Unlike {@link agentKindMeta} (which always returns a usable
477
+ * fallback so renderers never crash), this returns `false` for an unknown kind — used to flag
478
+ * a pipeline that references a nonexistent agent. Call AFTER custom kinds are registered.
479
+ */
480
+ export function isKnownAgentKind(kind: string): boolean {
481
+ return kind in AGENT_BY_KIND || kind in SYSTEM_AGENT_META
482
+ }
483
+
450
484
  type BlockTypeMeta = { label: string; icon: string; accent: string }
451
485
 
452
486
  /** Visual metadata for each architecture block type. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,7 +32,7 @@
32
32
  "pinia-plugin-persistedstate": "^4.7.1",
33
33
  "vue": "^3.5.38",
34
34
  "wretch": "^3.0.9",
35
- "@cat-factory/contracts": "0.37.0"
35
+ "@cat-factory/contracts": "0.39.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@toad-contracts/testing": "0.3.1",