@cat-factory/app 0.206.0 → 0.208.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.
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { parseMediaTypeRequirement, sameFormats } from './BinaryOutputStepPicker.logic'
3
+
4
+ describe('parseMediaTypeRequirement', () => {
5
+ it('stores the reduction the backend compares against, not what was typed', () => {
6
+ // The field is forgiving on the way in and exact on the way out. A locally-lowercased copy
7
+ // would store a format that matches nothing and then reads everywhere as one that was simply
8
+ // never emitted — indistinguishable from a real delivery failure.
9
+ expect(parseMediaTypeRequirement(' Model/GLTF-Binary , image/PNG ').usable).toEqual([
10
+ 'model/gltf-binary',
11
+ 'image/png',
12
+ ])
13
+ })
14
+
15
+ it('drops a parameter, because a requirement is a format and not one request encoding', () => {
16
+ expect(parseMediaTypeRequirement('model/gltf-binary; charset=binary').usable).toEqual([
17
+ 'model/gltf-binary',
18
+ ])
19
+ })
20
+
21
+ it('NAMES what it refused rather than quietly shortening the requirement', () => {
22
+ // A requirement someone typed and the step does not carry is the "absent reads as fine"
23
+ // failure the rest of this surface is built to avoid, so the entry survives verbatim for the
24
+ // warning to quote.
25
+ const parsed = parseMediaTypeRequirement('gltf, model/obj, ')
26
+ expect(parsed.usable).toEqual(['model/obj'])
27
+ expect(parsed.unusable).toEqual(['gltf'])
28
+ })
29
+
30
+ it('deduplicates what two spellings reduce to, keeping first-stated order', () => {
31
+ const parsed = parseMediaTypeRequirement('model/obj, MODEL/OBJ, model/gltf-binary')
32
+ expect(parsed.usable).toEqual(['model/obj', 'model/gltf-binary'])
33
+ })
34
+
35
+ it('maps no synonyms, so a near neighbour stays a separate requirement', () => {
36
+ // `model/obj` and `application/x-tgif` are the same file. Collapsing them would make the
37
+ // admission check accept a GLB where an OBJ was required — the failure it exists to prevent.
38
+ const parsed = parseMediaTypeRequirement('model/obj, application/x-tgif')
39
+ expect(parsed.usable).toEqual(['model/obj', 'application/x-tgif'])
40
+ })
41
+
42
+ it('reads an empty requirement as no requirement', () => {
43
+ expect(parseMediaTypeRequirement(' , ')).toEqual({ usable: [], unusable: [] })
44
+ })
45
+ })
46
+
47
+ describe('sameFormats', () => {
48
+ it('treats an absent list and an empty one as the same write', () => {
49
+ // Clearing the field stores `undefined`, so the two spellings of "no requirement" must not
50
+ // read as a change that came from elsewhere.
51
+ expect(sameFormats(undefined, [])).toBe(true)
52
+ })
53
+
54
+ it('is order-sensitive, because the field writes back exactly what it read', () => {
55
+ expect(sameFormats(['a/b', 'c/d'], ['c/d', 'a/b'])).toBe(false)
56
+ expect(sameFormats(['a/b', 'c/d'], ['a/b', 'c/d'])).toBe(true)
57
+ })
58
+ })
@@ -0,0 +1,55 @@
1
+ import { mediaTypeSchema, normalizeMediaType } from '@cat-factory/contracts'
2
+ import * as v from 'valibot'
3
+
4
+ // The pure half of BinaryOutputStepPicker: reading a free-text FORMAT requirement, and telling
5
+ // this field's own write apart from one that landed underneath it. Extracted for the reason every
6
+ // `*.logic.ts` here is — a decision worth a test should not need a mounted component to reach.
7
+
8
+ /** A parsed format requirement: what the step will carry, and what was refused on the way in. */
9
+ export interface ParsedMediaTypeRequirement {
10
+ /** Normalised, deduplicated, order-preserving — exactly what gets stored. */
11
+ usable: string[]
12
+ /** Entries that are not a `type/subtype` at all, kept VERBATIM so the warning can quote them. */
13
+ unusable: string[]
14
+ }
15
+
16
+ /**
17
+ * Read a comma-separated format requirement the way the field accepts it and the way the backend
18
+ * will hold it.
19
+ *
20
+ * Forgiving on the way IN, exact on the way out, and both halves are the backend's own rules
21
+ * imported rather than re-implemented: `normalizeMediaType` is the same reduction the comparison
22
+ * uses at both ends (a divergent local lowercasing would store a format that matches nothing and
23
+ * reads everywhere as one that was simply never emitted), and `mediaTypeSchema` is what the save
24
+ * boundary holds this to — so what is refused here is exactly what would come back as a 422 one
25
+ * round trip later.
26
+ *
27
+ * A refused entry is REPORTED, never quietly dropped: a requirement someone typed and the step
28
+ * does not carry is the "absent reads as fine" failure the rest of this surface exists to avoid.
29
+ */
30
+ export function parseMediaTypeRequirement(text: string): ParsedMediaTypeRequirement {
31
+ const usable: string[] = []
32
+ const unusable: string[] = []
33
+ for (const entry of text
34
+ .split(',')
35
+ .map((part) => part.trim())
36
+ .filter(Boolean)) {
37
+ const normalized = normalizeMediaType(entry)
38
+ if (normalized && v.safeParse(mediaTypeSchema, normalized).success) usable.push(normalized)
39
+ else unusable.push(entry)
40
+ }
41
+ return { usable: [...new Set(usable)], unusable }
42
+ }
43
+
44
+ /**
45
+ * Whether two stored format lists are the same write.
46
+ *
47
+ * Order-sensitive on purpose: the field writes back what it read, so a differing order means the
48
+ * value came from somewhere else, which is precisely what the caller is asking about.
49
+ */
50
+ export function sameFormats(
51
+ a: readonly string[] | undefined,
52
+ b: readonly string[] | undefined,
53
+ ): boolean {
54
+ return (a ?? []).join(',') === (b ?? []).join(',')
55
+ }
@@ -19,14 +19,16 @@
19
19
  // ride the workspace snapshot (`binaryGenerators`) rather than a catalog read. Both halves are
20
20
  // offered here because a step needs both to work, and only this surface can tell a human that the
21
21
  // content types it promises to deliver are not covered by anything it selected.
22
- import { computed } from 'vue'
22
+ import { computed, ref, watch } from 'vue'
23
23
  import {
24
24
  ASSET_STORAGE_CAPABILITY,
25
25
  GENERATION_CONTEXT_CAPABILITY,
26
+ isBinaryModality,
26
27
  type BinaryModality,
27
28
  type BinaryOutputConfig,
28
29
  } from '@cat-factory/contracts'
29
30
  import { binaryOutputPickIssues, type BinaryOutputPickIssue } from '~/utils/binaryOutput'
31
+ import { parseMediaTypeRequirement, sameFormats } from './BinaryOutputStepPicker.logic'
30
32
 
31
33
  const props = defineProps<{ index: number }>()
32
34
 
@@ -44,12 +46,42 @@ const MODALITY_LABELS: Record<BinaryModality, () => string> = {
44
46
  image: () => t('pipeline.builder.binaryOutputModality.image'),
45
47
  audio: () => t('pipeline.builder.binaryOutputModality.audio'),
46
48
  video: () => t('pipeline.builder.binaryOutputModality.video'),
47
- '3d': () => t('pipeline.builder.binaryOutputModality.3d'),
49
+ '3d-model': () => t('pipeline.builder.binaryOutputModality.3d-model'),
50
+ '3d-scene': () => t('pipeline.builder.binaryOutputModality.3d-scene'),
48
51
  document: () => t('pipeline.builder.binaryOutputModality.document'),
49
52
  }
50
- const MODALITY_ORDER: BinaryModality[] = ['image', 'audio', 'video', '3d', 'document']
53
+ const MODALITY_ORDER: BinaryModality[] = [
54
+ 'image',
55
+ 'audio',
56
+ 'video',
57
+ '3d-model',
58
+ '3d-scene',
59
+ 'document',
60
+ ]
61
+ /**
62
+ * A content type in the reader's language, INCLUDING one this build no longer defines.
63
+ *
64
+ * The `Record` above is exhaustive over the union, so the lookup looks total — and is not, because
65
+ * `modalities` is PERSISTED: a step saved under an earlier vocabulary carries a member that has
66
+ * since been retired (`3d` did exactly that when it split into `3d-model` and `3d-scene`). Such a
67
+ * value is by construction uncovered by every registered integration, so it lands in the
68
+ * `modality_uncovered` warning below — the one line whose job is to tell someone what to re-pick —
69
+ * and a bare `MODALITY_LABELS[modality]()` there is a `TypeError` that takes the whole builder
70
+ * down, on exactly the surface the fix has to be made on.
71
+ *
72
+ * The guard is `isBinaryModality` (contracts, derived from the picklist itself) rather than an
73
+ * optional call on the `Record`, so the narrowing says WHY it is needed and a member added to the
74
+ * vocabulary is known here without anyone remembering to widen anything.
75
+ *
76
+ * The retired value is NAMED rather than silently dropped or guessed at a current member: nothing
77
+ * here knows which one was meant, and a modality quietly missing from the list reads as a step
78
+ * that never required it. This is the standing "absent is not zero" rule at the one place the
79
+ * typed-key check cannot reach — the key is static, but the LOOKUP is a runtime value.
80
+ */
51
81
  function modalityLabel(modality: BinaryModality): string {
52
- return MODALITY_LABELS[modality]()
82
+ return isBinaryModality(modality)
83
+ ? MODALITY_LABELS[modality]()
84
+ : t('pipeline.builder.binaryOutputModalityRetired', { modality: String(modality) })
53
85
  }
54
86
 
55
87
  const config = computed(() => pipelines.draftBinaryOutput(props.index))
@@ -143,6 +175,64 @@ function setGenerators(ids: string[]) {
143
175
  function setModalities(modalities: BinaryModality[]) {
144
176
  patch({ modalities })
145
177
  }
178
+
179
+ /**
180
+ * The FORMAT requirement is free text, not a pick from the selection, and that is deliberate: the
181
+ * whole reason a step states a format is that the selected integrations might not cover it, and a
182
+ * picker offering only what they declare could never express the requirement whose violation this
183
+ * feature exists to catch. What the selection declares is offered as a HINT below instead.
184
+ *
185
+ * Held in a local ref rather than bound straight to the config so a half-typed `model/` is not
186
+ * parsed on every keystroke, and so the normalisation the field applies is VISIBLE — the text
187
+ * snaps back to what was stored.
188
+ */
189
+ const mediaTypeText = ref((config.value?.mediaTypes ?? []).join(', '))
190
+
191
+ /**
192
+ * Entries that are not a `type/subtype` at all, named rather than silently dropped — a
193
+ * requirement someone typed and the step does not carry is exactly the "absent reads as fine"
194
+ * failure the rest of this surface is built to avoid.
195
+ */
196
+ const unusableMediaTypes = ref<string[]>([])
197
+
198
+ /**
199
+ * What this field last wrote, so the watch below can tell its OWN patch from a config that
200
+ * changed underneath it.
201
+ */
202
+ let lastWritten: string[] | undefined = config.value?.mediaTypes
203
+
204
+ watch(
205
+ () => config.value?.mediaTypes,
206
+ (mediaTypes) => {
207
+ mediaTypeText.value = (mediaTypes ?? []).join(', ')
208
+ // The rejected entries belong to the TEXT that was typed, so they outlive this field's own
209
+ // patch — clearing them on every config change would erase the warning in the same tick it
210
+ // was raised, since accepting `image/png` out of `foo, image/png` is itself a patch. Any
211
+ // OTHER route to a new value (the picker rebound to another step, the draft reloaded,
212
+ // storage cleared and the bag dropped) is describing text that no longer exists, and a
213
+ // warning about entries nobody can see is the same "absent reads as fine" failure pointed
214
+ // the other way.
215
+ if (!sameFormats(mediaTypes, lastWritten)) unusableMediaTypes.value = []
216
+ lastWritten = mediaTypes
217
+ },
218
+ )
219
+
220
+ function setMediaTypes(text: string) {
221
+ const { usable, unusable } = parseMediaTypeRequirement(text)
222
+ unusableMediaTypes.value = unusable
223
+ mediaTypeText.value = usable.join(', ')
224
+ // Claimed BEFORE the patch, so the watch above reads this write as its own however it is
225
+ // flushed, and the entries just rejected survive to be rendered.
226
+ lastWritten = usable.length ? usable : undefined
227
+ patch({ mediaTypes: lastWritten })
228
+ }
229
+
230
+ /** What the SELECTED integrations say they emit — the discoverable half of the free-text field. */
231
+ const declaredFormats = computed(() => {
232
+ const byId = new Map(agents.binaryGenerators.map((generator) => [generator.id, generator]))
233
+ const selected = (config.value?.generatorIds ?? []).flatMap((id) => byId.get(id) ?? [])
234
+ return [...new Set(selected.flatMap((generator) => generator.mediaTypes ?? []))]
235
+ })
146
236
  </script>
147
237
 
148
238
  <template>
@@ -216,6 +306,33 @@ function setModalities(modalities: BinaryModality[]) {
216
306
  />
217
307
  </div>
218
308
 
309
+ <!-- The FORMAT requirement, one notch finer than the content types above it and shown right
310
+ under them. Both tiers: like the rest of this picker it is not an override of a default —
311
+ a format nobody stated is a format the run does not check. -->
312
+ <div v-if="config?.storageServiceId" class="flex items-center gap-2">
313
+ <span class="text-[10px] text-slate-500">{{
314
+ t('pipeline.builder.binaryOutputMediaTypes')
315
+ }}</span>
316
+ <UInput
317
+ class="w-56"
318
+ :model-value="mediaTypeText"
319
+ size="xs"
320
+ :placeholder="t('pipeline.builder.binaryOutputMediaTypesPlaceholder')"
321
+ data-testid="binary-output-media-type-input"
322
+ @update:model-value="mediaTypeText = String($event)"
323
+ @change="setMediaTypes(mediaTypeText)"
324
+ />
325
+ </div>
326
+ <p
327
+ v-if="config?.storageServiceId && declaredFormats.length"
328
+ class="ms-1 text-[10px] text-slate-500"
329
+ data-testid="binary-output-declared-formats"
330
+ >
331
+ {{
332
+ t('pipeline.builder.binaryOutputDeclaredFormats', { formats: declaredFormats.join(', ') })
333
+ }}
334
+ </p>
335
+
219
336
  <!-- Every refusal this step would hit, named where it is fixable. Each is its own line
220
337
  with its own remedy: an unreachable catalog is not an empty one, a lost service is not
221
338
  an untagged one, and a lost CONTEXT service is not a lost storage target. -->
@@ -280,5 +397,42 @@ function setModalities(modalities: BinaryModality[]) {
280
397
  })
281
398
  }}
282
399
  </p>
400
+ <p
401
+ v-if="has('media_type_uncovered')"
402
+ class="text-[10px] text-amber-400"
403
+ data-testid="binary-output-media-type-uncovered"
404
+ >
405
+ {{
406
+ t('pipeline.builder.binaryOutputMediaTypeUncovered', {
407
+ formats: pick.uncoveredMediaTypes.join(', '),
408
+ })
409
+ }}
410
+ </p>
411
+ <!-- ADVISORY, and styled apart from every line above it: the step starts. The backend admits
412
+ a format requirement it could not judge, because a generator that declares no formats has
413
+ said only that its formats are unknown — and a surface that dressed that up as a refusal
414
+ would send someone editing a selection that is fine. -->
415
+ <p
416
+ v-if="has('media_type_unverifiable')"
417
+ class="text-[10px] text-slate-500"
418
+ data-testid="binary-output-media-type-unverifiable"
419
+ >
420
+ {{
421
+ t('pipeline.builder.binaryOutputMediaTypeUnverifiable', {
422
+ formats: pick.unverifiableMediaTypes.join(', '),
423
+ })
424
+ }}
425
+ </p>
426
+ <p
427
+ v-if="unusableMediaTypes.length"
428
+ class="text-[10px] text-amber-400"
429
+ data-testid="binary-output-media-type-unusable"
430
+ >
431
+ {{
432
+ t('pipeline.builder.binaryOutputMediaTypeUnusable', {
433
+ entries: unusableMediaTypes.join(', '),
434
+ })
435
+ }}
436
+ </p>
283
437
  </div>
284
438
  </template>
@@ -455,7 +455,10 @@ async function clone(p: Pipeline) {
455
455
  columns filling the full height. -->
456
456
  <div class="grid grid-cols-1 gap-4 lg:h-full lg:grid-cols-3">
457
457
  <!-- agent palette -->
458
- <div class="flex flex-col lg:min-h-0 lg:overflow-hidden">
458
+ <div
459
+ class="flex flex-col lg:min-h-0 lg:overflow-hidden"
460
+ data-testid="pipeline-builder-palette"
461
+ >
459
462
  <div class="mb-2 flex shrink-0 items-center justify-between gap-2">
460
463
  <h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
461
464
  {{ t('pipeline.builder.agentPalette') }}
@@ -476,7 +479,10 @@ async function clone(p: Pipeline) {
476
479
  </div>
477
480
 
478
481
  <!-- draft chain -->
479
- <div class="flex flex-col lg:min-h-0 lg:overflow-hidden">
482
+ <div
483
+ class="flex flex-col lg:min-h-0 lg:overflow-hidden"
484
+ data-testid="pipeline-builder-draft"
485
+ >
480
486
  <div class="mb-2 flex items-center justify-between gap-2">
481
487
  <h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
482
488
  {{ t('pipeline.builder.pipeline') }}
@@ -1294,6 +1300,7 @@ async function clone(p: Pipeline) {
1294
1300
  icon="i-lucide-save"
1295
1301
  size="sm"
1296
1302
  :disabled="pipelines.draft.length === 0 || stepsDisallowedByPurpose.length > 0"
1303
+ data-testid="pipeline-builder-save"
1297
1304
  @click="save"
1298
1305
  >
1299
1306
  {{ pipelines.editingId ? t('pipeline.builder.update') : t('pipeline.builder.save') }}
@@ -1,13 +1,15 @@
1
1
  <script setup lang="ts">
2
2
  // The tutorial launch prompt: asks once on first launch whether the user wants a guided tour,
3
- // listing the tours this board can actually run right now (first-party + consumer, resolved
4
- // against the same gates the nav uses), so it grows with the catalog rather than hard-coding
5
- // tours.
3
+ // listing the first-run tours this board can actually run right now (first-party + consumer,
4
+ // resolved against the same gates the nav uses), so it grows with the catalog rather than
5
+ // hard-coding tours.
6
6
  //
7
- // It is the OFFER, not the library: the full list — including the walkthroughs this board
8
- // can't run yet and what would unlock them is `TutorialCatalogue.vue`, one button away in
9
- // the footer and permanently reachable from the sidebar's Help section. That split is why
10
- // this stays a short, answerable question instead of growing into a browsing surface.
7
+ // It is the OFFER, not the library: the full list — the platform walkthroughs that are kept out
8
+ // of this question (`offeredAtLaunch: false`), plus the ones this board can't run yet and what
9
+ // would unlock them is `TutorialCatalogue.vue`, one button away in the footer and permanently
10
+ // reachable from the sidebar's Help section. That split is why this stays a short, answerable
11
+ // question instead of growing into a browsing surface, and why it reads `offered` rather than
12
+ // every startable tour.
11
13
  //
12
14
  // The decision semantics live in the store: starting a tour or "No thanks" is SAVED (the
13
15
  // prompt never auto-opens again), while closing without answering defers to next launch.
@@ -15,7 +17,7 @@ import { TUTORIAL_ACTION_KEYS } from '~/utils/tutorial'
15
17
 
16
18
  const { t } = useI18n()
17
19
  const tutorial = useTutorialStore()
18
- const { tours } = useTutorialTours()
20
+ const { offered } = useTutorialTours()
19
21
  // Start / Resume / Repeat is decided in one place for both surfaces — see `useTutorialLaunch`.
20
22
  const { actionFor, launch } = useTutorialLaunch()
21
23
 
@@ -41,7 +43,7 @@ const undecided = computed(() => tutorial.decision === null)
41
43
  <p class="text-sm text-slate-300">{{ t('tutorial.prompt.intro') }}</p>
42
44
  <ul class="space-y-2">
43
45
  <li
44
- v-for="tour in tours"
46
+ v-for="tour in offered"
45
47
  :key="tour.id"
46
48
  class="flex items-center gap-3 rounded-lg border border-slate-800 bg-slate-900/60 p-3"
47
49
  >
@@ -75,9 +77,15 @@ const undecided = computed(() => tutorial.decision === null)
75
77
  </UButton>
76
78
  </li>
77
79
  </ul>
78
- <!-- Every tour gated away (e.g. a viewer on a write-only catalog): say so rather
79
- than showing an unexplained empty list. -->
80
- <p v-if="tours.length === 0" class="text-sm text-slate-400">
80
+ <!-- Every first-run tour gated away: say so rather than showing an unexplained empty
81
+ list. The copy points at the catalogue rather than declaring the deployment empty,
82
+ because with the split this state no longer implies there is nothing to take: what
83
+ is missing is the FIRST-RUN arc, and the catalogue-only walkthroughs gate on a
84
+ permission that this user may well hold. (Unreachable with the built-in catalog
85
+ alone, where `board-basics` requires nothing at all — but a consumer's own slot
86
+ filter can produce it, and "no tours exist" would be the wrong thing to say then.)
87
+ The footer's browse button is the way on, so it stays. -->
88
+ <p v-if="offered.length === 0" class="text-sm text-slate-400">
81
89
  {{ t('tutorial.prompt.empty') }}
82
90
  </p>
83
91
  </div>
@@ -1,7 +1,7 @@
1
1
  import { computed } from 'vue'
2
2
  import { useReactiveSlots } from '@modular-vue/runtime'
3
3
  import { createSharedComposables } from '@modular-vue/vue'
4
- import { resolveTourCatalogue } from '~/utils/tutorial'
4
+ import { isLaunchOffer, resolveTourCatalogue } from '~/utils/tutorial'
5
5
  import type { TutorialCatalogueEntry, TutorialTour } from '~/utils/tutorial'
6
6
  import type { AppDeps } from '~/modular/registry'
7
7
  import type { AppSlots } from '~/modular/nav-contributions'
@@ -21,10 +21,13 @@ const { useOptional } = createSharedComposables<AppDeps>()
21
21
  /**
22
22
  * The tutorial catalog as this board sees it, resolved ONCE for every surface that reads it.
23
23
  *
24
- * Two views over one resolution, and the difference between them is the point:
24
+ * Three views over one resolution, and the differences between them are the point:
25
25
  *
26
- * - `tours` — what can be started right now. The launch prompt offers these, and the overlay
27
- * resolves a running tour from them, exactly as when this gating lived in `navSlotFilter`.
26
+ * - `tours` — what can be started right now. The overlay resolves a running tour from these,
27
+ * exactly as when this gating lived in `navSlotFilter`.
28
+ * - `offered` — the subset the launch prompt asks about: startable AND part of the first-run
29
+ * arc (see `TutorialTour.offeredAtLaunch`). The prompt is one answerable question, so it
30
+ * stays the delivery loop even as the catalog grows to cover the platform surfaces.
28
31
  * - `catalogue` — EVERY tour this deployment ships, each carrying why it is or isn't
29
32
  * available. The catalogue surface needs the unavailable ones: a list that quietly omits
30
33
  * four of six tours is indistinguishable from a deployment that ships two, and the user it
@@ -42,5 +45,6 @@ export function useTutorialTours() {
42
45
  const tours = computed<TutorialTour[]>(() =>
43
46
  catalogue.value.filter((entry) => entry.availability === 'ready').map((entry) => entry.tour),
44
47
  )
45
- return { tours, catalogue }
48
+ const offered = computed<TutorialTour[]>(() => tours.value.filter(isLaunchOffer))
49
+ return { tours, offered, catalogue }
46
50
  }
@@ -1,4 +1,4 @@
1
- # Frontend architecture state & data flow
1
+ # Frontend architecture: state & data flow
2
2
 
3
3
  How the SPA stays in sync with the backend. The app is a **thin client**: it holds
4
4
  no business logic, calls the Worker for every mutation, and hydrates its stores
@@ -14,11 +14,11 @@ REST (useApi) ─────────────▶ Worker ────
14
14
  stores (Pinia) ◀── patch ── useWorkspaceStream ◀── WebSocket push (events hub)
15
15
  ```
16
16
 
17
- - **Read path** the `workspace` store loads the full snapshot and fans it into
17
+ - **Read path**: the `workspace` store loads the full snapshot and fans it into
18
18
  `board`, `pipelines`, `execution`, `spend`, etc.
19
- - **Write path** components call `useApi` → Worker; the response (or a pushed
19
+ - **Write path**: components call `useApi` → Worker; the response (or a pushed
20
20
  event) patches the relevant store. No optimistic business logic.
21
- - **Live path** `useWorkspaceStream` opens one WebSocket to
21
+ - **Live path**: `useWorkspaceStream` opens one WebSocket to
22
22
  `GET /workspaces/:ws/events?token=…`, patches `execution` / `agentRuns` /
23
23
  `board` as events arrive, and refreshes on reconnect to reconcile anything
24
24
  missed.