@cat-factory/app 0.75.0 → 0.75.2

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.
@@ -5,6 +5,7 @@
5
5
  // inspector, task panel) gets identical behaviour from one place. Replaces the
6
6
  // three hand-rolled bootstrap banners that used to duplicate this logic.
7
7
  import type { AgentRunSummary } from '~/stores/agentRuns'
8
+ import FailureDetail from '~/components/board/FailureDetail.vue'
8
9
 
9
10
  const props = withDefaults(
10
11
  defineProps<{ run: AgentRunSummary; variant?: 'compact' | 'expanded' }>(),
@@ -82,15 +83,13 @@ async function retry() {
82
83
  {{ failure.hint }}
83
84
  </p>
84
85
 
85
- <details v-if="!compact && failure?.detail && failure.detail !== failure.message" class="mt-1">
86
- <summary class="cursor-pointer text-[10px] text-rose-400/60 hover:text-rose-300">
87
- {{ t('board.failure.showDetail') }}
88
- </summary>
89
- <pre
90
- class="mt-1 max-h-32 overflow-auto whitespace-pre-wrap rounded bg-rose-950/60 p-1.5 text-[10px] text-rose-200/80"
91
- >{{ failure.detail }}</pre
92
- >
93
- </details>
86
+ <FailureDetail
87
+ v-if="!compact && failure"
88
+ :detail="failure.detail"
89
+ :message="failure.message"
90
+ summary-class="text-[10px] text-rose-400/60 hover:text-rose-300"
91
+ pre-class="bg-rose-950/60 text-[10px] text-rose-200/80"
92
+ />
94
93
 
95
94
  <button
96
95
  type="button"
@@ -0,0 +1,60 @@
1
+ <script setup lang="ts">
2
+ // The error trail of a run's PRIOR attempts, preserved across retries/restarts. This is
3
+ // deliberately SEPARATE from the top failure banner (`AgentFailureCard`, keyed on the
4
+ // current `status === 'failed'`): when a failed task is retried it restarts and the top
5
+ // banner disappears, but this collapsed history stays available so every previous error
6
+ // remains viewable. Renders nothing when there is no trail.
7
+ import type { AgentFailure } from '~/types/domain'
8
+ import FailureDetail from '~/components/board/FailureDetail.vue'
9
+
10
+ const props = defineProps<{ failures: AgentFailure[] }>()
11
+
12
+ const { t, d } = useI18n()
13
+
14
+ // Newest attempt first — the most recent failure is the most relevant to look at.
15
+ const ordered = computed(() => [...props.failures].reverse())
16
+ </script>
17
+
18
+ <template>
19
+ <details
20
+ v-if="failures.length"
21
+ class="nodrag rounded-lg border border-slate-700/60 bg-slate-900/40 px-3 py-2"
22
+ data-testid="agent-failure-history"
23
+ >
24
+ <summary
25
+ class="flex cursor-pointer items-center gap-1.5 text-[11px] text-slate-400 hover:text-slate-200"
26
+ >
27
+ <UIcon name="i-lucide-history" class="h-3.5 w-3.5 shrink-0" />
28
+ {{ t('board.failure.history.previousErrors', { count: failures.length }, failures.length) }}
29
+ </summary>
30
+
31
+ <ol class="mt-2 space-y-2">
32
+ <li
33
+ v-for="failure in ordered"
34
+ :key="failure.occurredAt"
35
+ class="rounded-md border border-slate-800/80 bg-slate-950/50 px-2.5 py-2"
36
+ data-testid="agent-failure-history-entry"
37
+ >
38
+ <div class="flex items-center gap-1.5 text-[10px] text-slate-500">
39
+ <UIcon name="i-lucide-alert-triangle" class="h-3 w-3 shrink-0 text-rose-400/70" />
40
+ <time>{{ d(new Date(failure.occurredAt), 'long') }}</time>
41
+ </div>
42
+
43
+ <p class="mt-1 text-[11px] leading-snug text-slate-300" :title="failure.message">
44
+ {{ failure.message }}
45
+ </p>
46
+
47
+ <p v-if="failure.hint" class="mt-1 text-[10px] leading-snug text-slate-500">
48
+ {{ failure.hint }}
49
+ </p>
50
+
51
+ <FailureDetail
52
+ :detail="failure.detail"
53
+ :message="failure.message"
54
+ summary-class="text-[10px] text-slate-500 hover:text-slate-300"
55
+ pre-class="bg-slate-950/80 text-[10px] text-slate-400"
56
+ />
57
+ </li>
58
+ </ol>
59
+ </details>
60
+ </template>
@@ -0,0 +1,27 @@
1
+ <script setup lang="ts">
2
+ // Shared collapsible "Show detail" disclosure for a failure's extended `detail`, used by
3
+ // both the failure banner (`AgentFailureCard`) and the prior-errors history
4
+ // (`AgentFailureHistory`). Renders nothing when there is no detail or it merely repeats the
5
+ // message. Tone (summary/pre classes) is passed by the host so it blends into each surface
6
+ // (rose banner vs slate history) while the guard + the `showDetail` key + the whitespace-
7
+ // preserving `<pre>` structure live in one place.
8
+ defineProps<{
9
+ detail: string | null
10
+ message: string
11
+ summaryClass: string
12
+ preClass: string
13
+ }>()
14
+
15
+ const { t } = useI18n()
16
+ </script>
17
+
18
+ <template>
19
+ <details v-if="detail && detail !== message" class="mt-1">
20
+ <summary class="cursor-pointer" :class="summaryClass">
21
+ {{ t('board.failure.showDetail') }}
22
+ </summary>
23
+ <pre class="mt-1 max-h-32 overflow-auto whitespace-pre-wrap rounded p-1.5" :class="preClass">{{
24
+ detail
25
+ }}</pre>
26
+ </details>
27
+ </template>
@@ -4,7 +4,8 @@ import type { Block } from '~/types/domain'
4
4
  // Service-level best-practice fragments (frame blocks). These are the programming
5
5
  // standards/guidelines for the whole service; at run time their bodies are folded
6
6
  // into the prompt of every `code-aware` agent on tasks under this service. Drawn from
7
- // the universal fragment pool (built-in + deployment-registered), grouped by category.
7
+ // the board's merged fragment catalog (built-in registered account ∪ workspace,
8
+ // via the fragments store; static pool when the library is off), grouped by category.
8
9
  const props = defineProps<{ block: Block }>()
9
10
 
10
11
  const board = useBoardStore()
@@ -17,10 +18,12 @@ onMounted(() => fragments.ensureLoaded())
17
18
 
18
19
  type MenuItem = { label: string; icon?: string; onSelect: () => void }
19
20
 
21
+ // An id the catalog no longer resolves (removed/suppressed after selection) still
22
+ // renders — labelled by its raw id — so it stays visible and removable.
20
23
  const selectedFragments = computed(() =>
21
- (props.block.serviceFragmentIds ?? [])
22
- .map((id) => fragments.getFragment(id))
23
- .filter((f): f is NonNullable<typeof f> => !!f),
24
+ (props.block.serviceFragmentIds ?? []).map(
25
+ (id) => fragments.getFragment(id) ?? { id, title: id, summary: '' },
26
+ ),
24
27
  )
25
28
 
26
29
  // A trailing group that jumps from "attach a fragment" to authoring/editing the
@@ -8,6 +8,7 @@ import {
8
8
  containerPhaseLabel,
9
9
  } from '~/utils/pipelineRender'
10
10
  import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
11
+ import AgentFailureHistory from '~/components/board/AgentFailureHistory.vue'
11
12
  import EmptyState from '~/components/common/EmptyState.vue'
12
13
 
13
14
  const props = defineProps<{ block: Block }>()
@@ -57,6 +58,10 @@ const failedRun = computed(() => {
57
58
  return run && run.status === 'failed' ? run : null
58
59
  })
59
60
 
61
+ // Failures from prior attempts, preserved across retries — shown regardless of the run's
62
+ // CURRENT status, so the error trail stays viewable after a restart clears the top banner.
63
+ const failureHistory = computed(() => agentRuns.byBlock[props.block.id]?.failureHistory ?? [])
64
+
60
65
  const pr = computed(() => props.block.pullRequest)
61
66
  /** A PR is merged once the block is `done`; otherwise it is open awaiting merge. */
62
67
  const prMerged = computed(() => props.block.status === 'done')
@@ -413,6 +418,9 @@ async function mergePr() {
413
418
  <!-- failed run: shared failure banner + retry -->
414
419
  <AgentFailureCard v-if="failedRun" :run="failedRun" />
415
420
 
421
+ <!-- error trail of prior attempts (survives a retry/restart that cleared the banner) -->
422
+ <AgentFailureHistory :failures="failureHistory" />
423
+
416
424
  <!-- Open PR: link straight to it on GitHub -->
417
425
  <div v-if="pr" class="space-y-2">
418
426
  <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
@@ -9,14 +9,20 @@ const ui = useUiStore()
9
9
  const accounts = useAccountsStore()
10
10
  const { t } = useI18n()
11
11
 
12
+ // The catalog is per-board and invalidated on a workspace switch, so (re)load it when the
13
+ // task inspector mounts — mirrors ServiceFragments; ensureLoaded is a no-op while current.
14
+ onMounted(() => fragments.ensureLoaded())
15
+
12
16
  type MenuItem = { label: string; icon?: string; onSelect: () => void }
13
17
 
14
18
  // ---- best-practice prompt fragments ----------------------------------------
15
- // Selected fragments (resolved against the catalog; unknown ids are dropped).
19
+ // Selected fragments, resolved against the catalog. An id the catalog no longer
20
+ // resolves (removed/suppressed after selection) still renders — labelled by its
21
+ // raw id — so it stays visible and removable.
16
22
  const selectedFragments = computed(() =>
17
- (props.block.fragmentIds ?? [])
18
- .map((id) => fragments.getFragment(id))
19
- .filter((f): f is NonNullable<typeof f> => !!f),
23
+ (props.block.fragmentIds ?? []).map(
24
+ (id) => fragments.getFragment(id) ?? { id, title: id, summary: '' },
25
+ ),
20
26
  )
21
27
 
22
28
  // A trailing group that jumps from "attach a fragment" to authoring/editing the
@@ -1,7 +1,8 @@
1
1
  <script setup lang="ts">
2
2
  // Workspace settings: the default best-practice fragments NEW services inherit. The
3
- // selection is drawn from the universal fragment pool (built-in + deployment-registered)
4
- // served by GET /prompt-fragments. Changing it does not retroactively change existing
3
+ // selection is drawn from the board's merged fragment catalog (built-in registered
4
+ // account workspace via the fragments store; the static GET /prompt-fragments pool
5
+ // when the library is off). Changing it does not retroactively change existing
5
6
  // services — each owns its selection from creation. Persisted via the
6
7
  // serviceFragmentDefaults store (the backend replaces the whole list on each change).
7
8
  import { onMounted, ref } from 'vue'
@@ -17,10 +18,10 @@ const busy = ref(false)
17
18
  // The tab renders when Workspace settings opens; load the fragment pool then.
18
19
  onMounted(() => void fragments.ensureLoaded())
19
20
 
21
+ // An id the catalog no longer resolves still renders (labelled by its raw id) so it
22
+ // stays visible and removable from the default set.
20
23
  const selected = computed(() =>
21
- defaults.fragmentIds
22
- .map((id) => fragments.getFragment(id))
23
- .filter((f): f is NonNullable<typeof f> => !!f),
24
+ defaults.fragmentIds.map((id) => fragments.getFragment(id) ?? { id, title: id, summary: '' }),
24
25
  )
25
26
 
26
27
  // Pool fragments not already in the default set, grouped by category.
@@ -26,6 +26,13 @@ export interface AgentRunSummary {
26
26
  runId: string
27
27
  /** Structured failure when `status` is `failed`; null otherwise. */
28
28
  failure: AgentFailure | null
29
+ /**
30
+ * Failures from the run's PRIOR attempts, oldest→newest — the error trail preserved
31
+ * across retries/restarts. Stays populated after a restart (when `status` is no longer
32
+ * `failed` and the top banner is gone), so the "previous errors" history remains
33
+ * viewable. Empty for a bootstrap run or a run that never failed-then-retried.
34
+ */
35
+ failureHistory: AgentFailure[]
29
36
  /** Latest subtask counts for a live progress bar (null until reported). */
30
37
  subtasks: StepSubtasks | null
31
38
  }
@@ -138,6 +145,7 @@ export const useAgentRunsStore = defineStore('agentRuns', () => {
138
145
  status: e.status,
139
146
  runId: e.id,
140
147
  failure: e.failure ?? null,
148
+ failureHistory: e.failureHistory ?? [],
141
149
  subtasks: e.steps[e.currentStep]?.subtasks ?? null,
142
150
  }
143
151
  }
@@ -150,6 +158,8 @@ export const useAgentRunsStore = defineStore('agentRuns', () => {
150
158
  status: job.status,
151
159
  runId: job.id,
152
160
  failure: job.failure,
161
+ // Bootstrap runs keep no prior-attempt trail (retry mints a fresh row).
162
+ failureHistory: [],
153
163
  subtasks: job.subtasks,
154
164
  }
155
165
  }
@@ -11,6 +11,7 @@ import type {
11
11
  UpdatePromptFragmentInput,
12
12
  } from '~/types/domain'
13
13
  import { useWorkspaceStore } from '~/stores/workspace'
14
+ import { useFragmentsStore } from '~/stores/fragments'
14
15
 
15
16
  /**
16
17
  * Prompt-fragment library state (ADR 0006), scoped to a single owner — a board
@@ -83,6 +84,9 @@ function fragmentLibrarySetup(kind: FragmentOwnerKind, resolveOwnerId: () => str
83
84
  }
84
85
 
85
86
  async function refreshResolved() {
87
+ // Every library mutation lands here: drop the picker catalog's cache so the
88
+ // per-service / per-block pickers see the edit on their next open.
89
+ useFragmentsStore().invalidate()
86
90
  if (!hasResolved) return
87
91
  resolved.value = await api.getResolvedFragments(requireOwnerId())
88
92
  }
@@ -1,24 +1,47 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref, computed } from 'vue'
3
3
  import type { BlockType, PromptFragment } from '~/types/domain'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
4
5
 
5
6
  /**
6
- * The best-practice prompt fragment catalog. It is build-static reference data on
7
- * the backend (`GET /prompt-fragments`), workspace-independent, so this store
8
- * fetches it once and caches it for the per-block picker in the inspector.
7
+ * The best-practice prompt fragment catalog backing the per-service and per-block
8
+ * pickers. When the fragment library is configured it loads the MERGED tenant
9
+ * catalog for the active board (`GET /workspaces/:id/prompt-fragments/resolved`
10
+ * built-in ∪ account ∪ workspace, override-by-id, tombstones applied), so managed,
11
+ * repo-sourced and document-backed fragments are selectable exactly like the
12
+ * built-ins and a suppressed built-in disappears from the picker. When the library
13
+ * is off (the resolved endpoint 503s) it falls back to the workspace-independent
14
+ * static pool (`GET /prompt-fragments`). Cached per board; re-fetched on a board
15
+ * switch or after `invalidate()` (a library edit).
9
16
  */
10
17
  export const useFragmentsStore = defineStore('fragments', () => {
11
18
  const api = useApi()
12
19
  const fragments = ref<PromptFragment[]>([])
13
20
  const loaded = ref(false)
21
+ /** The board the catalog was loaded for (null = never; '' = static pool, no board). */
22
+ const loadedFor = ref<string | null>(null)
14
23
 
15
- /** Fetch the catalog once; subsequent calls are no-ops. */
24
+ /** Fetch the catalog for the active board; a no-op while it is current. */
16
25
  async function ensureLoaded() {
17
- if (loaded.value) return
18
- fragments.value = await api.getPromptFragments()
26
+ const wsId = useWorkspaceStore().workspaceId ?? ''
27
+ if (loaded.value && loadedFor.value === wsId) return
28
+ // Prefer the merged tenant catalog; only a FAILURE (a 503 when the library is
29
+ // unconfigured, or any other error) degrades to the static universal pool — mapped to
30
+ // null by the catch. A successful-but-empty resolved catalog is left empty on purpose:
31
+ // it means every fragment is suppressed at some tier, and falling back to the static
32
+ // pool would resurrect the very built-ins the tenant tombstoned.
33
+ const resolved = wsId ? await api.getResolvedFragments(wsId).catch(() => null) : null
34
+ fragments.value = resolved ?? (await api.getPromptFragments())
35
+ loadedFor.value = wsId
19
36
  loaded.value = true
20
37
  }
21
38
 
39
+ /** Drop the cache so the next `ensureLoaded()` re-fetches (after a library edit). */
40
+ function invalidate() {
41
+ loaded.value = false
42
+ loadedFor.value = null
43
+ }
44
+
22
45
  const byId = computed(() => {
23
46
  const map = new Map<string, PromptFragment>()
24
47
  for (const f of fragments.value) map.set(f.id, f)
@@ -36,5 +59,5 @@ export const useFragmentsStore = defineStore('fragments', () => {
36
59
  )
37
60
  }
38
61
 
39
- return { fragments, loaded, ensureLoaded, byId, getFragment, forBlockType }
62
+ return { fragments, loaded, ensureLoaded, invalidate, byId, getFragment, forBlockType }
40
63
  })
@@ -21,6 +21,7 @@ import { useClarityStore } from '~/stores/clarity'
21
21
  import { useBrainstormStore } from '~/stores/brainstorm'
22
22
  import { useConsensusStore } from '~/stores/consensus'
23
23
  import { useGitHubStore } from '~/stores/github'
24
+ import { useFragmentsStore } from '~/stores/fragments'
24
25
  import { useProviderConnectionsStore } from '~/stores/providerConnections'
25
26
 
26
27
  /**
@@ -81,6 +82,10 @@ export const useWorkspaceStore = defineStore(
81
82
  useBrainstormStore().reset()
82
83
  useConsensusStore().reset()
83
84
  useGitHubStore().reset()
85
+ // The fragment picker catalog is per-board (the merged tenant catalog), so drop
86
+ // it too — the next inspector open re-fetches it for the switched-to board rather
87
+ // than showing the previous board's (or a raw-id placeholder for) fragments.
88
+ useFragmentsStore().invalidate()
84
89
  }
85
90
  workspaceId.value = snapshot.workspace.id
86
91
  spend.value = snapshot.spend ?? null
@@ -225,7 +225,13 @@
225
225
  "retryBootstrap": "Retry bootstrap",
226
226
  "retryRun": "Retry run",
227
227
  "showDetail": "Show detail",
228
- "retrying": "Retrying…"
228
+ "retrying": "Retrying…",
229
+ "history": {
230
+ "previousErrors": "{count} previous error | {count} previous errors",
231
+ "@previousErrors": {
232
+ "description": "Count-based tally of a run's earlier failed attempts, rendered as e.g. '3 previous errors' (count is always >= 1). Provide ALL plural forms your language needs (English has 2; Polish/Ukrainian need 3 - one/few/many - via the custom pluralRules in i18n.config.ts)."
233
+ }
234
+ }
229
235
  },
230
236
  "stop": {
231
237
  "label": "Stop",
@@ -204,7 +204,10 @@
204
204
  "retryBootstrap": "Reintentar arranque",
205
205
  "retryRun": "Reintentar ejecución",
206
206
  "showDetail": "Mostrar detalle",
207
- "retrying": "Reintentando…"
207
+ "retrying": "Reintentando…",
208
+ "history": {
209
+ "previousErrors": "{count} error anterior | {count} errores anteriores"
210
+ }
208
211
  },
209
212
  "stop": {
210
213
  "label": "Detener",
@@ -204,7 +204,10 @@
204
204
  "retryBootstrap": "Relancer l’initialisation",
205
205
  "retryRun": "Relancer l’exécution",
206
206
  "showDetail": "Afficher le détail",
207
- "retrying": "Nouvelle tentative…"
207
+ "retrying": "Nouvelle tentative…",
208
+ "history": {
209
+ "previousErrors": "{count} erreur précédente | {count} erreurs précédentes"
210
+ }
208
211
  },
209
212
  "stop": {
210
213
  "label": "Arrêter",
@@ -204,7 +204,10 @@
204
204
  "retryBootstrap": "נסה שוב לאתחל",
205
205
  "retryRun": "נסה שוב להריץ",
206
206
  "showDetail": "הצג פרטים",
207
- "retrying": "מנסה שוב…"
207
+ "retrying": "מנסה שוב…",
208
+ "history": {
209
+ "previousErrors": "שגיאה קודמת {count} | {count} שגיאות קודמות"
210
+ }
208
211
  },
209
212
  "stop": {
210
213
  "label": "עצור",
@@ -204,7 +204,10 @@
204
204
  "retryBootstrap": "ブートストラップを再試行",
205
205
  "retryRun": "実行を再試行",
206
206
  "showDetail": "詳細を表示",
207
- "retrying": "再試行中…"
207
+ "retrying": "再試行中…",
208
+ "history": {
209
+ "previousErrors": "以前のエラー {count} 件 | 以前のエラー {count} 件"
210
+ }
208
211
  },
209
212
  "stop": {
210
213
  "label": "停止",
@@ -204,7 +204,10 @@
204
204
  "retryBootstrap": "Ponów inicjalizację",
205
205
  "retryRun": "Ponów uruchomienie",
206
206
  "showDetail": "Pokaż szczegóły",
207
- "retrying": "Ponawianie…"
207
+ "retrying": "Ponawianie…",
208
+ "history": {
209
+ "previousErrors": "{count} poprzedni błąd | {count} poprzednie błędy | {count} poprzednich błędów"
210
+ }
208
211
  },
209
212
  "stop": {
210
213
  "label": "Zatrzymaj",
@@ -204,7 +204,10 @@
204
204
  "retryBootstrap": "Bootstrap'ı yeniden dene",
205
205
  "retryRun": "Çalıştırmayı yeniden dene",
206
206
  "showDetail": "Ayrıntıyı göster",
207
- "retrying": "Yeniden deneniyor…"
207
+ "retrying": "Yeniden deneniyor…",
208
+ "history": {
209
+ "previousErrors": "{count} önceki hata | {count} önceki hata"
210
+ }
208
211
  },
209
212
  "stop": {
210
213
  "label": "Durdur",
@@ -204,7 +204,10 @@
204
204
  "retryBootstrap": "Повторити ініціалізацію",
205
205
  "retryRun": "Повторити запуск",
206
206
  "showDetail": "Показати деталі",
207
- "retrying": "Повторення…"
207
+ "retrying": "Повторення…",
208
+ "history": {
209
+ "previousErrors": "{count} попередня помилка | {count} попередні помилки | {count} попередніх помилок"
210
+ }
208
211
  },
209
212
  "stop": {
210
213
  "label": "Зупинити",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.75.0",
3
+ "version": "0.75.2",
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",
@@ -34,7 +34,7 @@
34
34
  "valibot": "^1.4.2",
35
35
  "vue": "^3.5.39",
36
36
  "wretch": "^3.0.9",
37
- "@cat-factory/contracts": "0.81.2"
37
+ "@cat-factory/contracts": "0.81.3"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@toad-contracts/testing": "0.3.2",