@cat-factory/app 0.71.2 → 0.73.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.
- package/app/components/board/nodes/TaskCard.vue +12 -2
- package/app/components/bootstrap/BootstrapModal.vue +23 -7
- package/app/components/brainstorm/BrainstormWindow.vue +2 -0
- package/app/components/clarity/ClarityReviewWindow.vue +2 -0
- package/app/components/consensus/ConsensusSessionWindow.vue +2 -0
- package/app/components/focus/BlockFocusView.vue +2 -0
- package/app/components/followUp/FollowUpWindow.vue +2 -0
- package/app/components/gates/GateResultView.vue +2 -0
- package/app/components/humanTest/HumanTestWindow.vue +2 -0
- package/app/components/layout/ConnectionStatusBanner.vue +81 -0
- package/app/components/layout/NotificationsInbox.vue +15 -0
- package/app/components/panels/GenericStructuredResultView.vue +2 -0
- package/app/components/panels/InspectorPanel.vue +30 -1
- package/app/components/panels/MergerResultView.vue +267 -0
- package/app/components/panels/StepResultViewHost.vue +4 -0
- package/app/components/panels/inspector/FrontendConfig.vue +111 -1
- package/app/components/panels/inspector/TaskExecution.vue +31 -2
- package/app/components/pipeline/PipelineBuilder.vue +110 -7
- package/app/components/requirements/RequirementsReviewWindow.vue +2 -0
- package/app/components/spec/ServiceSpecWindow.vue +2 -0
- package/app/components/testing/TestReportWindow.vue +91 -0
- package/app/composables/api/preview.ts +20 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useKeyboardShortcuts.ts +10 -2
- package/app/pages/index.vue +6 -4
- package/app/stores/board.spec.ts +58 -1
- package/app/stores/board.ts +59 -15
- package/app/stores/brainstorm.spec.ts +35 -0
- package/app/stores/brainstorm.ts +11 -2
- package/app/stores/clarity.spec.ts +33 -0
- package/app/stores/clarity.ts +11 -2
- package/app/stores/execution.spec.ts +71 -13
- package/app/stores/execution.ts +44 -6
- package/app/stores/pipelines.ts +53 -0
- package/app/stores/preview.ts +94 -0
- package/app/stores/recurringPipelines.ts +5 -1
- package/app/stores/requirements.spec.ts +21 -0
- package/app/stores/requirements.ts +11 -2
- package/app/stores/workspace.ts +1 -1
- package/app/types/domain.ts +2 -0
- package/app/utils/catalog.ts +12 -0
- package/i18n/locales/en.json +94 -5
- package/i18n/locales/es.json +94 -5
- package/i18n/locales/fr.json +94 -5
- package/i18n/locales/he.json +94 -5
- package/i18n/locales/ja.json +94 -5
- package/i18n/locales/pl.json +94 -5
- package/i18n/locales/tr.json +94 -5
- package/i18n/locales/uk.json +94 -5
- package/package.json +2 -2
package/app/stores/board.ts
CHANGED
|
@@ -29,6 +29,11 @@ interface RemovalSnapshot {
|
|
|
29
29
|
export const useBoardStore = defineStore('board', () => {
|
|
30
30
|
const api = useApi()
|
|
31
31
|
const toast = useToast()
|
|
32
|
+
// Stores run outside a component `setup`, so resolve translations through the Nuxt app's
|
|
33
|
+
// global i18n instance (the same handle `plugins/locale.client.ts` uses) rather than
|
|
34
|
+
// `useI18n()`, which requires an active component instance.
|
|
35
|
+
const nuxtApp = useNuxtApp()
|
|
36
|
+
const tr = (key: string): string => (nuxtApp.$i18n as { t: (k: string) => string }).t(key)
|
|
32
37
|
const blocks = ref<Block[]>([])
|
|
33
38
|
|
|
34
39
|
// Pure derivations (hierarchy, status/progress, sizing) live in the composable.
|
|
@@ -162,7 +167,7 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
162
167
|
} catch (e) {
|
|
163
168
|
t.epicId = prev
|
|
164
169
|
toast.add({
|
|
165
|
-
title: '
|
|
170
|
+
title: tr('board.toast.epicFailed'),
|
|
166
171
|
description: e instanceof Error ? e.message : String(e),
|
|
167
172
|
icon: 'i-lucide-triangle-alert',
|
|
168
173
|
color: 'error',
|
|
@@ -216,7 +221,7 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
216
221
|
b.parentId = prevParentId
|
|
217
222
|
b.position = prevPosition
|
|
218
223
|
toast.add({
|
|
219
|
-
title: '
|
|
224
|
+
title: tr('board.toast.moveFailed'),
|
|
220
225
|
description: e instanceof Error ? e.message : String(e),
|
|
221
226
|
icon: 'i-lucide-triangle-alert',
|
|
222
227
|
color: 'error',
|
|
@@ -289,7 +294,7 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
289
294
|
} catch (e) {
|
|
290
295
|
reattach(snap)
|
|
291
296
|
toast.add({
|
|
292
|
-
title: '
|
|
297
|
+
title: tr('board.toast.deleteFailed'),
|
|
293
298
|
description: e instanceof Error ? e.message : String(e),
|
|
294
299
|
icon: 'i-lucide-triangle-alert',
|
|
295
300
|
color: 'error',
|
|
@@ -313,26 +318,65 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
313
318
|
async function moveBlock(id: string, position: { x: number; y: number }) {
|
|
314
319
|
const b = getBlock(id)
|
|
315
320
|
if (!b) return
|
|
321
|
+
const prevPosition = b.position
|
|
316
322
|
b.position = position // optimistic: keep the drag feeling instant
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
323
|
+
try {
|
|
324
|
+
// A mounted service frame's position is a PER-WORKSPACE layout override on the mount, not
|
|
325
|
+
// on the (shared) block — so route a frame drag there. Other moves write the block.
|
|
326
|
+
const services = useServicesStore()
|
|
327
|
+
const mount = services.serviceByFrameBlock[id]
|
|
328
|
+
? services.byServiceId[services.serviceByFrameBlock[id]!.id]
|
|
329
|
+
: undefined
|
|
330
|
+
if (mount) {
|
|
331
|
+
await services.updateLayout(mount.serviceId, position)
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
upsert(await api.moveBlock(useWorkspaceStore().requireId(), id, { position }))
|
|
335
|
+
} catch (e) {
|
|
336
|
+
// Restore the pre-drag position — a rejected move must not leave the block at a
|
|
337
|
+
// spot the server never stored (a lie that survives until the next re-hydrate).
|
|
338
|
+
b.position = prevPosition
|
|
339
|
+
toast.add({
|
|
340
|
+
title: 'Could not move',
|
|
341
|
+
description: e instanceof Error ? e.message : String(e),
|
|
342
|
+
icon: 'i-lucide-triangle-alert',
|
|
343
|
+
color: 'error',
|
|
344
|
+
})
|
|
326
345
|
}
|
|
327
|
-
upsert(await api.moveBlock(useWorkspaceStore().requireId(), id, { position }))
|
|
328
346
|
}
|
|
329
347
|
|
|
330
348
|
/** Patch the user-editable fields of a block (title, features, threshold…). */
|
|
331
349
|
async function updateBlock(id: string, patch: UpdateBlockInput) {
|
|
332
350
|
const b = getBlock(id)
|
|
333
351
|
if (!b) return
|
|
352
|
+
// Snapshot ONLY the fields this patch touches so a rejected write restores them exactly
|
|
353
|
+
// (a patch may set several at once) rather than leaving a stale optimistic value stuck on
|
|
354
|
+
// screen with no feedback — the same rollback contract the other mutations here follow.
|
|
355
|
+
const prev: Record<string, unknown> = {}
|
|
356
|
+
const patchRecord = patch as Record<string, unknown>
|
|
357
|
+
const record = b as unknown as Record<string, unknown>
|
|
358
|
+
for (const key of Object.keys(patch)) prev[key] = record[key]
|
|
334
359
|
Object.assign(b, patch) // optimistic
|
|
335
|
-
|
|
360
|
+
try {
|
|
361
|
+
upsert(await api.updateBlock(useWorkspaceStore().requireId(), id, patch))
|
|
362
|
+
} catch (e) {
|
|
363
|
+
// Re-resolve the block: a live event may have replaced its object reference (`upsert`
|
|
364
|
+
// swaps in a fresh one) while the write was in flight, so `b` can be stale. Only revert
|
|
365
|
+
// fields that still hold OUR optimistic value, so a newer server value that landed
|
|
366
|
+
// mid-flight isn't clobbered by the rollback.
|
|
367
|
+
const cur = getBlock(id) as unknown as Record<string, unknown> | undefined
|
|
368
|
+
if (cur) {
|
|
369
|
+
for (const key of Object.keys(patch)) {
|
|
370
|
+
if (cur[key] === patchRecord[key]) cur[key] = prev[key]
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
toast.add({
|
|
374
|
+
title: tr('board.toast.updateFailed'),
|
|
375
|
+
description: e instanceof Error ? e.message : String(e),
|
|
376
|
+
icon: 'i-lucide-triangle-alert',
|
|
377
|
+
color: 'error',
|
|
378
|
+
})
|
|
379
|
+
}
|
|
336
380
|
}
|
|
337
381
|
|
|
338
382
|
/**
|
|
@@ -346,7 +390,7 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
346
390
|
upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
|
|
347
391
|
} catch (e) {
|
|
348
392
|
toast.add({
|
|
349
|
-
title: '
|
|
393
|
+
title: tr('board.toast.linkFailed'),
|
|
350
394
|
description: e instanceof Error ? e.message : String(e),
|
|
351
395
|
icon: 'i-lucide-triangle-alert',
|
|
352
396
|
color: 'error',
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { BrainstormSession } from '~/types/brainstorm'
|
|
3
|
+
import { useBrainstormStore } from '~/stores/brainstorm'
|
|
4
|
+
|
|
5
|
+
/** Minimal session factory — only the fields the upsert guard touches. */
|
|
6
|
+
function session(over: Partial<BrainstormSession> = {}): BrainstormSession {
|
|
7
|
+
return {
|
|
8
|
+
id: 'bs1',
|
|
9
|
+
blockId: 'b1',
|
|
10
|
+
stage: 'requirements',
|
|
11
|
+
status: 'ready',
|
|
12
|
+
options: [],
|
|
13
|
+
updatedAt: 1000,
|
|
14
|
+
...over,
|
|
15
|
+
} as BrainstormSession
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('brainstorm store live-event upsert guard', () => {
|
|
19
|
+
it('an out-of-order stream event cannot revert a newer cached session', () => {
|
|
20
|
+
const store = useBrainstormStore()
|
|
21
|
+
store.upsert(session({ updatedAt: 2000, status: 'merged' }))
|
|
22
|
+
store.upsert(session({ updatedAt: 1000, status: 'ready' })) // stale event → ignored
|
|
23
|
+
expect(store.sessionFor('b1', 'requirements')?.status).toBe('merged')
|
|
24
|
+
store.upsert(session({ updatedAt: 3000, status: 'incorporated' }))
|
|
25
|
+
expect(store.sessionFor('b1', 'requirements')?.status).toBe('incorporated')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('sessions are keyed per block+stage — one stage cannot clobber another', () => {
|
|
29
|
+
const store = useBrainstormStore()
|
|
30
|
+
store.upsert(session({ updatedAt: 2000 }))
|
|
31
|
+
store.upsert(session({ id: 'bs2', stage: 'architecture', updatedAt: 1000 }))
|
|
32
|
+
expect(store.sessionFor('b1', 'requirements')?.id).toBe('bs1')
|
|
33
|
+
expect(store.sessionFor('b1', 'architecture')?.id).toBe('bs2')
|
|
34
|
+
})
|
|
35
|
+
})
|
package/app/stores/brainstorm.ts
CHANGED
|
@@ -84,6 +84,16 @@ export const useBrainstormStore = defineStore('brainstorm', () => {
|
|
|
84
84
|
sessions.value = { ...sessions.value, [key(session.blockId, session.stage)]: session }
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/** Patch the cache from a live `brainstorm` stream event (newest wins per block+stage). */
|
|
88
|
+
function upsert(session: BrainstormSession) {
|
|
89
|
+
const existing = sessions.value[key(session.blockId, session.stage)]
|
|
90
|
+
// Keep the freshest by updatedAt (the consensus-store guard): `store()` also runs on
|
|
91
|
+
// API responses, so a slightly-older event racing a just-submitted answer over the
|
|
92
|
+
// separate WS transport must not revert the session the response already delivered.
|
|
93
|
+
if (existing && existing.id === session.id && existing.updatedAt > session.updatedAt) return
|
|
94
|
+
store(session)
|
|
95
|
+
}
|
|
96
|
+
|
|
87
97
|
/** Drop all cached sessions + in-flight state (called on workspace switch). */
|
|
88
98
|
function reset() {
|
|
89
99
|
available.value = null
|
|
@@ -215,7 +225,6 @@ export const useBrainstormStore = defineStore('brainstorm', () => {
|
|
|
215
225
|
proceed,
|
|
216
226
|
resolveExceeded,
|
|
217
227
|
reset,
|
|
218
|
-
|
|
219
|
-
upsert: store,
|
|
228
|
+
upsert,
|
|
220
229
|
}
|
|
221
230
|
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { ClarityReview } from '~/types/clarity'
|
|
3
|
+
import { useClarityStore } from '~/stores/clarity'
|
|
4
|
+
|
|
5
|
+
/** Minimal review factory — only the fields the upsert guard touches. */
|
|
6
|
+
function review(over: Partial<ClarityReview> = {}): ClarityReview {
|
|
7
|
+
return {
|
|
8
|
+
id: 'cr1',
|
|
9
|
+
blockId: 'b1',
|
|
10
|
+
status: 'ready',
|
|
11
|
+
items: [],
|
|
12
|
+
updatedAt: 1000,
|
|
13
|
+
...over,
|
|
14
|
+
} as ClarityReview
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('clarity store live-event upsert guard', () => {
|
|
18
|
+
it('an out-of-order stream event cannot revert a newer cached review', () => {
|
|
19
|
+
const store = useClarityStore()
|
|
20
|
+
store.upsert(review({ updatedAt: 2000, status: 'merged' }))
|
|
21
|
+
store.upsert(review({ updatedAt: 1000, status: 'ready' })) // stale event → ignored
|
|
22
|
+
expect(store.reviewFor('b1')?.status).toBe('merged')
|
|
23
|
+
store.upsert(review({ updatedAt: 3000, status: 'incorporated' }))
|
|
24
|
+
expect(store.reviewFor('b1')?.status).toBe('incorporated')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('a NEW review (different id) for the block replaces regardless of updatedAt', () => {
|
|
28
|
+
const store = useClarityStore()
|
|
29
|
+
store.upsert(review({ updatedAt: 2000 }))
|
|
30
|
+
store.upsert(review({ id: 'cr2', updatedAt: 1000 }))
|
|
31
|
+
expect(store.reviewFor('b1')?.id).toBe('cr2')
|
|
32
|
+
})
|
|
33
|
+
})
|
package/app/stores/clarity.ts
CHANGED
|
@@ -87,6 +87,16 @@ export const useClarityStore = defineStore('clarity', () => {
|
|
|
87
87
|
reviews.value = { ...reviews.value, [review.blockId]: review }
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** Patch the cache from a live `clarity` stream event (newest wins per block). */
|
|
91
|
+
function upsert(review: ClarityReview) {
|
|
92
|
+
const existing = reviews.value[review.blockId]
|
|
93
|
+
// Keep the freshest by updatedAt (the consensus-store guard): `store()` also runs on
|
|
94
|
+
// API responses, so a slightly-older event racing a just-submitted answer over the
|
|
95
|
+
// separate WS transport must not revert the review the response already delivered.
|
|
96
|
+
if (existing && existing.id === review.id && existing.updatedAt > review.updatedAt) return
|
|
97
|
+
store(review)
|
|
98
|
+
}
|
|
99
|
+
|
|
90
100
|
/** Drop all cached reviews + in-flight state (called on workspace switch). */
|
|
91
101
|
function reset() {
|
|
92
102
|
available.value = null
|
|
@@ -205,7 +215,6 @@ export const useClarityStore = defineStore('clarity', () => {
|
|
|
205
215
|
proceed,
|
|
206
216
|
resolveExceeded,
|
|
207
217
|
reset,
|
|
208
|
-
|
|
209
|
-
upsert: store,
|
|
218
|
+
upsert,
|
|
210
219
|
}
|
|
211
220
|
})
|
|
@@ -18,26 +18,84 @@ describe('execution store gate grouping', () => {
|
|
|
18
18
|
})
|
|
19
19
|
|
|
20
20
|
it('decisionsByBlock groups open (unchosen) decisions by block', () => {
|
|
21
|
-
store.hydrate(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
store.hydrate(
|
|
22
|
+
[
|
|
23
|
+
instance('e1', 'b1', [
|
|
24
|
+
{ agentKind: 'coder', decision: { id: 'd1', chosen: null } },
|
|
25
|
+
{ agentKind: 'coder', decision: { id: 'd2', chosen: 'yes' } }, // chosen ⇒ excluded
|
|
26
|
+
]),
|
|
27
|
+
instance('e2', 'b2', [{ agentKind: 'architect', decision: { id: 'd3', chosen: null } }]),
|
|
28
|
+
],
|
|
29
|
+
'ws1',
|
|
30
|
+
)
|
|
28
31
|
expect(store.decisionsByBlock.get('b1')?.map((d) => d.decision.id)).toEqual(['d1'])
|
|
29
32
|
expect(store.decisionsByBlock.get('b2')?.map((d) => d.decision.id)).toEqual(['d3'])
|
|
30
33
|
expect(store.decisionsByBlock.has('missing')).toBe(false)
|
|
31
34
|
})
|
|
32
35
|
|
|
33
36
|
it('approvalsByBlock groups pending approvals by block', () => {
|
|
34
|
-
store.hydrate(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
store.hydrate(
|
|
38
|
+
[
|
|
39
|
+
instance('e1', 'b1', [
|
|
40
|
+
{ agentKind: 'merger', approval: { id: 'a1', status: 'pending' } },
|
|
41
|
+
{ agentKind: 'merger', approval: { id: 'a2', status: 'approved' } }, // not pending ⇒ excluded
|
|
42
|
+
]),
|
|
43
|
+
],
|
|
44
|
+
'ws1',
|
|
45
|
+
)
|
|
40
46
|
expect(store.approvalsByBlock.get('b1')?.map((a) => a.approval.id)).toEqual(['a1'])
|
|
41
47
|
expect(store.approvalsByBlock.get('b2')).toBeUndefined()
|
|
42
48
|
})
|
|
43
49
|
})
|
|
50
|
+
|
|
51
|
+
/** A run fixture carrying the fields the reconcile guards read (`id`, `rev`, `status`). */
|
|
52
|
+
function run(id: string, rev: number, status: string): ExecutionInstance {
|
|
53
|
+
return { id, blockId: `blk_${id}`, steps: [], status, rev } as unknown as ExecutionInstance
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('execution store snapshot/event reconcile', () => {
|
|
57
|
+
let store: ReturnType<typeof useExecutionStore>
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
store = useExecutionStore()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('a lagging snapshot cannot regress a run a live event already advanced (REGRESS)', () => {
|
|
63
|
+
store.hydrate([run('e1', 3, 'running')], 'ws1')
|
|
64
|
+
// Live event: the run reached a terminal state (rev 4). It emits nothing further.
|
|
65
|
+
store.upsert(run('e1', 4, 'done'))
|
|
66
|
+
// A snapshot read BEFORE the event resolves after it — same run at the older rev.
|
|
67
|
+
store.hydrate([run('e1', 3, 'running')], 'ws1')
|
|
68
|
+
expect(store.getInstance('e1')?.status).toBe('done')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('keeps a live-added run a lagging snapshot never saw (DROP)', () => {
|
|
72
|
+
store.hydrate([run('e1', 1, 'running')], 'ws1')
|
|
73
|
+
store.upsert(run('e2', 1, 'running'))
|
|
74
|
+
store.hydrate([run('e1', 2, 'running')], 'ws1') // stale read: predates e2
|
|
75
|
+
expect(store.getInstance('e2')).toBeTruthy()
|
|
76
|
+
expect(store.getInstance('e1')?.rev).toBe(2)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('a workspace switch replaces the cache outright (no cross-board leak)', () => {
|
|
80
|
+
store.hydrate([run('e1', 1, 'running')], 'ws1')
|
|
81
|
+
store.upsert(run('e2', 1, 'running'))
|
|
82
|
+
store.hydrate([run('e3', 1, 'running')], 'ws2')
|
|
83
|
+
expect(store.getInstance('e1')).toBeUndefined()
|
|
84
|
+
expect(store.getInstance('e2')).toBeUndefined()
|
|
85
|
+
expect(store.getInstance('e3')).toBeTruthy()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('an out-of-order live event cannot regress a newer cached run; same-rev replaces', () => {
|
|
89
|
+
store.upsert(run('e1', 5, 'done'))
|
|
90
|
+
store.upsert(run('e1', 4, 'running')) // stale event → ignored
|
|
91
|
+
expect(store.getInstance('e1')?.status).toBe('done')
|
|
92
|
+
store.upsert(run('e1', 5, 'failed')) // equal rev → latest event wins
|
|
93
|
+
expect(store.getInstance('e1')?.status).toBe('failed')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('treats a missing rev as 0 (legacy rows still hydrate)', () => {
|
|
97
|
+
store.hydrate([{ id: 'e1', blockId: 'b1', steps: [], status: 'running' } as never], 'ws1')
|
|
98
|
+
store.upsert(run('e1', 1, 'done'))
|
|
99
|
+
expect(store.getInstance('e1')?.status).toBe('done')
|
|
100
|
+
})
|
|
101
|
+
})
|
package/app/stores/execution.ts
CHANGED
|
@@ -25,17 +25,55 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
25
25
|
// gets identical handling, including the fire-and-forget ones that never caught.
|
|
26
26
|
const runErrors = usePipelineErrorToast()
|
|
27
27
|
const instances = ref<ExecutionInstance[]>([])
|
|
28
|
+
// The workspace whose snapshot last hydrated the cache. Scopes the DROP-preservation
|
|
29
|
+
// below: a board SWITCH replaces the cache outright instead of leaking the previous
|
|
30
|
+
// board's runs (an ExecutionInstance carries no workspaceId of its own).
|
|
31
|
+
let hydratedWorkspaceId: string | null = null
|
|
28
32
|
|
|
29
|
-
/**
|
|
30
|
-
function
|
|
31
|
-
|
|
33
|
+
/** A run's monotonic server revision (bumped on every persisted write; absent = 0). */
|
|
34
|
+
function revOf(e: ExecutionInstance): number {
|
|
35
|
+
return e.rev ?? 0
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Reconcile the cached executions with a server snapshot for `workspaceId`. A snapshot
|
|
40
|
+
* is authoritative EXCEPT where a live `execution` event already advanced (or ADDED) a
|
|
41
|
+
* run past what this (possibly stale) read observed — the same two clobber hazards the
|
|
42
|
+
* `agentRuns` store guards, keyed here on the run's monotonic `rev`:
|
|
43
|
+
* - REGRESS: a run present in BOTH — keep the newer-by-`rev` version, so a lagging
|
|
44
|
+
* refresh (the stream's on-(re)connect resync, the debounced `board`-event refetch)
|
|
45
|
+
* can't revert a just-terminal run to `running`. A terminal run emits nothing
|
|
46
|
+
* further, so a regression here would strand the UI until an unrelated refresh.
|
|
47
|
+
* - DROP: a run a live event just ADDED that the (older) snapshot never saw — keep it
|
|
48
|
+
* rather than silently dropping it.
|
|
49
|
+
*/
|
|
50
|
+
function hydrate(next: ExecutionInstance[], workspaceId: string) {
|
|
51
|
+
const sameWorkspace = hydratedWorkspaceId === workspaceId
|
|
52
|
+
hydratedWorkspaceId = workspaceId
|
|
53
|
+
if (!sameWorkspace) {
|
|
54
|
+
instances.value = next
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
const incomingIds = new Set(next.map((e) => e.id))
|
|
58
|
+
const held = new Map(instances.value.map((e) => [e.id, e]))
|
|
59
|
+
const reconciled = next.map((incoming) => {
|
|
60
|
+
const current = held.get(incoming.id)
|
|
61
|
+
return current && revOf(current) > revOf(incoming) ? current : incoming
|
|
62
|
+
})
|
|
63
|
+
const preserved = [...held.values()].filter((e) => !incomingIds.has(e.id))
|
|
64
|
+
instances.value = [...reconciled, ...preserved]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Insert or replace a single execution instance pushed by the event stream.
|
|
69
|
+
* Monotonic by `rev`: an out-of-order/stale event can't regress a run a newer
|
|
70
|
+
* write already advanced (same guard as {@link hydrate}).
|
|
71
|
+
*/
|
|
35
72
|
function upsert(instance: ExecutionInstance) {
|
|
36
73
|
const i = instances.value.findIndex((e) => e.id === instance.id)
|
|
37
|
-
if (i >= 0)
|
|
38
|
-
|
|
74
|
+
if (i >= 0) {
|
|
75
|
+
if (revOf(instance) >= revOf(instances.value[i]!)) instances.value[i] = instance
|
|
76
|
+
} else instances.value.push(instance)
|
|
39
77
|
}
|
|
40
78
|
|
|
41
79
|
const byId = computed(() => {
|
package/app/stores/pipelines.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|
|
2
2
|
import { computed, ref } from 'vue'
|
|
3
3
|
import type { AgentKind, Pipeline } from '~/types/domain'
|
|
4
4
|
import type { ConsensusStepConfig, StepGating } from '~/types/consensus'
|
|
5
|
+
import type { TesterQualityConfig } from '@cat-factory/contracts'
|
|
5
6
|
import { companionForProducer, uid } from '~/utils/catalog'
|
|
6
7
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
7
8
|
|
|
@@ -59,6 +60,13 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
59
60
|
* a `coder` step; `false` disables the companion there (default/true ⇒ enabled).
|
|
60
61
|
*/
|
|
61
62
|
const draftFollowUps = ref<(boolean | null)[]>([])
|
|
63
|
+
/**
|
|
64
|
+
* Per-step test quality-control companion config, kept index-aligned with `draft`. Only
|
|
65
|
+
* meaningful on a Tester step (`tester-api`/`tester-ui`); `null`/absent means "enabled, no
|
|
66
|
+
* gating" (the QC companion is on by default), `{ enabled: false }` disables it, and an
|
|
67
|
+
* entry with `gating` makes it conditional on the task estimate.
|
|
68
|
+
*/
|
|
69
|
+
const draftTesterQuality = ref<(TesterQualityConfig | null)[]>([])
|
|
62
70
|
/** Organizational labels for the pipeline being assembled/edited. */
|
|
63
71
|
const draftLabels = ref<string[]>([])
|
|
64
72
|
const draftName = ref('New pipeline')
|
|
@@ -84,6 +92,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
84
92
|
draftConsensus.value.splice(index, 0, null)
|
|
85
93
|
draftGating.value.splice(index, 0, null)
|
|
86
94
|
draftFollowUps.value.splice(index, 0, null)
|
|
95
|
+
draftTesterQuality.value.splice(index, 0, null)
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
function addToDraft(kind: AgentKind) {
|
|
@@ -98,6 +107,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
98
107
|
draftConsensus.value.splice(index, 1)
|
|
99
108
|
draftGating.value.splice(index, 1)
|
|
100
109
|
draftFollowUps.value.splice(index, 1)
|
|
110
|
+
draftTesterQuality.value.splice(index, 1)
|
|
101
111
|
}
|
|
102
112
|
|
|
103
113
|
function moveInDraft(from: number, to: number) {
|
|
@@ -116,6 +126,8 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
116
126
|
draftGating.value.splice(to, 0, gat ?? null)
|
|
117
127
|
const [fu] = draftFollowUps.value.splice(from, 1)
|
|
118
128
|
draftFollowUps.value.splice(to, 0, fu ?? null)
|
|
129
|
+
const [tq] = draftTesterQuality.value.splice(from, 1)
|
|
130
|
+
draftTesterQuality.value.splice(to, 0, tq ?? null)
|
|
119
131
|
}
|
|
120
132
|
|
|
121
133
|
/** Whether the producer step at `index` currently has its companion attached after it. */
|
|
@@ -191,6 +203,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
191
203
|
draftConsensus.value = reorder(draftConsensus.value)
|
|
192
204
|
draftGating.value = reorder(draftGating.value)
|
|
193
205
|
draftFollowUps.value = reorder(draftFollowUps.value)
|
|
206
|
+
draftTesterQuality.value = reorder(draftTesterQuality.value)
|
|
194
207
|
}
|
|
195
208
|
|
|
196
209
|
/** Toggle the consensus mechanism on the draft step at `index` (default config / off). */
|
|
@@ -214,6 +227,33 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
214
227
|
draftFollowUps.value[index] = draftFollowUps.value[index] === false ? null : false
|
|
215
228
|
}
|
|
216
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Toggle the test quality-control companion on the draft (Tester) step at `index`. The
|
|
232
|
+
* companion is enabled by default (a `null` entry), so the first toggle disables it
|
|
233
|
+
* (`{ enabled: false }`, dropping any gating) and the next restores the default.
|
|
234
|
+
*/
|
|
235
|
+
function toggleDraftTesterQuality(index: number) {
|
|
236
|
+
draftTesterQuality.value[index] =
|
|
237
|
+
draftTesterQuality.value[index]?.enabled === false ? null : { enabled: false }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Toggle estimate gating on/off for the QC companion on the draft (Tester) step at `index`.
|
|
242
|
+
* A no-op while the companion is disabled (nothing to gate). Enabling gating pins the config
|
|
243
|
+
* to `{ enabled: true, gating }` so the thresholds are editable; disabling drops back to the
|
|
244
|
+
* default `null` (enabled, ungated).
|
|
245
|
+
*/
|
|
246
|
+
function toggleDraftTesterQualityGating(index: number) {
|
|
247
|
+
const cur = draftTesterQuality.value[index]
|
|
248
|
+
if (cur?.enabled === false) return
|
|
249
|
+
draftTesterQuality.value[index] = cur?.gating?.enabled
|
|
250
|
+
? null
|
|
251
|
+
: {
|
|
252
|
+
enabled: true,
|
|
253
|
+
gating: { enabled: true, minRisk: 0.5, minImpact: 0.5, onMissingEstimate: 'run' },
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
217
257
|
/** Enable/disable the draft step at `index` without removing it. */
|
|
218
258
|
function toggleDraftEnabled(index: number) {
|
|
219
259
|
draftEnabled.value[index] = draftEnabled.value[index] === false
|
|
@@ -227,6 +267,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
227
267
|
draftConsensus.value = []
|
|
228
268
|
draftGating.value = []
|
|
229
269
|
draftFollowUps.value = []
|
|
270
|
+
draftTesterQuality.value = []
|
|
230
271
|
draftLabels.value = []
|
|
231
272
|
draftName.value = 'New pipeline'
|
|
232
273
|
editingId.value = null
|
|
@@ -241,6 +282,9 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
241
282
|
draftConsensus.value = pipeline.agentKinds.map((_, i) => pipeline.consensus?.[i] ?? null)
|
|
242
283
|
draftGating.value = pipeline.agentKinds.map((_, i) => pipeline.gating?.[i] ?? null)
|
|
243
284
|
draftFollowUps.value = pipeline.agentKinds.map((_, i) => pipeline.followUps?.[i] ?? null)
|
|
285
|
+
draftTesterQuality.value = pipeline.agentKinds.map(
|
|
286
|
+
(_, i) => pipeline.testerQuality?.[i] ?? null,
|
|
287
|
+
)
|
|
244
288
|
draftLabels.value = [...(pipeline.labels ?? [])]
|
|
245
289
|
draftName.value = pipeline.name
|
|
246
290
|
editingId.value = pipeline.id
|
|
@@ -270,6 +314,12 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
270
314
|
...(draftFollowUps.value.some((f) => f === false)
|
|
271
315
|
? { followUps: [...draftFollowUps.value] }
|
|
272
316
|
: {}),
|
|
317
|
+
// Only send testerQuality when at least one Tester step deviates from the default
|
|
318
|
+
// (companion disabled, or an estimate gate configured) — the default (null/enabled,
|
|
319
|
+
// ungated) is not worth persisting.
|
|
320
|
+
...(draftTesterQuality.value.some((q) => q?.enabled === false || q?.gating?.enabled)
|
|
321
|
+
? { testerQuality: [...draftTesterQuality.value] }
|
|
322
|
+
: {}),
|
|
273
323
|
// Only send labels when there are any.
|
|
274
324
|
...(draftLabels.value.length ? { labels: [...draftLabels.value] } : {}),
|
|
275
325
|
}
|
|
@@ -342,6 +392,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
342
392
|
draftConsensus,
|
|
343
393
|
draftGating,
|
|
344
394
|
draftFollowUps,
|
|
395
|
+
draftTesterQuality,
|
|
345
396
|
draftLabels,
|
|
346
397
|
draftName,
|
|
347
398
|
editingId,
|
|
@@ -357,6 +408,8 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
357
408
|
toggleDraftGating,
|
|
358
409
|
toggleDraftGate,
|
|
359
410
|
toggleDraftFollowUps,
|
|
411
|
+
toggleDraftTesterQuality,
|
|
412
|
+
toggleDraftTesterQualityGating,
|
|
360
413
|
toggleDraftEnabled,
|
|
361
414
|
toggleDraftConsensus,
|
|
362
415
|
setDraftConsensus,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { PreviewState } from '~/types/domain'
|
|
4
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The browsable-frontend-preview runtime state, keyed by `frontend` frame id. Distinct from
|
|
8
|
+
* the frame's persisted `frontendConfig.previewEnabled` flag: this is the LIVE resource (a
|
|
9
|
+
* container building/serving the app on a host URL) fetched from the three preview endpoints.
|
|
10
|
+
* The three calls all return the same {@link PreviewState}, so each action just stores the
|
|
11
|
+
* result. While a preview is `starting` the store self-polls until it settles (ready/failed),
|
|
12
|
+
* so the inspector reflects the URL the moment it comes up — no manual refresh.
|
|
13
|
+
*/
|
|
14
|
+
export const usePreviewStore = defineStore('preview', () => {
|
|
15
|
+
const api = useApi()
|
|
16
|
+
|
|
17
|
+
/** frameId → its latest preview state. */
|
|
18
|
+
const byFrame = ref<Record<string, PreviewState>>({})
|
|
19
|
+
/** frameId → a start/stop request is in flight (drives the button loading state). */
|
|
20
|
+
const busy = ref<Record<string, boolean>>({})
|
|
21
|
+
/** frameId → the last start/stop request error (e.g. the runtime 503s), else undefined. */
|
|
22
|
+
const requestError = ref<Record<string, string | undefined>>({})
|
|
23
|
+
|
|
24
|
+
// Active poll timers while a preview is `starting`, so a settled/left preview stops polling.
|
|
25
|
+
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
26
|
+
const POLL_INTERVAL_MS = 2_500
|
|
27
|
+
|
|
28
|
+
function stopPolling(frameId: string) {
|
|
29
|
+
const timer = timers.get(frameId)
|
|
30
|
+
if (timer) {
|
|
31
|
+
clearTimeout(timer)
|
|
32
|
+
timers.delete(frameId)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function apply(frameId: string, state: PreviewState) {
|
|
37
|
+
byFrame.value[frameId] = state
|
|
38
|
+
if (state.status === 'starting') {
|
|
39
|
+
stopPolling(frameId)
|
|
40
|
+
timers.set(
|
|
41
|
+
frameId,
|
|
42
|
+
setTimeout(() => void refresh(frameId), POLL_INTERVAL_MS),
|
|
43
|
+
)
|
|
44
|
+
} else {
|
|
45
|
+
stopPolling(frameId)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Fetch the current preview state for a frame (used on mount + as the poll tick). */
|
|
50
|
+
async function refresh(frameId: string): Promise<void> {
|
|
51
|
+
const ws = useWorkspaceStore()
|
|
52
|
+
try {
|
|
53
|
+
apply(frameId, await api.getPreview(ws.requireId(), frameId))
|
|
54
|
+
} catch {
|
|
55
|
+
// A transient error leaves the last known state; stop polling so we don't spin.
|
|
56
|
+
stopPolling(frameId)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Start (or restart) the preview for a frame. A request failure (e.g. the runtime replies 503)
|
|
62
|
+
* is captured in {@link requestError} rather than escaping as an unhandled rejection from the
|
|
63
|
+
* click handler.
|
|
64
|
+
*/
|
|
65
|
+
async function start(frameId: string): Promise<void> {
|
|
66
|
+
const ws = useWorkspaceStore()
|
|
67
|
+
busy.value[frameId] = true
|
|
68
|
+
requestError.value[frameId] = undefined
|
|
69
|
+
try {
|
|
70
|
+
apply(frameId, await api.startPreview(ws.requireId(), frameId))
|
|
71
|
+
} catch (err) {
|
|
72
|
+
requestError.value[frameId] = err instanceof Error ? err.message : String(err)
|
|
73
|
+
} finally {
|
|
74
|
+
busy.value[frameId] = false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Stop the preview for a frame. Failures are captured in {@link requestError}, not thrown. */
|
|
79
|
+
async function stop(frameId: string): Promise<void> {
|
|
80
|
+
const ws = useWorkspaceStore()
|
|
81
|
+
busy.value[frameId] = true
|
|
82
|
+
requestError.value[frameId] = undefined
|
|
83
|
+
try {
|
|
84
|
+
stopPolling(frameId)
|
|
85
|
+
apply(frameId, await api.stopPreview(ws.requireId(), frameId))
|
|
86
|
+
} catch (err) {
|
|
87
|
+
requestError.value[frameId] = err instanceof Error ? err.message : String(err)
|
|
88
|
+
} finally {
|
|
89
|
+
busy.value[frameId] = false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { byFrame, busy, requestError, refresh, start, stop, stopPolling }
|
|
94
|
+
})
|
|
@@ -13,6 +13,10 @@ import { useBoardStore } from '~/stores/board'
|
|
|
13
13
|
export const useRecurringPipelinesStore = defineStore('recurringPipelines', () => {
|
|
14
14
|
const api = useApi()
|
|
15
15
|
const toast = useToast()
|
|
16
|
+
// Resolve translations through the Nuxt app's global i18n instance — a store runs outside a
|
|
17
|
+
// component `setup`, so `useI18n()` is unavailable (see the board store for the same pattern).
|
|
18
|
+
const nuxtApp = useNuxtApp()
|
|
19
|
+
const tr = (key: string): string => (nuxtApp.$i18n as { t: (k: string) => string }).t(key)
|
|
16
20
|
|
|
17
21
|
const schedules = ref<PipelineSchedule[]>([])
|
|
18
22
|
/** Lazily-loaded run history, keyed by schedule id. */
|
|
@@ -68,7 +72,7 @@ export const useRecurringPipelinesStore = defineStore('recurringPipelines', () =
|
|
|
68
72
|
schedules.value = prevSchedules
|
|
69
73
|
if (blockSnap) board.reattach(blockSnap)
|
|
70
74
|
toast.add({
|
|
71
|
-
title: '
|
|
75
|
+
title: tr('board.toast.recurringDeleteFailed'),
|
|
72
76
|
description: e instanceof Error ? e.message : String(e),
|
|
73
77
|
icon: 'i-lucide-triangle-alert',
|
|
74
78
|
color: 'error',
|