@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.
- package/README.md +20 -5
- package/app/components/board/AddTaskModal.vue +31 -12
- package/app/components/board/CreateInitiativeModal.vue +33 -6
- package/app/components/context/ContextAttachmentFields.vue +63 -42
- package/app/components/documents/ContextDocumentPicker.logic.spec.ts +186 -0
- package/app/components/documents/ContextDocumentPicker.logic.ts +178 -0
- package/app/components/documents/ContextDocumentPicker.vue +269 -34
- package/app/components/pipeline/AgentKindIcon.vue +1 -1
- package/app/components/settings/ModelConfigurationPanel.vue +2 -2
- package/app/composables/api/documents.ts +10 -0
- package/app/composables/useContextLinking.spec.ts +95 -0
- package/app/composables/useContextLinking.ts +96 -15
- package/app/composables/useWorkspaceStream.ts +31 -77
- package/app/composables/workspaceStream/applyWorkspaceEvent.spec.ts +150 -0
- package/app/composables/workspaceStream/applyWorkspaceEvent.ts +173 -0
- package/app/stores/agents.ts +5 -3
- package/app/stores/documents.ts +12 -0
- package/app/types/documents.ts +2 -0
- package/app/utils/catalog.spec.ts +37 -2
- package/app/utils/catalog.ts +56 -36
- package/i18n/locales/de.json +15 -4
- package/i18n/locales/en.json +19 -2
- package/i18n/locales/es.json +15 -4
- package/i18n/locales/fr.json +15 -4
- package/i18n/locales/he.json +15 -4
- package/i18n/locales/it.json +15 -4
- package/i18n/locales/ja.json +15 -4
- package/i18n/locales/pl.json +15 -4
- package/i18n/locales/tr.json +15 -4
- package/i18n/locales/uk.json +15 -4
- 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
|
-
|
|
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);
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* while disconnected, so the server stays the source of truth
|
|
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
|
-
|
|
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
|
+
})
|