@cat-factory/app 0.107.7 → 0.108.1

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.
@@ -0,0 +1,180 @@
1
+ <script setup lang="ts">
2
+ // Startup advisory for built-in model presets that drifted from the catalog. Opened once per
3
+ // session from the board page when `useModelPresetHealth` reports any issue. Lists:
4
+ // • new built-in presets the workspace doesn't have yet (ADD them);
5
+ // • built-ins with a newer catalog version available (RESEED to adopt it).
6
+ // Both fixes are the same reseed call (it creates or updates by catalog id). Detection is
7
+ // client-side (see useModelPresetHealth); the actions hit the modelPresets store.
8
+ const { t } = useI18n()
9
+ const ui = useUiStore()
10
+ const presets = useModelPresetsStore()
11
+ const { newPresets, outdated, hasIssues } = useModelPresetHealth()
12
+ const toast = useToast()
13
+
14
+ const open = computed({
15
+ get: () => ui.modelPresetHealthOpen,
16
+ set: (v: boolean) => {
17
+ if (!v) ui.closeModelPresetHealth()
18
+ },
19
+ })
20
+
21
+ // Per-preset in-flight ids, so each row's button shows its own spinner.
22
+ const busy = ref<Set<string>>(new Set())
23
+ const isBusy = (id: string) => busy.value.has(id)
24
+ const anyBusy = computed(() => busy.value.size > 0)
25
+
26
+ async function reseed(id: string) {
27
+ busy.value = new Set(busy.value).add(id)
28
+ try {
29
+ await presets.reseed(id)
30
+ } catch (e) {
31
+ toast.add({
32
+ title: t('modelPreset.health.toast.reseedFailed'),
33
+ description: e instanceof Error ? e.message : String(e),
34
+ icon: 'i-lucide-triangle-alert',
35
+ color: 'error',
36
+ })
37
+ } finally {
38
+ const next = new Set(busy.value)
39
+ next.delete(id)
40
+ busy.value = next
41
+ }
42
+ }
43
+
44
+ /** Reseed every advised preset (new + outdated built-ins) in one go, refreshing the board once. */
45
+ async function reseedAll() {
46
+ const ids = [...new Set([...newPresets.value, ...outdated.value].map((i) => i.id))]
47
+ busy.value = new Set([...busy.value, ...ids])
48
+ try {
49
+ await presets.reseedMany(ids)
50
+ } catch (e) {
51
+ toast.add({
52
+ title: t('modelPreset.health.toast.reseedFailed'),
53
+ description: e instanceof Error ? e.message : String(e),
54
+ icon: 'i-lucide-triangle-alert',
55
+ color: 'error',
56
+ })
57
+ } finally {
58
+ const next = new Set(busy.value)
59
+ for (const id of ids) next.delete(id)
60
+ busy.value = next
61
+ }
62
+ }
63
+
64
+ const reseedableCount = computed(
65
+ () => new Set([...newPresets.value, ...outdated.value].map((i) => i.id)).size,
66
+ )
67
+ </script>
68
+
69
+ <template>
70
+ <UModal v-model:open="open" :title="t('modelPreset.health.title')" :ui="{ content: 'max-w-2xl' }">
71
+ <template #body>
72
+ <div v-if="!hasIssues" class="py-6 text-center text-sm text-slate-400">
73
+ <UIcon name="i-lucide-check-circle-2" class="mx-auto mb-2 h-8 w-8 text-emerald-400" />
74
+ {{ t('modelPreset.health.allValid') }}
75
+ </div>
76
+
77
+ <div v-else class="space-y-5">
78
+ <!-- New built-in presets the workspace can add. -->
79
+ <section v-if="newPresets.length" class="space-y-2">
80
+ <div class="flex items-center gap-2">
81
+ <UIcon name="i-lucide-sparkles" class="h-4 w-4 text-emerald-400" />
82
+ <h3 class="text-sm font-semibold text-slate-200">
83
+ {{ t('modelPreset.health.newHeading') }}
84
+ </h3>
85
+ </div>
86
+ <p class="text-[11px] text-slate-500">{{ t('modelPreset.health.newDescription') }}</p>
87
+ <ul class="space-y-2">
88
+ <li
89
+ v-for="i in newPresets"
90
+ :key="i.id"
91
+ class="flex items-center justify-between gap-3 rounded-lg border border-slate-800 bg-slate-900/40 p-3"
92
+ >
93
+ <div class="min-w-0">
94
+ <span class="truncate text-sm font-medium text-slate-100 capitalize">{{
95
+ i.name
96
+ }}</span>
97
+ </div>
98
+ <UButton
99
+ size="xs"
100
+ color="primary"
101
+ variant="subtle"
102
+ icon="i-lucide-plus"
103
+ :loading="isBusy(i.id)"
104
+ :disabled="anyBusy"
105
+ @click="reseed(i.id)"
106
+ >
107
+ {{ t('modelPreset.health.add') }}
108
+ </UButton>
109
+ </li>
110
+ </ul>
111
+ </section>
112
+
113
+ <!-- Outdated built-ins: a newer catalog version is available. -->
114
+ <section v-if="outdated.length" class="space-y-2">
115
+ <div class="flex items-center gap-2">
116
+ <UIcon name="i-lucide-arrow-up-circle" class="h-4 w-4 text-amber-400" />
117
+ <h3 class="text-sm font-semibold text-slate-200">
118
+ {{ t('modelPreset.health.updatesHeading') }}
119
+ </h3>
120
+ </div>
121
+ <p class="text-[11px] text-slate-500">{{ t('modelPreset.health.updatesDescription') }}</p>
122
+ <ul class="space-y-2">
123
+ <li
124
+ v-for="i in outdated"
125
+ :key="i.id"
126
+ class="flex items-center justify-between gap-3 rounded-lg border border-slate-800 bg-slate-900/40 p-3"
127
+ >
128
+ <div class="min-w-0">
129
+ <span class="truncate text-sm font-medium text-slate-100">{{ i.name }}</span>
130
+ <p class="text-[11px] text-amber-400/80">
131
+ {{
132
+ t('modelPreset.health.versionAvailable', {
133
+ from: i.fromVersion ?? 0,
134
+ to: i.toVersion ?? 0,
135
+ })
136
+ }}
137
+ </p>
138
+ </div>
139
+ <UButton
140
+ size="xs"
141
+ color="primary"
142
+ variant="subtle"
143
+ icon="i-lucide-rotate-ccw"
144
+ :loading="isBusy(i.id)"
145
+ :disabled="anyBusy"
146
+ @click="reseed(i.id)"
147
+ >
148
+ {{ t('modelPreset.health.reseed') }}
149
+ </UButton>
150
+ </li>
151
+ </ul>
152
+ </section>
153
+ </div>
154
+ </template>
155
+
156
+ <template #footer>
157
+ <div class="flex w-full items-center justify-between gap-2">
158
+ <UButton
159
+ v-if="reseedableCount > 1"
160
+ color="primary"
161
+ variant="ghost"
162
+ icon="i-lucide-rotate-ccw"
163
+ :loading="anyBusy"
164
+ @click="reseedAll"
165
+ >
166
+ {{ t('modelPreset.health.reseedAll', { count: reseedableCount }) }}
167
+ </UButton>
168
+ <span v-else />
169
+ <UButton
170
+ color="neutral"
171
+ variant="ghost"
172
+ :disabled="anyBusy"
173
+ @click="ui.closeModelPresetHealth()"
174
+ >
175
+ {{ hasIssues ? t('modelPreset.health.dismiss') : t('modelPreset.health.done') }}
176
+ </UButton>
177
+ </div>
178
+ </template>
179
+ </UModal>
180
+ </template>
@@ -6,6 +6,7 @@ import {
6
6
  listMergePresetsContract,
7
7
  listModelPresetsContract,
8
8
  reseedMergePresetContract,
9
+ reseedModelPresetContract,
9
10
  updateMergePresetContract,
10
11
  updateModelPresetContract,
11
12
  } from '@cat-factory/contracts'
@@ -60,5 +61,10 @@ export function presetsApi({ send, ws }: ApiContext) {
60
61
 
61
62
  deleteModelPreset: (workspaceId: string, presetId: string) =>
62
63
  send(deleteModelPresetContract, { pathPrefix: ws(workspaceId), pathParams: { presetId } }),
64
+
65
+ // Restore a built-in model preset to its current catalog definition (adopt an update, repair
66
+ // a drifted one, or materialise a new built-in that appeared). Custom presets reject this.
67
+ reseedModelPreset: (workspaceId: string, presetId: string) =>
68
+ send(reseedModelPresetContract, { pathPrefix: ws(workspaceId), pathParams: { presetId } }),
63
69
  }
64
70
  }
@@ -0,0 +1,65 @@
1
+ import { computed } from 'vue'
2
+ import type { ModelPreset } from '~/types/model-presets'
3
+ import { useModelPresetsStore } from '~/stores/modelPresets'
4
+
5
+ export type ModelPresetIssueType = 'outdated' | 'new'
6
+
7
+ /** A built-in model preset that the workspace should reseed (an update, or a new one to add). */
8
+ export interface ModelPresetIssue {
9
+ type: ModelPresetIssueType
10
+ /** The catalog (built-in) id — what the reseed endpoint is keyed by. */
11
+ id: string
12
+ /** The preset name (the stored copy's for `outdated`, the built-in id for a `new` one). */
13
+ name: string
14
+ /** For an `outdated` issue: the persisted copy's version (the display copy renders it via i18n). */
15
+ fromVersion?: number
16
+ /** For an `outdated` issue: the newer catalog version available. */
17
+ toVersion?: number
18
+ }
19
+
20
+ /** A built-in's display name for an issue message (humanise its catalog id as a fallback). */
21
+ function builtinName(id: string, stored: ModelPreset | undefined): string {
22
+ if (stored) return stored.name
23
+ // `mdp_claude` -> "claude" — only used until the row is reseeded into existence.
24
+ return id.replace(/^mdp_/, '').replace(/_/g, ' ')
25
+ }
26
+
27
+ /**
28
+ * Detect built-in model presets the workspace should reseed for the startup advisory: a stored
29
+ * built-in whose catalog definition moved ahead (offer to adopt it) and a brand-new built-in
30
+ * that appeared in the catalog but isn't in the workspace yet (offer to add it). The catalog
31
+ * versions the snapshot ships ARE the set of built-in ids, so detection is entirely client-side:
32
+ * a stored preset is a built-in iff its id is a catalog key, and a catalog key with no stored
33
+ * preset is a new built-in.
34
+ */
35
+ export function useModelPresetHealth() {
36
+ const store = useModelPresetsStore()
37
+
38
+ const issues = computed<ModelPresetIssue[]>(() => {
39
+ const out: ModelPresetIssue[] = []
40
+ const byId = new Map(store.presets.map((p) => [p.id, p]))
41
+ for (const [id, catalogVersion] of Object.entries(store.catalogVersions)) {
42
+ const stored = byId.get(id)
43
+ if (!stored) {
44
+ out.push({ type: 'new', id, name: builtinName(id, undefined) })
45
+ continue
46
+ }
47
+ if (catalogVersion > (stored.version ?? 0)) {
48
+ out.push({
49
+ type: 'outdated',
50
+ id,
51
+ name: stored.name,
52
+ fromVersion: stored.version ?? 0,
53
+ toVersion: catalogVersion,
54
+ })
55
+ }
56
+ }
57
+ return out
58
+ })
59
+
60
+ const hasIssues = computed(() => issues.value.length > 0)
61
+ const newPresets = computed(() => issues.value.filter((i) => i.type === 'new'))
62
+ const outdated = computed(() => issues.value.filter((i) => i.type === 'outdated'))
63
+
64
+ return { issues, hasIssues, newPresets, outdated }
65
+ }
@@ -74,6 +74,10 @@ const PipelineHealthModal = defineAsyncComponent(
74
74
  const MergePresetHealthModal = defineAsyncComponent(
75
75
  () => import('~/components/settings/MergePresetHealthModal.vue'),
76
76
  )
77
+ // Startup advisory for new / outdated built-in model presets — same once-per-session pattern.
78
+ const ModelPresetHealthModal = defineAsyncComponent(
79
+ () => import('~/components/settings/ModelPresetHealthModal.vue'),
80
+ )
77
81
  const IntegrationsHub = defineAsyncComponent(
78
82
  () => import('~/components/layout/IntegrationsHub.vue'),
79
83
  )
@@ -192,6 +196,24 @@ watch(
192
196
  { immediate: true },
193
197
  )
194
198
 
199
+ // Same advisory for built-in model presets: surface new / outdated ones once per session. Defers
200
+ // to the pipeline + merge-preset advisories when they fire, so at most one modal auto-opens.
201
+ const { hasIssues: modelPresetIssues } = useModelPresetHealth()
202
+ watch(
203
+ () => [workspace.ready, modelPresetIssues.value, ui.pipelineHealthOpen, ui.mergePresetHealthOpen],
204
+ () => {
205
+ if (
206
+ workspace.ready &&
207
+ modelPresetIssues.value &&
208
+ !ui.pipelineHealthOpen &&
209
+ !ui.mergePresetHealthOpen
210
+ ) {
211
+ ui.maybeOpenModelPresetHealth()
212
+ }
213
+ },
214
+ { immediate: true },
215
+ )
216
+
195
217
  // Auto-open the right AI-onboarding dialog once per session: the no-source prompt takes
196
218
  // precedence over the preset-mismatch prompt. Honour the per-session dismissed flags so a
197
219
  // user who closed the banner isn't re-interrupted, and only auto-open once each (later opens
@@ -369,6 +391,7 @@ watch(
369
391
  <FragmentLibraryPanel v-if="ui.fragmentLibraryOpen" />
370
392
  <PipelineHealthModal v-if="ui.pipelineHealthOpen" />
371
393
  <MergePresetHealthModal v-if="ui.mergePresetHealthOpen" />
394
+ <ModelPresetHealthModal v-if="ui.modelPresetHealthOpen" />
372
395
  <IntegrationsHub v-if="ui.integrationsOpen" />
373
396
  <PersonalSetupModal v-if="ui.personalSetupOpen" />
374
397
  <WorkspaceSettingsPanel v-if="ui.workspaceSettingsOpen" />
@@ -18,9 +18,18 @@ export const useModelPresetsStore = defineStore('modelPresets', () => {
18
18
  const api = useApi()
19
19
 
20
20
  const presets = ref<ModelPreset[]>([])
21
+ /**
22
+ * Current built-in catalog versions (`seedModelPresets()`), keyed by preset id, from the
23
+ * workspace snapshot. The keys ARE the set of built-in ids: a stored preset whose id is a
24
+ * key here is a built-in (and is outdated when its `version` is below the catalog value),
25
+ * and a key with no matching stored preset is a NEW built-in the workspace can add. Drives
26
+ * `useModelPresetHealth`.
27
+ */
28
+ const catalogVersions = ref<Record<string, number>>({})
21
29
 
22
- function hydrate(list: ModelPreset[]) {
30
+ function hydrate(list: ModelPreset[], versions?: Record<string, number>) {
23
31
  presets.value = [...list].sort((a, b) => a.createdAt - b.createdAt)
32
+ if (versions) catalogVersions.value = versions
24
33
  }
25
34
 
26
35
  /** The workspace default (fallback for a task that picks none). */
@@ -61,5 +70,43 @@ export const useModelPresetsStore = defineStore('modelPresets', () => {
61
70
  await ws.refresh()
62
71
  }
63
72
 
64
- return { presets, defaultPreset, resolve, modelForKind, hydrate, create, update, remove }
73
+ /**
74
+ * Reseed a built-in preset from the backend's current catalog: adopt an updated definition,
75
+ * repair a drifted one, or materialise a NEW built-in that appeared after the workspace was
76
+ * created. The `presetId` is the catalog id (e.g. `mdp_kimi`). Refreshes the snapshot.
77
+ */
78
+ async function reseed(presetId: string) {
79
+ const ws = useWorkspaceStore()
80
+ const updated = await api.reseedModelPreset(ws.requireId(), presetId)
81
+ await ws.refresh()
82
+ return updated
83
+ }
84
+
85
+ /**
86
+ * Reseed several built-ins in one go, refreshing the snapshot ONCE at the end rather than
87
+ * after every id (each `reseed` refetches the whole board, so a per-id refresh in a loop is
88
+ * wasteful). The POSTs run sequentially so the backend's single-default invariant settles
89
+ * deterministically.
90
+ */
91
+ async function reseedMany(presetIds: string[]) {
92
+ const ws = useWorkspaceStore()
93
+ for (const presetId of presetIds) {
94
+ await api.reseedModelPreset(ws.requireId(), presetId)
95
+ }
96
+ await ws.refresh()
97
+ }
98
+
99
+ return {
100
+ presets,
101
+ catalogVersions,
102
+ defaultPreset,
103
+ resolve,
104
+ modelForKind,
105
+ hydrate,
106
+ create,
107
+ update,
108
+ remove,
109
+ reseed,
110
+ reseedMany,
111
+ }
65
112
  })
package/app/stores/ui.ts CHANGED
@@ -44,6 +44,11 @@ export const useUiStore = defineStore('ui', () => {
44
44
  // once per session so it does not re-pop on every snapshot re-hydration (mirrors pipelines).
45
45
  const mergePresetHealthOpen = ref(false)
46
46
  const mergePresetHealthSeen = ref(false)
47
+ // Model-preset health startup advisory: lists built-ins with a newer catalog version (reseed)
48
+ // and new built-in presets the workspace can add. `modelPresetHealthSeen` gates auto-open to
49
+ // once per session so it does not re-pop on every snapshot re-hydration (mirrors pipelines).
50
+ const modelPresetHealthOpen = ref(false)
51
+ const modelPresetHealthSeen = ref(false)
47
52
  const decisionContext = ref<{ instanceId: string; decisionId: string } | null>(null)
48
53
 
49
54
  // Document-source integration modals, keyed by source. `documentImport` and
@@ -323,6 +328,22 @@ export const useUiStore = defineStore('ui', () => {
323
328
  mergePresetHealthOpen.value = false
324
329
  }
325
330
 
331
+ /** Auto-open the model-preset health advisory once per session (no-op after it's been shown). */
332
+ function maybeOpenModelPresetHealth() {
333
+ if (modelPresetHealthSeen.value) return
334
+ modelPresetHealthSeen.value = true
335
+ modelPresetHealthOpen.value = true
336
+ }
337
+
338
+ function openModelPresetHealth() {
339
+ modelPresetHealthSeen.value = true
340
+ modelPresetHealthOpen.value = true
341
+ }
342
+
343
+ function closeModelPresetHealth() {
344
+ modelPresetHealthOpen.value = false
345
+ }
346
+
326
347
  function openDecision(instanceId: string, decisionId: string) {
327
348
  decisionContext.value = { instanceId, decisionId }
328
349
  }
@@ -820,6 +841,8 @@ export const useUiStore = defineStore('ui', () => {
820
841
  pipelineHealthSeen,
821
842
  mergePresetHealthOpen,
822
843
  mergePresetHealthSeen,
844
+ modelPresetHealthOpen,
845
+ modelPresetHealthSeen,
823
846
  decisionContext,
824
847
  documentConnect,
825
848
  documentImport,
@@ -887,6 +910,9 @@ export const useUiStore = defineStore('ui', () => {
887
910
  maybeOpenMergePresetHealth,
888
911
  openMergePresetHealth,
889
912
  closeMergePresetHealth,
913
+ maybeOpenModelPresetHealth,
914
+ openModelPresetHealth,
915
+ closeModelPresetHealth,
890
916
  openDecision,
891
917
  closeDecision,
892
918
  openApprovalDetail,
@@ -0,0 +1,113 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import type { Block, WorkspaceSnapshot } from '~/types/domain'
3
+ import { useBoardStore } from '~/stores/board'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+
6
+ // The workspace store's `hydrate` fans out to ~20 sibling stores via Nuxt auto-imports, which
7
+ // aren't defined under plain vitest. Stub every one INERT (a proxy whose every method is a no-op)
8
+ // EXCEPT the board store, which we keep real so the block list a refresh commits is observable.
9
+ const INERT_STORES = [
10
+ 'useAccountsStore',
11
+ 'useAgentConfigStore',
12
+ 'useAgentRunsStore',
13
+ 'useAgentsStore',
14
+ 'useBrainstormStore',
15
+ 'useClarityStore',
16
+ 'useConsensusStore',
17
+ 'useDocInterviewStore',
18
+ 'useExecutionStore',
19
+ 'useFragmentsStore',
20
+ 'useGitHubStore',
21
+ 'useInitiativesStore',
22
+ 'useMergePresetsStore',
23
+ 'useModelPresetsStore',
24
+ 'useNotificationsStore',
25
+ 'usePipelinesStore',
26
+ 'useProviderConnectionsStore',
27
+ 'useRecurringPipelinesStore',
28
+ 'useRequirementsStore',
29
+ 'useServiceFragmentDefaultsStore',
30
+ 'useServicesStore',
31
+ 'useSharedStacksStore',
32
+ 'useTrackerStore',
33
+ 'useUserSettingsStore',
34
+ 'useWorkspaceSettingsStore',
35
+ ]
36
+ beforeEach(() => {
37
+ const inert = () => new Proxy({}, { get: () => () => undefined })
38
+ for (const name of INERT_STORES) vi.stubGlobal(name, inert)
39
+ // The real board store (same active Pinia) so `getBlock` reflects the snapshot a refresh hydrates.
40
+ vi.stubGlobal('useBoardStore', useBoardStore)
41
+ })
42
+
43
+ // Regression for the live-push CLOBBER race: `board`-type stream events (and the on-connect
44
+ // resync) each trigger a full-snapshot `refresh()`, and `hydrate` REPLACES the block list. Two
45
+ // refreshes can be in flight at once (events >300ms apart, or a resync + a board event), and if a
46
+ // slower/staler fetch resolves AFTER a newer one, its hydrate used to clobber the newer state —
47
+ // dropping a just-spawned block whose only live delivery was the coarse board event, so its card
48
+ // never reappeared (no further event to restore it). This surfaced as an intermittent e2e timeout
49
+ // where a spawned task/document card never rendered. `refresh()` now stamps each call so only the
50
+ // latest-issued one commits; this test pins that a stale out-of-order refresh can't win.
51
+
52
+ /** Minimal block — only the fields the board store's index getters read. */
53
+ function block(id: string, over: Partial<Block> = {}): Block {
54
+ return {
55
+ id,
56
+ title: id,
57
+ type: 'service',
58
+ description: '',
59
+ position: { x: 0, y: 0 },
60
+ status: 'planned',
61
+ progress: 0,
62
+ dependsOn: [],
63
+ executionId: null,
64
+ level: 'frame',
65
+ parentId: null,
66
+ ...over,
67
+ }
68
+ }
69
+
70
+ /** Minimal snapshot — the arrays a bare hydrate iterates; everything else defaults. */
71
+ function snapshot(id: string, blocks: Block[]): WorkspaceSnapshot {
72
+ return {
73
+ workspace: { id, name: id, accountId: null },
74
+ blocks,
75
+ pipelines: [],
76
+ executions: [],
77
+ } as unknown as WorkspaceSnapshot
78
+ }
79
+
80
+ describe('workspace store refresh ordering', () => {
81
+ it('a stale refresh resolving out of order does not clobber a newer one', async () => {
82
+ const frame = block('f1')
83
+ const spawned = block('spawned', { level: 'task', parentId: 'f1' })
84
+ let resolveStale!: (s: WorkspaceSnapshot) => void
85
+ let resolveFresh!: (s: WorkspaceSnapshot) => void
86
+
87
+ const getWorkspace = vi
88
+ .fn()
89
+ // 1) switchTo establishes the active board (no spawned block yet).
90
+ .mockResolvedValueOnce(snapshot('ws1', [frame]))
91
+ // 2) the EARLIER-issued refresh (stale: still no spawned block) — resolved LAST below.
92
+ .mockReturnValueOnce(new Promise<WorkspaceSnapshot>((r) => (resolveStale = r)))
93
+ // 3) the LATER-issued refresh (fresh: the spawned block landed) — resolved FIRST below.
94
+ .mockReturnValueOnce(new Promise<WorkspaceSnapshot>((r) => (resolveFresh = r)))
95
+ vi.stubGlobal('useApi', () => ({ getWorkspace }))
96
+
97
+ const ws = useWorkspaceStore()
98
+ const board = useBoardStore()
99
+ await ws.switchTo('ws1')
100
+ expect(board.getBlock('spawned')).toBeUndefined()
101
+
102
+ // Two overlapping refreshes: the later-issued one carries the fresher snapshot, but the
103
+ // earlier-issued (stale) one resolves last — the exact out-of-order clobber the guard blocks.
104
+ const stalePass = ws.refresh()
105
+ const freshPass = ws.refresh()
106
+ resolveFresh(snapshot('ws1', [frame, spawned]))
107
+ resolveStale(snapshot('ws1', [frame]))
108
+ await Promise.all([stalePass, freshPass])
109
+
110
+ // The fresh snapshot won and the stale one was discarded: the spawned card survives.
111
+ expect(board.getBlock('spawned')).toBeDefined()
112
+ })
113
+ })
@@ -127,7 +127,10 @@ export const useWorkspaceStore = defineStore(
127
127
  useSharedStacksStore().hydrate(snapshot.sharedStacks ?? [])
128
128
  useWorkspaceSettingsStore().hydrate(snapshot.settings)
129
129
  useAgentConfigStore().hydrate(snapshot.agentConfigCatalog ?? [])
130
- useModelPresetsStore().hydrate(snapshot.modelPresets ?? [])
130
+ useModelPresetsStore().hydrate(
131
+ snapshot.modelPresets ?? [],
132
+ snapshot.modelPresetCatalogVersions,
133
+ )
131
134
  useServiceFragmentDefaultsStore().hydrate(snapshot.serviceFragmentDefaults?.fragmentIds)
132
135
  useRecurringPipelinesStore().hydrate(snapshot.recurringPipelines ?? [])
133
136
  useInitiativesStore().hydrate(snapshot.initiatives)
@@ -247,10 +250,24 @@ export const useWorkspaceStore = defineStore(
247
250
  }
248
251
  }
249
252
 
253
+ // Monotonic guard for {@link refresh}: `board`-type stream events (and the on-connect resync)
254
+ // each fire a full-snapshot refresh, and {@link hydrate} REPLACES the block list. Without
255
+ // ordering, two in-flight fetches can resolve out of order, so a slower/staler snapshot's
256
+ // hydrate clobbers a newer one — dropping a just-spawned block whose ONLY live delivery was
257
+ // the coarse `board` event (there is no per-block push), so its card never reappears (no
258
+ // further event to restore it). Stamping each call lets only the latest-issued refresh commit.
259
+ let refreshSeq = 0
260
+
250
261
  /** Re-fetch the snapshot and re-hydrate (after mutations and on stream (re)connect). */
251
262
  async function refresh() {
252
- if (!workspaceId.value) return
253
- hydrate(await api.getWorkspace(workspaceId.value))
263
+ const targetId = workspaceId.value
264
+ if (!targetId) return
265
+ const seq = ++refreshSeq
266
+ const snapshot = await api.getWorkspace(targetId)
267
+ // A newer refresh was issued (or the active board switched) while this fetch was in flight —
268
+ // discard this older/staler result so it can't clobber the newer hydrate.
269
+ if (seq !== refreshSeq || workspaceId.value !== targetId) return
270
+ hydrate(snapshot)
254
271
  }
255
272
 
256
273
  /** The active workspace id, or throw if the app isn't bootstrapped yet. */
@@ -4374,6 +4374,25 @@
4374
4374
  }
4375
4375
  }
4376
4376
  },
4377
+ "modelPreset": {
4378
+ "health": {
4379
+ "title": "Modell-Preset-Updates",
4380
+ "allValid": "Alle integrierten Modell-Presets sind auf dem neuesten Stand.",
4381
+ "newHeading": "Neue Presets verfügbar",
4382
+ "newDescription": "Neue integrierte Modell-Presets sind verfügbar. Füge sie zur Bibliothek dieses Boards hinzu.",
4383
+ "add": "Hinzufügen",
4384
+ "updatesHeading": "Updates verfügbar",
4385
+ "updatesDescription": "Eine neuere Version dieser integrierten Modell-Presets ist verfügbar. Führe ein erneutes Seeding durch, um sie zu übernehmen (Standard und Reihenfolge bleiben erhalten).",
4386
+ "versionAvailable": "Version {from} → {to} verfügbar.",
4387
+ "reseed": "Erneut seeden",
4388
+ "reseedAll": "Alle aktualisieren ({count})",
4389
+ "dismiss": "Schließen",
4390
+ "done": "Fertig",
4391
+ "toast": {
4392
+ "reseedFailed": "Modell-Preset konnte nicht erneut geseedet werden"
4393
+ }
4394
+ }
4395
+ },
4377
4396
  "environmentWizard": {
4378
4397
  "title": "Eine Testumgebung einrichten",
4379
4398
  "subtitle": "Erkenne, prüfe und speichere ein Docker-Compose-Rezept, damit der Deployer es bereitstellt.",
@@ -3269,6 +3269,25 @@
3269
3269
  }
3270
3270
  }
3271
3271
  },
3272
+ "modelPreset": {
3273
+ "health": {
3274
+ "title": "Model preset updates",
3275
+ "allValid": "All built-in model presets are up to date.",
3276
+ "newHeading": "New presets available",
3277
+ "newDescription": "New built-in model presets have shipped. Add them to this board's library.",
3278
+ "add": "Add",
3279
+ "updatesHeading": "Updates available",
3280
+ "updatesDescription": "A newer version of these built-in model presets has shipped. Reseed to adopt it (the default and ordering are kept).",
3281
+ "versionAvailable": "Version {from} → {to} available.",
3282
+ "reseed": "Reseed",
3283
+ "reseedAll": "Update all ({count})",
3284
+ "dismiss": "Dismiss",
3285
+ "done": "Done",
3286
+ "toast": {
3287
+ "reseedFailed": "Could not reseed model preset"
3288
+ }
3289
+ }
3290
+ },
3272
3291
  "palette": {
3273
3292
  "hint": "Click an agent to append it to the pipeline.",
3274
3293
  "customAgents": "Custom agents"
@@ -4264,6 +4264,25 @@
4264
4264
  }
4265
4265
  }
4266
4266
  },
4267
+ "modelPreset": {
4268
+ "health": {
4269
+ "title": "Actualizaciones de presets de modelo",
4270
+ "allValid": "Todos los presets de modelo integrados están actualizados.",
4271
+ "newHeading": "Nuevos presets disponibles",
4272
+ "newDescription": "Hay nuevos presets de modelo integrados. Añádelos a la biblioteca de este tablero.",
4273
+ "add": "Añadir",
4274
+ "updatesHeading": "Actualizaciones disponibles",
4275
+ "updatesDescription": "Hay una versión más reciente de estos presets de modelo integrados. Regenera para adoptarla (se conservan el predeterminado y el orden).",
4276
+ "versionAvailable": "Versión {from} → {to} disponible.",
4277
+ "reseed": "Regenerar",
4278
+ "reseedAll": "Actualizar todos ({count})",
4279
+ "dismiss": "Descartar",
4280
+ "done": "Hecho",
4281
+ "toast": {
4282
+ "reseedFailed": "No se pudo regenerar el preset de modelo"
4283
+ }
4284
+ }
4285
+ },
4267
4286
  "initiative": {
4268
4287
  "create": {
4269
4288
  "title": "Crear iniciativa",
@@ -4264,6 +4264,25 @@
4264
4264
  }
4265
4265
  }
4266
4266
  },
4267
+ "modelPreset": {
4268
+ "health": {
4269
+ "title": "Mises à jour des presets de modèle",
4270
+ "allValid": "Tous les presets de modèle intégrés sont à jour.",
4271
+ "newHeading": "Nouveaux presets disponibles",
4272
+ "newDescription": "De nouveaux presets de modèle intégrés sont arrivés. Ajoutez-les à la bibliothèque de ce tableau.",
4273
+ "add": "Ajouter",
4274
+ "updatesHeading": "Mises à jour disponibles",
4275
+ "updatesDescription": "Une version plus récente de ces presets de modèle intégrés est arrivée. Régénérez pour l'adopter (le preset par défaut et l'ordre sont conservés).",
4276
+ "versionAvailable": "Version {from} → {to} disponible.",
4277
+ "reseed": "Régénérer",
4278
+ "reseedAll": "Tout mettre à jour ({count})",
4279
+ "dismiss": "Ignorer",
4280
+ "done": "Terminé",
4281
+ "toast": {
4282
+ "reseedFailed": "Impossible de régénérer le preset de modèle"
4283
+ }
4284
+ }
4285
+ },
4267
4286
  "initiative": {
4268
4287
  "create": {
4269
4288
  "title": "Creer une initiative",
@@ -4275,6 +4275,25 @@
4275
4275
  }
4276
4276
  }
4277
4277
  },
4278
+ "modelPreset": {
4279
+ "health": {
4280
+ "title": "עדכוני קביעות מודל",
4281
+ "allValid": "כל קביעות המודל המובנות מעודכנות.",
4282
+ "newHeading": "קביעות חדשות זמינות",
4283
+ "newDescription": "הגיעו קביעות מודל מובנות חדשות. הוסף אותן לספריית הלוח הזה.",
4284
+ "add": "הוסף",
4285
+ "updatesHeading": "עדכונים זמינים",
4286
+ "updatesDescription": "גרסה חדשה יותר של קביעות המודל המובנות האלה הגיעה. זרע מחדש כדי לאמץ אותה (ברירת המחדל והסדר נשמרים).",
4287
+ "versionAvailable": "גרסה {from} → {to} זמינה.",
4288
+ "reseed": "זרע מחדש",
4289
+ "reseedAll": "עדכן הכל ({count})",
4290
+ "dismiss": "התעלם",
4291
+ "done": "בוצע",
4292
+ "toast": {
4293
+ "reseedFailed": "לא ניתן לזרוע מחדש את קביעת המודל"
4294
+ }
4295
+ }
4296
+ },
4278
4297
  "initiative": {
4279
4298
  "create": {
4280
4299
  "title": "יצירת יוזמה",
@@ -4374,6 +4374,25 @@
4374
4374
  }
4375
4375
  }
4376
4376
  },
4377
+ "modelPreset": {
4378
+ "health": {
4379
+ "title": "Aggiornamenti dei preset di modello",
4380
+ "allValid": "Tutti i preset di modello integrati sono aggiornati.",
4381
+ "newHeading": "Nuovi preset disponibili",
4382
+ "newDescription": "Sono stati rilasciati nuovi preset di modello integrati. Aggiungili alla libreria di questa board.",
4383
+ "add": "Aggiungi",
4384
+ "updatesHeading": "Aggiornamenti disponibili",
4385
+ "updatesDescription": "È stata rilasciata una versione più recente di questi preset di modello integrati. Ripristina i valori iniziali per adottarla (il predefinito e l'ordinamento vengono mantenuti).",
4386
+ "versionAvailable": "Versione {from} → {to} disponibile.",
4387
+ "reseed": "Ripristina",
4388
+ "reseedAll": "Aggiorna tutti ({count})",
4389
+ "dismiss": "Ignora",
4390
+ "done": "Fatto",
4391
+ "toast": {
4392
+ "reseedFailed": "Impossibile ripristinare il preset di modello"
4393
+ }
4394
+ }
4395
+ },
4377
4396
  "environmentWizard": {
4378
4397
  "title": "Configura un ambiente di test",
4379
4398
  "subtitle": "Rileva, rivedi e salva una ricetta Docker Compose in modo che il Deployer la esegua.",
@@ -4276,6 +4276,25 @@
4276
4276
  }
4277
4277
  }
4278
4278
  },
4279
+ "modelPreset": {
4280
+ "health": {
4281
+ "title": "モデルプリセットの更新",
4282
+ "allValid": "組み込みのモデルプリセットはすべて最新です。",
4283
+ "newHeading": "新しいプリセットがあります",
4284
+ "newDescription": "新しい組み込みモデルプリセットが追加されました。このボードのライブラリに追加してください。",
4285
+ "add": "追加",
4286
+ "updatesHeading": "更新あり",
4287
+ "updatesDescription": "これらの組み込みモデルプリセットの新しいバージョンがあります。再シードして取り込みます(既定と並び順は保持されます)。",
4288
+ "versionAvailable": "バージョン {from} → {to} が利用可能です。",
4289
+ "reseed": "再シード",
4290
+ "reseedAll": "すべて更新 ({count})",
4291
+ "dismiss": "閉じる",
4292
+ "done": "完了",
4293
+ "toast": {
4294
+ "reseedFailed": "モデルプリセットを再シードできませんでした"
4295
+ }
4296
+ }
4297
+ },
4279
4298
  "initiative": {
4280
4299
  "create": {
4281
4300
  "title": "イニシアチブを作成",
@@ -4264,6 +4264,25 @@
4264
4264
  }
4265
4265
  }
4266
4266
  },
4267
+ "modelPreset": {
4268
+ "health": {
4269
+ "title": "Aktualizacje presetów modelu",
4270
+ "allValid": "Wszystkie wbudowane presety modelu są aktualne.",
4271
+ "newHeading": "Dostępne nowe presety",
4272
+ "newDescription": "Pojawiły się nowe wbudowane presety modelu. Dodaj je do biblioteki tej tablicy.",
4273
+ "add": "Dodaj",
4274
+ "updatesHeading": "Dostępne aktualizacje",
4275
+ "updatesDescription": "Dostępna jest nowsza wersja tych wbudowanych presetów modelu. Zregeneruj, aby ją przyjąć (domyślny i kolejność są zachowane).",
4276
+ "versionAvailable": "Dostępna wersja {from} → {to}.",
4277
+ "reseed": "Zregeneruj",
4278
+ "reseedAll": "Zaktualizuj wszystkie ({count})",
4279
+ "dismiss": "Odrzuć",
4280
+ "done": "Gotowe",
4281
+ "toast": {
4282
+ "reseedFailed": "Nie udało się zregenerować presetu modelu"
4283
+ }
4284
+ }
4285
+ },
4267
4286
  "initiative": {
4268
4287
  "create": {
4269
4288
  "title": "Utworz inicjatywe",
@@ -4276,6 +4276,25 @@
4276
4276
  }
4277
4277
  }
4278
4278
  },
4279
+ "modelPreset": {
4280
+ "health": {
4281
+ "title": "Model ön ayarı güncellemeleri",
4282
+ "allValid": "Tüm yerleşik model ön ayarları güncel.",
4283
+ "newHeading": "Yeni ön ayarlar mevcut",
4284
+ "newDescription": "Yeni yerleşik model ön ayarları geldi. Bu panonun kütüphanesine ekleyin.",
4285
+ "add": "Ekle",
4286
+ "updatesHeading": "Güncellemeler mevcut",
4287
+ "updatesDescription": "Bu yerleşik model ön ayarlarının daha yeni bir sürümü geldi. Benimsemek için yeniden tohumlayın (varsayılan ve sıralama korunur).",
4288
+ "versionAvailable": "Sürüm {from} → {to} mevcut.",
4289
+ "reseed": "Yeniden tohumla",
4290
+ "reseedAll": "Tümünü güncelle ({count})",
4291
+ "dismiss": "Yoksay",
4292
+ "done": "Tamam",
4293
+ "toast": {
4294
+ "reseedFailed": "Model ön ayarı yeniden tohumlanamadı"
4295
+ }
4296
+ }
4297
+ },
4279
4298
  "initiative": {
4280
4299
  "create": {
4281
4300
  "title": "Girisim olustur",
@@ -4264,6 +4264,25 @@
4264
4264
  }
4265
4265
  }
4266
4266
  },
4267
+ "modelPreset": {
4268
+ "health": {
4269
+ "title": "Оновлення пресетів моделі",
4270
+ "allValid": "Усі вбудовані пресети моделі актуальні.",
4271
+ "newHeading": "Доступні нові пресети",
4272
+ "newDescription": "З'явилися нові вбудовані пресети моделі. Додайте їх до бібліотеки цієї дошки.",
4273
+ "add": "Додати",
4274
+ "updatesHeading": "Доступні оновлення",
4275
+ "updatesDescription": "Доступна новіша версія цих вбудованих пресетів моделі. Перегенеруйте, щоб застосувати її (стандартний пресет і порядок збережено).",
4276
+ "versionAvailable": "Доступна версія {from} → {to}.",
4277
+ "reseed": "Перегенерувати",
4278
+ "reseedAll": "Оновити всі ({count})",
4279
+ "dismiss": "Відхилити",
4280
+ "done": "Готово",
4281
+ "toast": {
4282
+ "reseedFailed": "Не вдалося перегенерувати пресет моделі"
4283
+ }
4284
+ }
4285
+ },
4267
4286
  "initiative": {
4268
4287
  "create": {
4269
4288
  "title": "Створити ініціативу",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.107.7",
3
+ "version": "0.108.1",
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.118.0"
37
+ "@cat-factory/contracts": "0.119.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@toad-contracts/testing": "0.3.2",