@cat-factory/app 0.234.2 → 0.236.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.
Files changed (31) hide show
  1. package/README.md +20 -5
  2. package/app/components/board/AddTaskModal.vue +31 -12
  3. package/app/components/board/CreateInitiativeModal.vue +33 -6
  4. package/app/components/context/ContextAttachmentFields.vue +63 -42
  5. package/app/components/documents/ContextDocumentPicker.logic.spec.ts +186 -0
  6. package/app/components/documents/ContextDocumentPicker.logic.ts +178 -0
  7. package/app/components/documents/ContextDocumentPicker.vue +269 -34
  8. package/app/components/pipeline/AgentKindIcon.vue +1 -1
  9. package/app/components/settings/ModelConfigurationPanel.vue +2 -2
  10. package/app/composables/api/documents.ts +10 -0
  11. package/app/composables/useContextLinking.spec.ts +95 -0
  12. package/app/composables/useContextLinking.ts +96 -15
  13. package/app/composables/useWorkspaceStream.ts +31 -77
  14. package/app/composables/workspaceStream/applyWorkspaceEvent.spec.ts +150 -0
  15. package/app/composables/workspaceStream/applyWorkspaceEvent.ts +173 -0
  16. package/app/stores/agents.ts +5 -3
  17. package/app/stores/documents.ts +12 -0
  18. package/app/types/documents.ts +2 -0
  19. package/app/utils/catalog.spec.ts +37 -2
  20. package/app/utils/catalog.ts +56 -36
  21. package/i18n/locales/de.json +15 -4
  22. package/i18n/locales/en.json +19 -2
  23. package/i18n/locales/es.json +15 -4
  24. package/i18n/locales/fr.json +15 -4
  25. package/i18n/locales/he.json +15 -4
  26. package/i18n/locales/it.json +15 -4
  27. package/i18n/locales/ja.json +15 -4
  28. package/i18n/locales/pl.json +15 -4
  29. package/i18n/locales/tr.json +15 -4
  30. package/i18n/locales/uk.json +15 -4
  31. package/package.json +2 -2
@@ -85,6 +85,101 @@ describe('contextKey', () => {
85
85
  })
86
86
  })
87
87
 
88
+ describe('resolvePending', () => {
89
+ // Fetching an attachment moved AHEAD of the create: an unreachable page is a correction the
90
+ // user can still make with the form open, where the same failure afterwards leaves a task
91
+ // carrying context it never got. These pin the two halves that makes load-bearing: what the
92
+ // host gets back, and that one bad attachment does not hide a second one.
93
+ function stub(
94
+ importDocument: (source: string, ref: string) => Promise<{ externalId: string }>,
95
+ importTask: (
96
+ source: string,
97
+ ref: string,
98
+ ) => Promise<{
99
+ externalId: string
100
+ description: string
101
+ }> = async () => ({ externalId: 'T-1', description: '' }),
102
+ ) {
103
+ vi.stubGlobal('useDocumentsStore', () => ({ importDocument }))
104
+ vi.stubGlobal('useTasksStore', () => ({ importTask }))
105
+ vi.stubGlobal('useWorkspaceStore', () => ({ workspaceId: 'ws_1' }))
106
+ vi.stubGlobal('useToast', () => ({ add: vi.fn() }))
107
+ vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
108
+ vi.stubGlobal('useCopyToClipboard', () => ({ copyAction: () => ({ label: 'copy' }) }))
109
+ }
110
+
111
+ afterEach(() => vi.unstubAllGlobals())
112
+
113
+ it('imports what needs it and hands back items the later link can use directly', async () => {
114
+ stub(async (_source, ref) => ({ externalId: `resolved:${ref}` }))
115
+ const already = item({ externalId: 'acme/repo:docs/done.md', needsImport: false })
116
+
117
+ const { resolved, failures } = await useContextLinking().resolvePending([item(), already])
118
+
119
+ expect(failures).toEqual([])
120
+ // The import's own canonical id is carried forward, not the pasted ref, and the flag flips so
121
+ // `linkPending` links rather than re-fetching.
122
+ expect(resolved.map((c) => [c.externalId, c.needsImport])).toEqual([
123
+ ['resolved:acme/repo:docs/x.md', false],
124
+ ['acme/repo:docs/done.md', false],
125
+ ])
126
+ })
127
+
128
+ it('reports EVERY failure and keeps the failed item staged', async () => {
129
+ stub(async (_source, ref) => {
130
+ throw new Error(`no access to ${ref}`)
131
+ })
132
+ const a = item({ externalId: 'acme/repo:a.md' })
133
+ const b = item({ externalId: 'acme/repo:b.md' })
134
+
135
+ const { resolved, failures } = await useContextLinking().resolvePending([a, b])
136
+
137
+ // Both, not just the first: fixing them one round-trip at a time is the failure mode.
138
+ expect(failures.map((f) => f.item.externalId)).toEqual(['acme/repo:a.md', 'acme/repo:b.md'])
139
+ expect(failures[0]!.message).toContain('no access to acme/repo:a.md')
140
+ // Still staged and still unresolved: the host aborts the create, so dropping them here would
141
+ // silently discard attachments the user asked for while they are fixing them.
142
+ expect(resolved.map((c) => c.needsImport)).toEqual([true, true])
143
+ // And MARKED, so the form the user is still looking at names which chip refused. The toast
144
+ // names them too, but the toast is gone by the time they go looking for the one to remove.
145
+ expect(resolved.map((c) => c.unreadable)).toEqual([
146
+ 'no access to acme/repo:a.md',
147
+ 'no access to acme/repo:b.md',
148
+ ])
149
+ })
150
+
151
+ it('clears a stale unreadable mark once the item does resolve', async () => {
152
+ // A prior failure is not a standing verdict: leaving the mark on a page that has just been
153
+ // fetched accuses a good attachment (and the retry is the whole point of keeping it staged).
154
+ stub(async (_source, ref) => ({ externalId: `resolved:${ref}` }))
155
+ const previouslyFailed = item({ unreadable: 'GitHub denied access (HTTP 403).' })
156
+
157
+ const { resolved } = await useContextLinking().resolvePending([previouslyFailed])
158
+
159
+ expect(resolved[0]!.unreadable).toBeUndefined()
160
+ expect(resolved[0]!.needsImport).toBe(false)
161
+ })
162
+
163
+ it("carries an imported issue's description forward, not just its id", async () => {
164
+ // The add-task form composes the saved description from these items on the very next statement
165
+ // (`linkedIssueBodies`), so an import that fetched the body and dropped it produced a task
166
+ // silently missing the issue text it was created from, with the bytes in hand at that moment.
167
+ stub(
168
+ async (_source, ref) => ({ externalId: ref }),
169
+ async () => ({ externalId: 'ENG-42', description: 'Steps to reproduce: …' }),
170
+ )
171
+ const issue = item({ kind: 'task', externalId: 'ENG-42', title: 'ENG-42 · Crash' })
172
+
173
+ const { resolved } = await useContextLinking().resolvePending([issue])
174
+
175
+ expect(resolved[0]).toMatchObject({
176
+ externalId: 'ENG-42',
177
+ needsImport: false,
178
+ description: 'Steps to reproduce: …',
179
+ })
180
+ })
181
+ })
182
+
88
183
  describe('presentLinkFailures', () => {
89
184
  // Stub the Nuxt auto-imports `useContextLinking` pulls in, so the toast-orchestration
90
185
  // side of the composable can be exercised without a full Nuxt runtime.
@@ -29,6 +29,17 @@ export interface PendingContext {
29
29
  description?: string
30
30
  /** True when the item must be imported before it can be linked. */
31
31
  needsImport: boolean
32
+ /**
33
+ * Why this item could not be FETCHED, when an attempt has already failed (the server's own
34
+ * message). Set by {@link useContextLinking.resolvePending} and by the add-task form's body
35
+ * pre-fetch; cleared the moment a later attempt succeeds.
36
+ *
37
+ * It exists because the fetch moved ahead of the create: a failure now costs the user the
38
+ * create, so the item that caused it has to be identifiable ON the form they are still looking
39
+ * at, not only in the toast that named it. A tracker issue has no pre-flight of its own, so this
40
+ * is the whole of its warning.
41
+ */
42
+ unreadable?: string
32
43
  }
33
44
 
34
45
  /**
@@ -114,6 +125,70 @@ export function useContextLinking() {
114
125
  const { t } = useI18n()
115
126
  const { copyAction } = useCopyToClipboard()
116
127
 
128
+ /**
129
+ * Import every pending item that still needs it, BEFORE the block exists.
130
+ *
131
+ * The fetch against the external source is the half of attaching that actually fails (a page
132
+ * that moved, a token without access, a source that is down), and it needs no block id. Running
133
+ * it after the block was created therefore bought nothing and cost the user their chance to fix
134
+ * it: the task existed, carrying context it had not got. Run here, a failure is a correction
135
+ * the host can ask for with the form still open and the reference still editable.
136
+ *
137
+ * Returns the items with what succeeded folded in (`needsImport: false`, so the later
138
+ * {@link linkPending} links them directly), alongside the failures. The batch is NOT aborted on
139
+ * the first failure: one unreachable page must not hide a second one, or the user fixes them
140
+ * one round-trip at a time.
141
+ *
142
+ * What "folded in" covers is the whole point of running this before the create, so it is more
143
+ * than the id: a tracker issue's own DESCRIPTION arrives with the import, and the add-task form
144
+ * composes the saved description from exactly these items on the next statement. Keeping only
145
+ * the id dropped a body the platform had in hand at the one moment it was needed.
146
+ */
147
+ async function resolvePending(
148
+ items: PendingContext[],
149
+ ): Promise<{ resolved: PendingContext[]; failures: LinkFailure[] }> {
150
+ const failures: LinkFailure[] = []
151
+ const resolved: PendingContext[] = []
152
+ for (const item of items) {
153
+ if (!item.needsImport) {
154
+ resolved.push(item)
155
+ continue
156
+ }
157
+ try {
158
+ resolved.push(await importPending(item))
159
+ } catch (e) {
160
+ const failure = describeLinkFailure(item, e)
161
+ failures.push(failure)
162
+ // Kept in the list, still unresolved: the host aborts on any failure, and dropping the
163
+ // item here would silently discard an attachment the user asked for while they fix it.
164
+ // Marked, so the form the user is still looking at names WHICH attachment refused.
165
+ resolved.push({ ...item, unreadable: failure.message })
166
+ }
167
+ }
168
+ return { resolved, failures }
169
+ }
170
+
171
+ /** Fetch one pending item, folding everything the import answers back onto it. */
172
+ async function importPending(item: PendingContext): Promise<PendingContext> {
173
+ // `unreadable` is dropped rather than preserved: a prior failure is not a standing verdict, and
174
+ // leaving the mark on a page that has just been fetched would accuse a good attachment.
175
+ const { unreadable: _cleared, ...rest } = item
176
+ if (item.kind === 'document') {
177
+ const doc = await documents.importDocument(item.source as DocumentSourceKind, item.externalId)
178
+ return { ...rest, externalId: doc.externalId, needsImport: false }
179
+ }
180
+ const task = await tasks.importTask(item.source as TaskSourceKind, item.externalId)
181
+ return {
182
+ ...rest,
183
+ externalId: task.externalId,
184
+ needsImport: false,
185
+ // The body reaches the created task through the host's description composition, which reads
186
+ // `description` off these items: an import that fetched it and did not carry it forward is a
187
+ // task silently missing the issue text it was created from.
188
+ ...(task.description.trim() ? { description: task.description } : {}),
189
+ }
190
+ }
191
+
117
192
  /**
118
193
  * Import (when needed) then link every pending item to `blockId`. Each failure
119
194
  * is captured with its actual cause rather than aborting the batch, so one bad
@@ -137,25 +212,31 @@ export function useContextLinking() {
137
212
  await tasks.linkToBlock(blockId, source, externalId)
138
213
  }
139
214
  } catch (e) {
140
- // Never swallow the cause: capture the server's own message + status/code/details
141
- // so the toast can name the specific reason and the copy affordance can carry the
142
- // full context (incl. the upstream GitHub status the backend puts on `details`).
143
- const envelope = apiErrorEnvelope(e)
144
- failures.push({
145
- item,
146
- message: e instanceof Error ? e.message : String(e),
147
- status: apiErrorStatus(e),
148
- code: envelope?.code,
149
- details:
150
- envelope?.details && typeof envelope.details === 'object'
151
- ? (envelope.details as Record<string, unknown>)
152
- : undefined,
153
- })
215
+ failures.push(describeLinkFailure(item, e))
154
216
  }
155
217
  }
156
218
  return failures
157
219
  }
158
220
 
221
+ /**
222
+ * Never swallow the cause: capture the server's own message + status/code/details so the toast
223
+ * can name the specific reason and the copy affordance can carry the full context (incl. the
224
+ * upstream GitHub status the backend puts on `details`).
225
+ */
226
+ function describeLinkFailure(item: PendingContext, e: unknown): LinkFailure {
227
+ const envelope = apiErrorEnvelope(e)
228
+ return {
229
+ item,
230
+ message: e instanceof Error ? e.message : String(e),
231
+ status: apiErrorStatus(e),
232
+ code: envelope?.code,
233
+ details:
234
+ envelope?.details && typeof envelope.details === 'object'
235
+ ? (envelope.details as Record<string, unknown>)
236
+ : undefined,
237
+ }
238
+ }
239
+
159
240
  /**
160
241
  * Surface link failures as a single actionable toast: the specific per-item
161
242
  * reasons as the body, and a "Copy details" action that puts the full diagnostic
@@ -209,5 +290,5 @@ export function useContextLinking() {
209
290
  })
210
291
  }
211
292
 
212
- return { linkPending, presentLinkFailures }
293
+ return { resolvePending, linkPending, presentLinkFailures }
213
294
  }
@@ -1,6 +1,10 @@
1
1
  import { ref, onScopeDispose } from 'vue'
2
2
  import type { WorkspaceEvent } from '~/types/domain'
3
3
  import { wsOriginFor } from '~/utils/apiOrigin'
4
+ import {
5
+ applyWorkspaceEvent,
6
+ type WorkspaceEventTargets,
7
+ } from '~/composables/workspaceStream/applyWorkspaceEvent'
4
8
 
5
9
  /**
6
10
  * Subscribes to the backend's per-workspace WebSocket event stream and keeps the
@@ -8,11 +12,11 @@ import { wsOriginFor } from '~/utils/apiOrigin'
8
12
  * once (e.g. on the board page) after the workspace is ready.
9
13
  *
10
14
  * `execution` events patch the run + its block directly; `bootstrap` events patch
11
- * a repo-bootstrap run + its service frame (live "bootstrapping…" progress); the
12
- * coarse `board` event (module materialised, run cancelled) triggers a debounced
13
- * full refresh. On every (re)connect we refresh once to reconcile anything missed
14
- * while disconnected, so the server stays the source of truth and a dropped socket
15
- * self-heals.
15
+ * a repo-bootstrap run + its service frame (live "bootstrapping…" progress); a
16
+ * `board` event patches the block it carries, or triggers a debounced full refresh when it
17
+ * carries none (a removal, a reparent, a service-frame change). On every (re)connect we refresh
18
+ * once to reconcile anything missed while disconnected, so the server stays the source of truth
19
+ * and a dropped socket self-heals. Routing lives in {@link applyWorkspaceEvent}.
16
20
  */
17
21
  export function useWorkspaceStream() {
18
22
  const workspace = useWorkspaceStore()
@@ -81,6 +85,27 @@ export function useWorkspaceStream() {
81
85
  boardDebounce = setTimeout(() => void refreshWithRetry(workspaceId), 300)
82
86
  }
83
87
 
88
+ // The stores this stream feeds, bound once. Routing lives in `applyWorkspaceEvent` so the
89
+ // targeted-vs-coarse decision on a `board` event is unit-testable without a socket.
90
+ const targets: WorkspaceEventTargets = {
91
+ upsertExecution: (instance) => execution.upsert(instance),
92
+ upsertBlock: (block) => board.upsert(block),
93
+ upsertBootstrap: (job) => agentRuns.upsertBootstrap(job),
94
+ upsertEnvConfigRepair: (job) => agentRuns.upsertEnvConfigRepair(job),
95
+ upsertEnvironmentTest: (run) => environmentTest.upsert(run),
96
+ patchInfraSetup: (area, status, detail) => workspace.patchInfraSetup(area, status, detail),
97
+ upsertNotification: (n) => notifications.upsert(n),
98
+ appendLlmCall: (call) => observability.appendCall(call),
99
+ upsertRequirements: (r) => requirements.upsert(r),
100
+ upsertConsensus: (s) => consensus.upsert(s),
101
+ upsertClarity: (r) => clarity.upsert(r),
102
+ upsertBrainstorm: (s) => brainstorm.upsert(s),
103
+ upsertKaizen: (g) => kaizen.upsert(g),
104
+ upsertInitiative: (i) => initiatives.upsert(i),
105
+ upsertDocInterview: (s) => docInterview.upsert(s),
106
+ refreshBoard: () => debouncedBoardRefresh(),
107
+ }
108
+
84
109
  function onMessage(raw: string) {
85
110
  let event: WorkspaceEvent
86
111
  try {
@@ -88,78 +113,7 @@ export function useWorkspaceStream() {
88
113
  } catch {
89
114
  return
90
115
  }
91
- if (event.type === 'execution') {
92
- // Full instance drives the step-level UI; agentRuns derives its coarse
93
- // failure/retry summary from the same store, so no extra call is needed.
94
- execution.upsert(event.instance)
95
- if (event.block) board.upsert(event.block)
96
- } else if (event.type === 'board') {
97
- debouncedBoardRefresh()
98
- } else if (event.type === 'bootstrap') {
99
- // Patch the run's live status/subtasks and its provisional/linked frame so
100
- // the "bootstrapping…" card updates in place (then flips to a ready service
101
- // or a failed badge) without a full refresh.
102
- agentRuns.upsertBootstrap(event.job)
103
- if (event.block) board.upsert(event.block)
104
- } else if (event.type === 'env-config-repair') {
105
- // A provider config-repair run advanced — patch its live status/subtasks/outcome so
106
- // the infrastructure-providers window's "repairing…" indicator updates in place
107
- // (then flips to ok / residual issues / a failure) without a refetch. No board block.
108
- agentRuns.upsertEnvConfigRepair(event.job)
109
- } else if (event.type === 'envTest') {
110
- // An ephemeral-environment self-test advanced a stage — patch the run so the service
111
- // inspector's "Test environment creation" control shows the live stage + final
112
- // outcome in place without a refetch. No board block.
113
- environmentTest.upsert(event.run)
114
- } else if (event.type === 'infraSetup') {
115
- // The reachability watcher found a configured infrastructure area dead (or answering again) —
116
- // patch that one area so the setup banner appears/clears immediately. A full refresh would
117
- // pay the whole snapshot aggregate for a one-field delta, and the projection the snapshot
118
- // recomputes already folds the same recorded state.
119
- workspace.patchInfraSetup(event.area, event.status, event.detail)
120
- } else if (event.type === 'notification') {
121
- // A PR needs a merge decision, a pipeline finished, or CI gave up — patch the
122
- // inbox + per-block badge in place (resolved ones drop out of the inbox).
123
- notifications.upsert(event.notification)
124
- } else if (event.type === 'llmCall') {
125
- // A container agent just made a model call — fold the compact summary into the
126
- // observability store so an open "Model activity" panel updates live (and keeps
127
- // updating even when the durable driver is evicted: the proxy emits these
128
- // independently of the run's poll loop).
129
- observability.appendCall(event.call)
130
- } else if (event.type === 'requirements') {
131
- // The async incorporate + re-review cycle changed a review's status — patch the cache
132
- // so an open review window / inspector reflects it live ("incorporating…" → the next
133
- // cycle / converged). The summons back, when needed, arrives as a `notification`.
134
- requirements.upsert(event.review)
135
- } else if (event.type === 'consensus') {
136
- // A consensus session advanced (a round landed, the synthesis completed, or it
137
- // failed) — patch the cache so an open Consensus Session window renders the
138
- // multi-model process live, round by round.
139
- consensus.upsert(event.session)
140
- } else if (event.type === 'clarity') {
141
- // The async incorporate + re-review cycle changed a clarity review's status — patch the
142
- // cache so an open review window / inspector reflects it live ("incorporating…" → the
143
- // next cycle / converged). The summons back, when needed, arrives as a `notification`.
144
- clarity.upsert(event.review)
145
- } else if (event.type === 'brainstorm') {
146
- // The async incorporate + re-run cycle changed a brainstorm session's status — patch the
147
- // cache so an open brainstorm window / inspector reflects it live.
148
- brainstorm.upsert(event.session)
149
- } else if (event.type === 'kaizen') {
150
- // A post-run Kaizen grading was scheduled, started or completed — fold it into the
151
- // run cache (so an open run window shows scheduled→running→complete live) and the
152
- // Kaizen screen history. Never surfaced on the board.
153
- kaizen.upsert(event.grading)
154
- } else if (event.type === 'initiative') {
155
- // An initiative changed (created, plan ingested, an item settled) — patch the cache
156
- // so an open tracker window / the board card reflects the transition live.
157
- initiatives.upsert(event.initiative)
158
- } else if (event.type === 'docInterview') {
159
- // The interactive document interview advanced (a fresh batch of questions, an answer, or
160
- // convergence) — patch the cache so an open interview window reflects it live.
161
- docInterview.upsert(event.session)
162
- }
116
+ applyWorkspaceEvent(event, targets)
163
117
  }
164
118
 
165
119
  async function connect() {
@@ -0,0 +1,150 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import type { Block, WorkspaceEvent } from '~/types/domain'
3
+ import {
4
+ applyWorkspaceEvent,
5
+ type WorkspaceEventTargets,
6
+ } from '~/composables/workspaceStream/applyWorkspaceEvent'
7
+
8
+ function targets(): WorkspaceEventTargets & { calls: string[] } {
9
+ const calls: string[] = []
10
+ const record =
11
+ <T extends unknown[]>(name: string) =>
12
+ (...args: T) => {
13
+ void args
14
+ calls.push(name)
15
+ }
16
+ return {
17
+ calls,
18
+ upsertExecution: record('upsertExecution'),
19
+ upsertBlock: record('upsertBlock'),
20
+ upsertBootstrap: record('upsertBootstrap'),
21
+ upsertEnvConfigRepair: record('upsertEnvConfigRepair'),
22
+ upsertEnvironmentTest: record('upsertEnvironmentTest'),
23
+ patchInfraSetup: record('patchInfraSetup'),
24
+ upsertNotification: record('upsertNotification'),
25
+ appendLlmCall: record('appendLlmCall'),
26
+ upsertRequirements: record('upsertRequirements'),
27
+ upsertConsensus: record('upsertConsensus'),
28
+ upsertClarity: record('upsertClarity'),
29
+ upsertBrainstorm: record('upsertBrainstorm'),
30
+ upsertKaizen: record('upsertKaizen'),
31
+ upsertInitiative: record('upsertInitiative'),
32
+ upsertDocInterview: record('upsertDocInterview'),
33
+ refreshBoard: record('refreshBoard'),
34
+ }
35
+ }
36
+
37
+ const task = {
38
+ id: 'blk_task',
39
+ title: 'Ship it',
40
+ type: 'service',
41
+ description: '',
42
+ position: { x: 0, y: 0 },
43
+ status: 'planned',
44
+ progress: 0,
45
+ dependsOn: [],
46
+ executionId: null,
47
+ level: 'task',
48
+ parentId: 'blk_frame',
49
+ } as unknown as Block
50
+
51
+ describe('applyWorkspaceEvent: the board branch', () => {
52
+ it('patches the carried block instead of refreshing the whole board', () => {
53
+ const to = targets()
54
+ const event: WorkspaceEvent = { type: 'board', reason: 'block-added', block: task, at: 1 }
55
+
56
+ applyWorkspaceEvent(event, to)
57
+
58
+ // The whole point of the change: a spawned task costs one upsert, not a snapshot fetch that
59
+ // REPLACES every store's list.
60
+ expect(to.calls).toEqual(['upsertBlock'])
61
+ })
62
+
63
+ it('falls back to a full refresh when the change carries no block', () => {
64
+ const to = targets()
65
+ // Everything the publisher withholds a payload for lands here: a removal that cascades, a
66
+ // reparent that moves a subtree, and a service FRAME whose geometry is per-board. None has a
67
+ // single block that states the new shape, so the client must re-read its own projection.
68
+ const event: WorkspaceEvent = { type: 'board', reason: 'block-removed', at: 1 }
69
+
70
+ applyWorkspaceEvent(event, to)
71
+
72
+ expect(to.calls).toEqual(['refreshBoard'])
73
+ })
74
+
75
+ it('falls back to a full refresh when the payload is explicitly null', () => {
76
+ const to = targets()
77
+ // The frame shape as the publishers actually emit it: `deliverableBoardBlock` answers `null`
78
+ // rather than omitting the key, and an absent payload and a null one must route identically.
79
+ const event: WorkspaceEvent = { type: 'board', reason: 'block-archived', block: null, at: 1 }
80
+
81
+ applyWorkspaceEvent(event, to)
82
+
83
+ expect(to.calls).toEqual(['refreshBoard'])
84
+ })
85
+
86
+ it('routes a board event through the SAME upsert an execution event uses', () => {
87
+ // Coherence: the monotonic live-upsert stamp that stops a stale refresh clobbering newer
88
+ // state lives in `board.upsert`. A targeted board event that patched `blocks` any other way
89
+ // would silently escape that guard.
90
+ const upsertBlock = vi.fn()
91
+ const to = { ...targets(), upsertBlock }
92
+
93
+ applyWorkspaceEvent({ type: 'board', reason: 'block-updated', block: task, at: 1 }, to)
94
+ applyWorkspaceEvent(
95
+ {
96
+ type: 'execution',
97
+ instance: { id: 'exec_1', blockId: task.id } as never,
98
+ block: task,
99
+ at: 2,
100
+ },
101
+ to,
102
+ )
103
+
104
+ expect(upsertBlock).toHaveBeenCalledTimes(2)
105
+ expect(upsertBlock).toHaveBeenNthCalledWith(1, task)
106
+ expect(upsertBlock).toHaveBeenNthCalledWith(2, task)
107
+ })
108
+ })
109
+
110
+ describe('applyWorkspaceEvent: the other branches', () => {
111
+ it('keeps every non-board event on its own targeted store call', () => {
112
+ // Each type below is delivered as a patch rather than a board refresh. This table cannot see
113
+ // a NEW event type (an absent member is just absent from a hand-written list). That job
114
+ // belongs to the `never` guard on the switch's `default`, which fails the BUILD instead.
115
+ const cases: [WorkspaceEvent, string][] = [
116
+ [{ type: 'bootstrap', job: {} as never, block: null, at: 1 }, 'upsertBootstrap'],
117
+ [{ type: 'env-config-repair', job: {} as never, at: 1 }, 'upsertEnvConfigRepair'],
118
+ [{ type: 'envTest', run: {} as never, at: 1 }, 'upsertEnvironmentTest'],
119
+ [
120
+ { type: 'infraSetup', area: 'runnerPool' as never, status: 'ok' as never, at: 1 },
121
+ 'patchInfraSetup',
122
+ ],
123
+ [{ type: 'notification', notification: {} as never, at: 1 }, 'upsertNotification'],
124
+ [{ type: 'llmCall', call: {} as never, at: 1 }, 'appendLlmCall'],
125
+ [{ type: 'requirements', review: {} as never, at: 1 }, 'upsertRequirements'],
126
+ [{ type: 'consensus', session: {} as never, at: 1 }, 'upsertConsensus'],
127
+ [{ type: 'clarity', review: {} as never, at: 1 }, 'upsertClarity'],
128
+ [{ type: 'brainstorm', session: {} as never, at: 1 }, 'upsertBrainstorm'],
129
+ [{ type: 'kaizen', grading: {} as never, at: 1 }, 'upsertKaizen'],
130
+ [{ type: 'initiative', initiative: {} as never, at: 1 }, 'upsertInitiative'],
131
+ [{ type: 'docInterview', session: {} as never, at: 1 }, 'upsertDocInterview'],
132
+ ]
133
+
134
+ for (const [event, expected] of cases) {
135
+ const to = targets()
136
+ applyWorkspaceEvent(event, to)
137
+ expect(to.calls, `${event.type} should route to ${expected}`).toEqual([expected])
138
+ }
139
+ })
140
+
141
+ it('drops an event type it does not know rather than tearing down the session', () => {
142
+ // A backend one release ahead pushes types this build has never heard of. The socket carries
143
+ // every other event for the whole workspace, so the unknown one is dropped, not thrown on.
144
+ const to = targets()
145
+
146
+ applyWorkspaceEvent({ type: 'a-later-release', at: 1 } as unknown as WorkspaceEvent, to)
147
+
148
+ expect(to.calls).toEqual([])
149
+ })
150
+ })