@cat-factory/app 0.72.0 → 0.74.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 (46) hide show
  1. package/app/components/board/nodes/TaskCard.vue +12 -2
  2. package/app/components/bootstrap/BootstrapModal.vue +23 -7
  3. package/app/components/brainstorm/BrainstormWindow.vue +2 -0
  4. package/app/components/clarity/ClarityReviewWindow.vue +2 -0
  5. package/app/components/consensus/ConsensusSessionWindow.vue +2 -0
  6. package/app/components/focus/BlockFocusView.vue +2 -0
  7. package/app/components/followUp/FollowUpWindow.vue +2 -0
  8. package/app/components/gates/GateResultView.vue +2 -0
  9. package/app/components/github/AddServiceFromRepoModal.vue +54 -85
  10. package/app/components/humanTest/HumanTestWindow.vue +2 -0
  11. package/app/components/layout/ConnectionStatusBanner.vue +81 -0
  12. package/app/components/layout/NotificationsInbox.vue +15 -0
  13. package/app/components/panels/GenericStructuredResultView.vue +2 -0
  14. package/app/components/panels/InspectorPanel.vue +30 -1
  15. package/app/components/panels/inspector/TaskExecution.vue +25 -1
  16. package/app/components/pipeline/PipelineBuilder.vue +110 -7
  17. package/app/components/requirements/RequirementsReviewWindow.vue +2 -0
  18. package/app/components/spec/ServiceSpecWindow.vue +2 -0
  19. package/app/components/testing/TestReportWindow.vue +91 -0
  20. package/app/composables/api/github.ts +8 -3
  21. package/app/composables/useKeyboardShortcuts.ts +10 -2
  22. package/app/pages/index.vue +6 -4
  23. package/app/stores/board.spec.ts +58 -1
  24. package/app/stores/board.ts +59 -15
  25. package/app/stores/brainstorm.spec.ts +35 -0
  26. package/app/stores/brainstorm.ts +11 -2
  27. package/app/stores/clarity.spec.ts +33 -0
  28. package/app/stores/clarity.ts +11 -2
  29. package/app/stores/execution.spec.ts +71 -13
  30. package/app/stores/execution.ts +44 -6
  31. package/app/stores/github.ts +12 -3
  32. package/app/stores/pipelines.ts +53 -0
  33. package/app/stores/recurringPipelines.ts +5 -1
  34. package/app/stores/requirements.spec.ts +21 -0
  35. package/app/stores/requirements.ts +11 -2
  36. package/app/stores/workspace.ts +1 -1
  37. package/app/utils/catalog.ts +9 -0
  38. package/i18n/locales/en.json +56 -5
  39. package/i18n/locales/es.json +56 -5
  40. package/i18n/locales/fr.json +56 -5
  41. package/i18n/locales/he.json +56 -5
  42. package/i18n/locales/ja.json +56 -5
  43. package/i18n/locales/pl.json +56 -5
  44. package/i18n/locales/tr.json +56 -5
  45. package/i18n/locales/uk.json +56 -5
  46. package/package.json +2 -2
@@ -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
- // Patch the cache from a live `clarity` stream event.
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
- instance('e1', 'b1', [
23
- { agentKind: 'coder', decision: { id: 'd1', chosen: null } },
24
- { agentKind: 'coder', decision: { id: 'd2', chosen: 'yes' } }, // chosen ⇒ excluded
25
- ]),
26
- instance('e2', 'b2', [{ agentKind: 'architect', decision: { id: 'd3', chosen: null } }]),
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
- instance('e1', 'b1', [
36
- { agentKind: 'merger', approval: { id: 'a1', status: 'pending' } },
37
- { agentKind: 'merger', approval: { id: 'a2', status: 'approved' } }, // not pending ⇒ excluded
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
+ })
@@ -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
- /** Replace the cached executions with a server snapshot. */
30
- function hydrate(next: ExecutionInstance[]) {
31
- instances.value = next
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
- /** Insert or replace a single execution instance pushed by the event stream. */
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) instances.value[i] = instance
38
- else instances.value.push(instance)
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(() => {
@@ -131,12 +131,21 @@ export const useGitHubStore = defineStore('github', () => {
131
131
  if (connected.value && repos.value.length === 0) await load()
132
132
  }
133
133
 
134
- /** Load the repos the installation can access, with this workspace's link state. */
135
- async function loadAvailableRepos() {
134
+ /**
135
+ * Load the repos the installation can access, with this workspace's link state.
136
+ * With a `q` the backend filters `owner/name` server-side (the add-service picker
137
+ * searches instead of prefetching a huge installation); without one it browses all
138
+ * (the repo-link panel). A blank/short `q` clears the list rather than fetching.
139
+ */
140
+ async function loadAvailableRepos(q?: string) {
136
141
  if (!connected.value) return
142
+ if (q !== undefined && q.trim() === '') {
143
+ availableRepos.value = []
144
+ return
145
+ }
137
146
  loadingAvailable.value = true
138
147
  try {
139
- availableRepos.value = await api.listGitHubAvailableRepos(workspace.requireId())
148
+ availableRepos.value = await api.listGitHubAvailableRepos(workspace.requireId(), q)
140
149
  } finally {
141
150
  loadingAvailable.value = false
142
151
  }
@@ -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,
@@ -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: 'Could not delete recurring pipeline',
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',
@@ -92,3 +92,24 @@ describe('requirements store load() loading flag', () => {
92
92
  expect(calls).toBe(2)
93
93
  })
94
94
  })
95
+
96
+ describe('requirements store live-event upsert guard', () => {
97
+ it('an out-of-order stream event cannot revert a newer cached review', () => {
98
+ const store = useRequirementsStore()
99
+ // The API response for a just-submitted answer landed first (newer updatedAt)…
100
+ store.upsert(review({ updatedAt: 2000, status: 'merged' }))
101
+ // …then the slightly-older stream event (emitted just before) arrives late.
102
+ store.upsert(review({ updatedAt: 1000, status: 'ready' }))
103
+ expect(store.reviewFor('b1')?.status).toBe('merged')
104
+ // A genuinely newer event still applies.
105
+ store.upsert(review({ updatedAt: 3000, status: 'incorporated' }))
106
+ expect(store.reviewFor('b1')?.status).toBe('incorporated')
107
+ })
108
+
109
+ it('a NEW review (different id) for the block replaces regardless of updatedAt', () => {
110
+ const store = useRequirementsStore()
111
+ store.upsert(review({ updatedAt: 2000 }))
112
+ store.upsert(review({ id: 'rr2', updatedAt: 1000 }))
113
+ expect(store.reviewFor('b1')?.id).toBe('rr2')
114
+ })
115
+ })
@@ -97,6 +97,16 @@ export const useRequirementsStore = defineStore('requirements', () => {
97
97
  reviews.value = { ...reviews.value, [review.blockId]: review }
98
98
  }
99
99
 
100
+ /** Patch the cache from a live `requirements` stream event (newest wins per block). */
101
+ function upsert(review: RequirementReview) {
102
+ const existing = reviews.value[review.blockId]
103
+ // Keep the freshest by updatedAt (the consensus-store guard): `store()` also runs on
104
+ // API responses, so a slightly-older event racing a just-submitted answer over the
105
+ // separate WS transport must not revert the review the response already delivered.
106
+ if (existing && existing.id === review.id && existing.updatedAt > review.updatedAt) return
107
+ store(review)
108
+ }
109
+
100
110
  /** Drop all cached reviews + in-flight state (called on workspace switch). */
101
111
  function reset() {
102
112
  available.value = null
@@ -277,7 +287,6 @@ export const useRequirementsStore = defineStore('requirements', () => {
277
287
  rejectRecommendation,
278
288
  reRequestRecommendation,
279
289
  reset,
280
- // Patch the cache from a live `requirements` stream event.
281
- upsert: store,
290
+ upsert,
282
291
  }
283
292
  })
@@ -91,7 +91,7 @@ export const useWorkspaceStore = defineStore(
91
91
  else workspaces.value.unshift(snapshot.workspace)
92
92
  useBoardStore().hydrate(snapshot.blocks)
93
93
  usePipelinesStore().hydrate(snapshot.pipelines, snapshot.pipelineCatalogVersions)
94
- useExecutionStore().hydrate(snapshot.executions)
94
+ useExecutionStore().hydrate(snapshot.executions, snapshot.workspace.id)
95
95
  useAgentRunsStore().hydrate(snapshot.bootstrapJobs ?? [], snapshot.workspace.id)
96
96
  useAgentRunsStore().hydrateEnvConfigRepair(snapshot.envConfigRepairJobs ?? [])
97
97
  useNotificationsStore().hydrate(snapshot.notifications ?? [])
@@ -312,6 +312,15 @@ export function isConsensusEligibleKind(kind: string): boolean {
312
312
  return CONSENSUS_ELIGIBLE_KINDS.has(kind)
313
313
  }
314
314
 
315
+ /**
316
+ * Whether an agent kind is one of the Tester gate kinds (API or UI). Mirrors the backend
317
+ * `isTesterKind`; used by the pipeline builder to surface the test quality-control companion
318
+ * toggle only on Tester steps.
319
+ */
320
+ export function isTesterKind(kind: string): boolean {
321
+ return kind === 'tester-api' || kind === 'tester-ui'
322
+ }
323
+
315
324
  /**
316
325
  * Display metadata for the engine-driven "system" kinds — the gate/automation
317
326
  * steps (blueprint mapper, conflicts gate + resolver, CI gate + fixer, merger)
@@ -1,4 +1,10 @@
1
1
  {
2
+ "app": {
3
+ "loading": "Loading…",
4
+ "loadingBoard": "Loading board…",
5
+ "backendUnreachable": "Can't reach the backend",
6
+ "reconnecting": "Reconnecting…"
7
+ },
2
8
  "language": {
3
9
  "switcher": "Language",
4
10
  "warning": {
@@ -83,6 +89,14 @@
83
89
  "accountSettings": "Account settings"
84
90
  },
85
91
  "board": {
92
+ "toast": {
93
+ "updateFailed": "Could not save changes",
94
+ "epicFailed": "Could not change epic",
95
+ "moveFailed": "Could not move",
96
+ "deleteFailed": "Could not delete",
97
+ "linkFailed": "Could not link tasks",
98
+ "recurringDeleteFailed": "Could not delete recurring pipeline"
99
+ },
86
100
  "repoTypes": {
87
101
  "service": "Service",
88
102
  "frontend": "Frontend",
@@ -291,6 +305,11 @@
291
305
  "openPrOnGithub": "Open {pr} on GitHub",
292
306
  "review": "Review",
293
307
  "merge": "Merge",
308
+ "mergeConfirm": {
309
+ "title": "Merge this pull request?",
310
+ "body": "This merges the PR into its base branch and completes the task. This can't be undone.",
311
+ "confirm": "Merge"
312
+ },
294
313
  "implemented": "implemented",
295
314
  "module": "Module: {name}",
296
315
  "buildSteps": "Build steps",
@@ -645,6 +664,16 @@
645
664
  "stopTooltip": "Stop the run but keep it (readable and retryable)",
646
665
  "reset": "Reset",
647
666
  "resetTooltip": "Discard this run and reset the task to planned",
667
+ "resetConfirm": {
668
+ "title": "Discard this run?",
669
+ "body": "This deletes the run and returns the task to planned. This can't be undone.",
670
+ "confirm": "Discard run"
671
+ },
672
+ "mergeConfirm": {
673
+ "title": "Merge this pull request?",
674
+ "body": "This merges the PR into its base branch and completes the task. This can't be undone.",
675
+ "confirm": "Merge"
676
+ },
648
677
  "viewDetailsOutput": "View details and read output",
649
678
  "viewDetails": "View step details",
650
679
  "companion": "Companion",
@@ -1228,7 +1257,9 @@
1228
1257
  "dismiss": "Dismiss",
1229
1258
  "toast": {
1230
1259
  "acted": "Marked as handled",
1231
- "dismissed": "Dismissed"
1260
+ "dismissed": "Dismissed",
1261
+ "actFailed": "Could not complete that action",
1262
+ "dismissFailed": "Could not dismiss"
1232
1263
  },
1233
1264
  "action": {
1234
1265
  "merge_review": "Merge",
@@ -2675,6 +2706,10 @@
2675
2706
  "consensusRevertTooltip": "Consensus enabled. Click to revert to a single agent.",
2676
2707
  "followUpEnableTooltip": "Follow-up companion disabled. Click to enable (Coder surfaces loose ends / questions).",
2677
2708
  "followUpDisableTooltip": "Follow-up companion enabled. Coder surfaces loose ends / side-tasks / questions; click to disable.",
2709
+ "testerQualityEnableTooltip": "Test quality companion disabled. Click to enable (audits the report for coverage and loops the Tester on gaps).",
2710
+ "testerQualityDisableTooltip": "Test quality companion enabled. Audits the report for coverage before greenlight and loops the Tester on gaps; click to disable.",
2711
+ "testerQualityLabel": "Test quality companion",
2712
+ "testerQualityGateTooltip": "Only run the quality audit when the task estimate clears a threshold (needs a Task Estimator earlier)",
2678
2713
  "moveUp": "Move step up",
2679
2714
  "moveDown": "Move step down",
2680
2715
  "removeStep": "Remove this step from the pipeline",
@@ -3365,6 +3400,15 @@
3365
3400
  "failed": "Failed",
3366
3401
  "addressed": "Addressing"
3367
3402
  },
3403
+ "quality": {
3404
+ "heading": "Coverage review",
3405
+ "reruns": "Quality-driven Tester re-runs",
3406
+ "rerunCount": "{attempts}/{max} re-runs",
3407
+ "exceeded": "Budget spent",
3408
+ "adequate": "Coverage adequate",
3409
+ "inadequate": "Coverage gaps found",
3410
+ "gaps": "Gaps to close"
3411
+ },
3368
3412
  "empty": {
3369
3413
  "title": "No test report yet.",
3370
3414
  "hint": "The report appears once the Tester finishes a pass. While it runs, the step shows live progress on the board."
@@ -3837,6 +3881,7 @@
3837
3881
  },
3838
3882
  "targetRepo": {
3839
3883
  "label": "Target repository name",
3884
+ "namePlaceholder": "payments-service",
3840
3885
  "descWithOwner": "Create a fresh repo with this name under {owner}, then bootstrap pushes into it. A prepopulated README, .gitignore or license is fine.",
3841
3886
  "descNoOwner": "Create a fresh repo with this name, then bootstrap pushes into it. A prepopulated README, .gitignore or license is fine."
3842
3887
  },
@@ -3856,7 +3901,8 @@
3856
3901
  },
3857
3902
  "description": {
3858
3903
  "label": "Description",
3859
- "help": "Optional one-line summary for the repo."
3904
+ "help": "Optional one-line summary for the repo.",
3905
+ "placeholder": "Handles payment intents and refunds"
3860
3906
  },
3861
3907
  "instructions": {
3862
3908
  "labelReference": "Extra instructions for the bootstrapper",
@@ -3886,18 +3932,23 @@
3886
3932
  "add": "Add",
3887
3933
  "pickRepo": {
3888
3934
  "label": "Pick an existing GitHub repo",
3889
- "description": "Choose a repo you can access to fill in its owner and name, or enter them manually below."
3935
+ "description": "Choose a repo you can access to fill in its owner and name, or enter them manually below.",
3936
+ "placeholder": "owner/name"
3890
3937
  },
3891
3938
  "name": {
3892
3939
  "label": "Name",
3893
- "description": "A friendly label for this base."
3940
+ "description": "A friendly label for this base.",
3941
+ "placeholder": "Service Template"
3894
3942
  },
3895
3943
  "repoOwner": "Repo owner",
3944
+ "repoOwnerPlaceholder": "acme",
3896
3945
  "repoName": "Repo name",
3946
+ "repoNamePlaceholder": "service-template",
3897
3947
  "descriptionPlaceholder": "Optional summary of this base",
3898
3948
  "defaultInstructions": {
3899
3949
  "label": "Default bootstrapper instructions",
3900
- "description": "Prepended to the per-run instructions whenever this base is used."
3950
+ "description": "Prepended to the per-run instructions whenever this base is used.",
3951
+ "placeholder": "e.g. keep the structure; rename packages to match the new service"
3901
3952
  }
3902
3953
  },
3903
3954
  "toast": {