@cat-factory/app 0.256.2 → 0.257.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 (48) hide show
  1. package/README.md +4 -0
  2. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +284 -0
  3. package/app/components/binaryOutput/BinaryOutputReport.vue +33 -0
  4. package/app/components/board/nodes/BlockNode.vue +3 -7
  5. package/app/components/board/nodes/InitiativeCard.vue +1 -1
  6. package/app/components/focus/BlockFocusView.vue +1 -1
  7. package/app/components/forkDecision/ForkDecisionWindow.vue +3 -1
  8. package/app/components/outcome/OutcomeSummaryWindow.vue +1 -2
  9. package/app/components/panels/AgentStepDetail.vue +42 -20
  10. package/app/components/panels/InspectorPanel.vue +6 -11
  11. package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
  12. package/app/components/panels/inspector/TaskExecution.vue +34 -34
  13. package/app/components/pipeline/BinaryOutputStepPicker.logic.spec.ts +90 -1
  14. package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +92 -1
  15. package/app/components/pipeline/BinaryOutputStepPicker.vue +354 -1
  16. package/app/components/pipeline/PipelineProgress.vue +48 -10
  17. package/app/components/settings/KubernetesEngineForm.vue +98 -46
  18. package/app/components/spec/ServiceSpecWindow.vue +5 -4
  19. package/app/composables/api/binaryCandidates.ts +36 -0
  20. package/app/composables/useApi.ts +2 -0
  21. package/app/docs/architecture.md +1 -1
  22. package/app/modular/panels/inspector.ts +3 -3
  23. package/app/modular/result-views.ts +4 -0
  24. package/app/stores/binaryCandidates.ts +89 -0
  25. package/app/stores/board/placement.ts +32 -8
  26. package/app/stores/ui/resultViews.ts +8 -6
  27. package/app/stores/ui/runStepOpeners.ts +23 -1
  28. package/app/types/execution.ts +5 -0
  29. package/app/utils/badge.ts +14 -0
  30. package/app/utils/binaryCandidates.spec.ts +110 -0
  31. package/app/utils/binaryCandidates.ts +126 -0
  32. package/app/utils/binaryOutput.ts +48 -2
  33. package/app/utils/catalog.ts +2 -1
  34. package/app/utils/initiative.ts +1 -4
  35. package/app/utils/pipelineRender.spec.ts +46 -1
  36. package/app/utils/pipelineRender.ts +70 -2
  37. package/i18n/locales/de.json +78 -2
  38. package/i18n/locales/en.json +78 -2
  39. package/i18n/locales/es.json +78 -2
  40. package/i18n/locales/fr.json +78 -2
  41. package/i18n/locales/he.json +78 -2
  42. package/i18n/locales/it.json +78 -2
  43. package/i18n/locales/ja.json +78 -2
  44. package/i18n/locales/pl.json +78 -2
  45. package/i18n/locales/tr.json +78 -2
  46. package/i18n/locales/uk.json +78 -2
  47. package/i18n/plural-forms.spec.ts +15 -0
  48. package/package.json +2 -2
@@ -14,6 +14,7 @@ import type {
14
14
  InfraEngine,
15
15
  InfraHandlerConfig,
16
16
  } from '@cat-factory/contracts'
17
+ import { isKubernetesUrlSource } from '@cat-factory/contracts'
17
18
  import type { K3sSetupPrefill } from '~/stores/ui'
18
19
 
19
20
  // The kube branch of the discriminated handler config this form produces (the `local-k3s` /
@@ -22,6 +23,10 @@ import type { K3sSetupPrefill } from '~/stores/ui'
22
23
  // `as never` cast, so a wrong config shape is caught at the call site instead of server-side.
23
24
  type KubeHandlerConfig = Extract<InfraHandlerConfig, { engine: 'local-k3s' | 'remote-kubernetes' }>
24
25
  type KubeHandlerPayload = { config: KubeHandlerConfig; secrets: Record<string, string> }
26
+ /** The engine connection block itself, read off the variant so it cannot drift from it. */
27
+ type KubeEngineConfig = KubeHandlerConfig['kubernetes']
28
+ /** How the environment URL is derived: its own discriminated union, keyed by `source`. */
29
+ type KubeUrlSource = KubeEngineConfig['url']
25
30
 
26
31
  const props = defineProps<{
27
32
  /** `local-k3s` or `remote-kubernetes` — the engine this handler is registered under. */
@@ -46,12 +51,9 @@ const emit = defineEmits<{
46
51
 
47
52
  const { t } = useI18n()
48
53
 
49
- type UrlSource =
50
- | 'ingressTemplate'
51
- | 'ingressStatus'
52
- | 'serviceStatus'
53
- | 'gatewayStatus'
54
- | 'httpRouteStatus'
54
+ /** The `source` discriminants, read off the contract union: a source added there makes
55
+ * `buildUrl`'s switch non-exhaustive rather than leaving this list quietly short. */
56
+ type UrlSource = KubeUrlSource['source']
55
57
 
56
58
  const form = reactive({
57
59
  label: '',
@@ -101,23 +103,34 @@ watch(
101
103
  (h) => {
102
104
  const cfg = h?.config
103
105
  if (!cfg || (cfg.engine !== 'local-k3s' && cfg.engine !== 'remote-kubernetes')) return
104
- const k = cfg.kubernetes as Record<string, unknown>
105
- form.label = typeof k.label === 'string' ? k.label : ''
106
- form.apiServerUrl = typeof k.apiServerUrl === 'string' ? k.apiServerUrl : ''
107
- form.caCertPem = typeof k.caCertPem === 'string' ? k.caCertPem : ''
106
+ // Narrowing `cfg.engine` above types `kubernetes` as the engine config, so these read the
107
+ // contract directly. They used to widen it to a `Record` and `typeof`-guard every field,
108
+ // which re-derived at runtime what the discriminated union already states.
109
+ const k = cfg.kubernetes
110
+ form.label = k.label
111
+ form.apiServerUrl = k.apiServerUrl
112
+ form.caCertPem = k.caCertPem ?? ''
108
113
  form.insecureSkipTlsVerify = k.insecureSkipTlsVerify === true
109
- form.namespaceTemplate = typeof k.namespaceTemplate === 'string' ? k.namespaceTemplate : ''
110
- form.imageTemplate = typeof k.imageTemplate === 'string' ? k.imageTemplate : ''
111
- const url = k.url as Record<string, unknown> | undefined
112
- const src = typeof url?.source === 'string' ? (url.source as UrlSource) : 'ingressTemplate'
113
- form.urlSource = src
114
- form.hostTemplate = typeof url?.hostTemplate === 'string' ? url.hostTemplate : ''
115
- form.ingressName = typeof url?.ingressName === 'string' ? url.ingressName : ''
116
- form.serviceName = typeof url?.serviceName === 'string' ? url.serviceName : ''
117
- form.servicePort = typeof url?.port === 'number' ? String(url.port) : ''
118
- form.gatewayName = typeof url?.gatewayName === 'string' ? url.gatewayName : ''
119
- form.httpRouteName = typeof url?.httpRouteName === 'string' ? url.httpRouteName : ''
120
- form.urlScheme = url?.scheme === 'http' || url?.scheme === 'https' ? url.scheme : 'default'
114
+ form.namespaceTemplate = k.namespaceTemplate ?? ''
115
+ form.imageTemplate = k.imageTemplate ?? ''
116
+ // Each url field is read off the ONE variant that carries it, so a field belonging to a
117
+ // different `source` cannot silently populate the form.
118
+ //
119
+ // Typed as present with an on-union `source`, read as neither: both were true when the
120
+ // connect form admitted this config, and the value has been through storage since — which is
121
+ // exactly why the backend re-parses a stored `providerConfig` rather than asserting it, and
122
+ // this form is where an operator REPAIRS one that drifted. An unrecognised source falls back
123
+ // to the form's default, because `buildUrl` has no branch to build a config out of one.
124
+ const url: KubeUrlSource | undefined = k.url
125
+ const source = url?.source
126
+ form.urlSource = isKubernetesUrlSource(source) ? source : 'ingressTemplate'
127
+ form.hostTemplate = url?.source === 'ingressTemplate' ? url.hostTemplate : ''
128
+ form.ingressName = url?.source === 'ingressStatus' ? (url.ingressName ?? '') : ''
129
+ form.serviceName = url?.source === 'serviceStatus' ? url.serviceName : ''
130
+ form.servicePort = url?.source === 'serviceStatus' && url.port != null ? String(url.port) : ''
131
+ form.gatewayName = url?.source === 'gatewayStatus' ? (url.gatewayName ?? '') : ''
132
+ form.httpRouteName = url?.source === 'httpRouteStatus' ? (url.httpRouteName ?? '') : ''
133
+ form.urlScheme = url?.scheme ?? 'default'
121
134
  },
122
135
  { immediate: true },
123
136
  )
@@ -222,42 +235,81 @@ const connectBlockedReason = computed(() => {
222
235
  return t('settings.infrastructure.kubernetesEngine.invalidPort')
223
236
  })
224
237
 
225
- function buildUrl(): Record<string, unknown> {
226
- const url: Record<string, unknown> = { source: form.urlSource }
227
- if (form.urlSource === 'ingressTemplate') {
228
- url.hostTemplate = form.hostTemplate.trim()
229
- } else if (form.urlSource === 'ingressStatus') {
230
- if (form.ingressName.trim()) url.ingressName = form.ingressName.trim()
231
- } else if (form.urlSource === 'serviceStatus') {
232
- url.serviceName = form.serviceName.trim()
233
- const port = Number(form.servicePort)
234
- if (form.servicePort.trim() && Number.isInteger(port)) url.port = port
235
- } else if (form.urlSource === 'gatewayStatus') {
236
- if (form.gatewayName.trim()) url.gatewayName = form.gatewayName.trim()
237
- } else {
238
- if (form.httpRouteName.trim()) url.httpRouteName = form.httpRouteName.trim()
238
+ /**
239
+ * The URL-derivation block, built as the contract's discriminated union rather than a `Record`.
240
+ * Each branch returns its OWN variant, so the fields a source carries are checked against that
241
+ * source: setting `hostTemplate` on a `serviceStatus` url stops compiling instead of shipping a
242
+ * config the backend rejects. `urlScheme` is the one field every variant shares, and the
243
+ * 'default' sentinel means "omit it and let the derivation decide".
244
+ */
245
+ function buildUrl(): KubeUrlSource {
246
+ const scheme = form.urlScheme === 'default' ? {} : { scheme: form.urlScheme }
247
+ switch (form.urlSource) {
248
+ case 'ingressTemplate':
249
+ return { source: 'ingressTemplate', hostTemplate: form.hostTemplate.trim(), ...scheme }
250
+ case 'ingressStatus': {
251
+ const ingressName = form.ingressName.trim()
252
+ return { source: 'ingressStatus', ...(ingressName ? { ingressName } : {}), ...scheme }
253
+ }
254
+ case 'serviceStatus': {
255
+ const port = Number(form.servicePort)
256
+ return {
257
+ source: 'serviceStatus',
258
+ serviceName: form.serviceName.trim(),
259
+ ...(form.servicePort.trim() && Number.isInteger(port) ? { port } : {}),
260
+ ...scheme,
261
+ }
262
+ }
263
+ case 'gatewayStatus': {
264
+ const gatewayName = form.gatewayName.trim()
265
+ return { source: 'gatewayStatus', ...(gatewayName ? { gatewayName } : {}), ...scheme }
266
+ }
267
+ case 'httpRouteStatus': {
268
+ const httpRouteName = form.httpRouteName.trim()
269
+ return { source: 'httpRouteStatus', ...(httpRouteName ? { httpRouteName } : {}), ...scheme }
270
+ }
271
+ default:
272
+ return refuseUnknownUrlSource(form.urlSource)
239
273
  }
240
- if (form.urlScheme !== 'default') url.scheme = form.urlScheme
241
- return url
274
+ }
275
+
276
+ /**
277
+ * A `source` outside the contract union, which the switch above therefore cannot build.
278
+ *
279
+ * The parameter is `never`, so this keeps BOTH properties at once: a source added to the contract
280
+ * without a case above still fails the typecheck (the argument stops being `never`), while a value
281
+ * the union never had is refused at runtime instead of falling off the end of the switch. That end
282
+ * is what the `default` exists to close: it returned `undefined`, which `buildPayload` then sent as
283
+ * the config's `url` for the backend to reject as a missing block.
284
+ *
285
+ * Deliberately NOT mapped onto a current source. Nothing here knows which one was meant, and a
286
+ * guess would silently rewrite the operator's URL derivation to something they never picked.
287
+ */
288
+ function refuseUnknownUrlSource(source: never): never {
289
+ throw new Error(`Unsupported Kubernetes URL source '${String(source)}'`)
242
290
  }
243
291
 
244
292
  function buildPayload(): KubeHandlerPayload {
245
- const kubernetes: Record<string, unknown> = {
293
+ // Built as the contract type rather than assembled into a `Record` and asserted: an optional
294
+ // field is a conditional SPREAD, so a key the config does not declare (or a value of the
295
+ // wrong type) fails the build here instead of surfacing as a server-side validation refusal.
296
+ const caCertPem = form.caCertPem.trim()
297
+ const namespaceTemplate = form.namespaceTemplate.trim()
298
+ const imageTemplate = form.imageTemplate.trim()
299
+ const kubernetes: KubeEngineConfig = {
246
300
  label: form.label.trim(),
247
301
  apiServerUrl: form.apiServerUrl.trim(),
248
302
  url: buildUrl(),
303
+ ...(caCertPem ? { caCertPem } : {}),
304
+ ...(form.insecureSkipTlsVerify ? { insecureSkipTlsVerify: true } : {}),
305
+ ...(namespaceTemplate ? { namespaceTemplate } : {}),
306
+ ...(imageTemplate ? { imageTemplate } : {}),
249
307
  }
250
- if (form.caCertPem.trim()) kubernetes.caCertPem = form.caCertPem.trim()
251
- if (form.insecureSkipTlsVerify) kubernetes.insecureSkipTlsVerify = true
252
- if (form.namespaceTemplate.trim()) kubernetes.namespaceTemplate = form.namespaceTemplate.trim()
253
- if (form.imageTemplate.trim()) kubernetes.imageTemplate = form.imageTemplate.trim()
254
- // One honest assertion at the boundary that actually builds the shape (the reactive form is
255
- // dynamically assembled, then validated server-side); the emitted config flows typed onward.
256
308
  // OMIT the token when the field is blank so the backend preserves the saved one (a blank
257
309
  // secret means "keep it") — only a typed value is sent, and it replaces the stored token.
258
310
  const token = apiToken.value.trim()
259
311
  return {
260
- config: { engine: props.engine, kubernetes } as unknown as KubeHandlerConfig,
312
+ config: { engine: props.engine, kubernetes },
261
313
  secrets: token ? { [KUBERNETES_ENV_TOKEN_SECRET_KEY]: token } : {},
262
314
  }
263
315
  }
@@ -21,6 +21,7 @@ import {
21
21
  summarizeSpecStates,
22
22
  type RequirementStateFilter,
23
23
  } from './ServiceSpecWindow.logic'
24
+ import type { BadgeColor } from '~/utils/badge'
24
25
 
25
26
  const { t } = useI18n()
26
27
  const board = useBoardStore()
@@ -115,7 +116,7 @@ function retry() {
115
116
 
116
117
  // Exhaustive priority → label/chip map. Literal `t()` keys keep the typed-key drift
117
118
  // guard live, vs a runtime-built `spec.priority.${value}`.
118
- const PRIORITY_META: Record<RequirementPriority, { label: string; chip: string }> = {
119
+ const PRIORITY_META: Record<RequirementPriority, { label: string; chip: BadgeColor }> = {
119
120
  must: { label: t('spec.priority.must'), chip: 'error' },
120
121
  should: { label: t('spec.priority.should'), chip: 'warning' },
121
122
  could: { label: t('spec.priority.could'), chip: 'neutral' },
@@ -131,7 +132,7 @@ const KIND_LABELS: Record<RequirementKind, string> = {
131
132
  // Exhaustive implementation-state → presentation map. `established` is the only state that
132
133
  // means "the service is observed to do this"; everything else is a behaviour that has been
133
134
  // agreed and not yet seen to hold, which must never read as standing behaviour.
134
- const STATE_META: Record<RequirementState, { label: string; chip: string; icon: string }> = {
135
+ const STATE_META: Record<RequirementState, { label: string; chip: BadgeColor; icon: string }> = {
135
136
  established: {
136
137
  label: t('spec.state.established'),
137
138
  chip: 'success',
@@ -448,7 +449,7 @@ function kindLabel(item: RequirementItem): string {
448
449
  <!-- implementation state: agreed vs observed to hold. The distinction the
449
450
  build prompt and the tester act on, so a reader must see it too. -->
450
451
  <UBadge
451
- :color="stateMeta(req).chip as any"
452
+ :color="stateMeta(req).chip"
452
453
  variant="subtle"
453
454
  size="sm"
454
455
  :icon="stateMeta(req).icon"
@@ -456,7 +457,7 @@ function kindLabel(item: RequirementItem): string {
456
457
  >
457
458
  {{ stateMeta(req).label }}
458
459
  </UBadge>
459
- <UBadge :color="priorityMeta(req).chip as any" variant="subtle" size="sm">
460
+ <UBadge :color="priorityMeta(req).chip" variant="subtle" size="sm">
460
461
  {{ priorityMeta(req).label }}
461
462
  </UBadge>
462
463
  <UBadge color="neutral" variant="subtle" size="sm">{{ kindLabel(req) }}</UBadge>
@@ -0,0 +1,36 @@
1
+ import {
2
+ getBinaryCandidatesContract,
3
+ keepBinaryCandidatesContract,
4
+ type KeepBinaryCandidatesInput,
5
+ } from '@cat-factory/contracts'
6
+ import type { ApiContext } from './context'
7
+
8
+ /**
9
+ * Generated-candidate comparison. A binary-output step configured to COMPARE generates a
10
+ * candidate from each of its selected integrations, stages them through the step's storage
11
+ * service, and parks. These endpoints read the staged candidates and record which of them
12
+ * survive (and under which alternate ids); keeping re-runs the step to deliver exactly those.
13
+ * The read returns null when no step carries candidate state.
14
+ */
15
+ export function binaryCandidatesApi({ send, ws }: ApiContext) {
16
+ return {
17
+ // The live candidate state for a run (null when no step carries one).
18
+ getBinaryCandidates: (workspaceId: string, executionId: string) =>
19
+ send(getBinaryCandidatesContract, {
20
+ pathPrefix: ws(workspaceId),
21
+ pathParams: { executionId },
22
+ }),
23
+
24
+ // Keep the chosen candidates and discard the rest.
25
+ keepBinaryCandidates: (
26
+ workspaceId: string,
27
+ executionId: string,
28
+ body: KeepBinaryCandidatesInput,
29
+ ) =>
30
+ send(keepBinaryCandidatesContract, {
31
+ pathPrefix: ws(workspaceId),
32
+ pathParams: { executionId },
33
+ body,
34
+ }),
35
+ }
36
+ }
@@ -13,6 +13,7 @@ import { boardApi } from './api/board'
13
13
  import { documentsApi } from './api/documents'
14
14
  import { executionApi } from './api/execution'
15
15
  import { followUpsApi } from './api/followUps'
16
+ import { binaryCandidatesApi } from './api/binaryCandidates'
16
17
  import { forkDecisionApi } from './api/forkDecision'
17
18
  import { inputGateApi } from './api/inputGate'
18
19
  import { judgeApi } from './api/judge'
@@ -131,6 +132,7 @@ export function useApi() {
131
132
  ...bugHuntApi(ctx),
132
133
  ...reviewsApi(ctx),
133
134
  ...followUpsApi(ctx),
135
+ ...binaryCandidatesApi(ctx),
134
136
  ...forkDecisionApi(ctx),
135
137
  ...inputGateApi(ctx),
136
138
  ...judgeApi(ctx),
@@ -3,7 +3,7 @@
3
3
  How the SPA stays in sync with the backend. The app is a **thin client**: it holds
4
4
  no business logic, calls the Worker for every mutation, and hydrates its stores
5
5
  from server snapshots plus pushed events. For the high-level tour see
6
- [`../README.md`](../README.md).
6
+ [`frontend/app/README.md`](../../README.md).
7
7
 
8
8
  ## The three paths
9
9
 
@@ -4,8 +4,8 @@ import type { PanelEntry } from '@modular-vue/core'
4
4
  import type { Block } from '~/types/domain'
5
5
 
6
6
  /** The engine's opaque component type on a `PanelEntry` (the neutral `UiComponent`,
7
- * which isn't exported by name). A Vue component is a valid one; the outlet renders
8
- * it as a Vue component. We reference it structurally so no cast reaches for `any`. */
7
+ * which isn't exported by name). Referenced structurally off `PanelEntry` so a
8
+ * `defineComponent` result is checked against it rather than asserted into it. */
9
9
  type PanelComponent = PanelEntry<Block>['component']
10
10
  import {
11
11
  INSPECTOR_PANELS_SLOT,
@@ -61,7 +61,7 @@ function blockPanel(component: Component, id: InspectorPanelId): PanelComponent
61
61
  const block = usePanelSubject<Block>()
62
62
  return () => h(component, { block: block.value })
63
63
  },
64
- }) as unknown as PanelComponent
64
+ })
65
65
  }
66
66
 
67
67
  /** Exhaustive id → sub-panel map. Typed `Record<InspectorPanelId, …>` so adding a
@@ -13,6 +13,7 @@ import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindo
13
13
  import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
14
14
  import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
15
15
  import FollowUpWindow from '~/components/followUp/FollowUpWindow.vue'
16
+ import BinaryCandidatesWindow from '~/components/binaryCandidates/BinaryCandidatesWindow.vue'
16
17
  import ForkDecisionWindow from '~/components/forkDecision/ForkDecisionWindow.vue'
17
18
  import PrReviewWindow from '~/components/prReview/PrReviewWindow.vue'
18
19
  import MergerResultView from '~/components/panels/MergerResultView.vue'
@@ -77,6 +78,9 @@ const BUILT_IN_RESULT_VIEWS: Record<ResultViewId, Component> = {
77
78
  'follow-ups': FollowUpWindow,
78
79
  // The implementation-fork decision: the proposer's approaches + the human's pick / custom.
79
80
  'fork-decision': ForkDecisionWindow,
81
+ // The generated-candidate comparison: the candidates a generating step staged, side by side,
82
+ // and the human's keep/discard decision (with the alternate ids they assigned).
83
+ 'binary-candidates': BinaryCandidatesWindow,
80
84
  // The PR deep-review: the reviewer's sliced, prioritized findings + the human's multi-select.
81
85
  'pr-review': PrReviewWindow,
82
86
  // The merger's verdict: PR complexity/risk/impact scores + the engine's decision (and why).
@@ -0,0 +1,89 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type { KeepBinaryCandidatesInput } from '@cat-factory/contracts'
4
+ import type { BinaryCandidateStepState } from '~/types/execution'
5
+ import { useApi } from '~/composables/useApi'
6
+ import { useWorkspaceStore } from '~/stores/workspace'
7
+ import { useExecutionStore } from '~/stores/execution'
8
+
9
+ /**
10
+ * The generated-candidate action surface. The live state lives on the run's step
11
+ * (`step.binaryCandidates`) and the execution stream keeps it fresh, so the window reads it
12
+ * straight off the execution store; this store only wraps the `keep` action (plus a warm-up
13
+ * `load`), tracks the in-flight state so the window can disable its controls, and echoes the
14
+ * returned state back so the UI settles without waiting for the stream. Shaped exactly like the
15
+ * fork-decision store, which is the same park one subject over.
16
+ */
17
+ export const useBinaryCandidatesStore = defineStore('binaryCandidates', () => {
18
+ const api = useApi()
19
+ const workspace = useWorkspaceStore()
20
+ const execution = useExecutionStore()
21
+
22
+ /** True while a keep call is in flight (drives the button spinner / disabled state). */
23
+ const keeping = ref(false)
24
+ /** The last error message from an action, surfaced inline; cleared on the next action. */
25
+ const error = ref<string | null>(null)
26
+
27
+ /**
28
+ * Apply an authoritative candidate state to the run's step.
29
+ *
30
+ * A pipeline may carry more than one generating step, so target the step this decision is
31
+ * ABOUT rather than the first that happens to hold candidate state: prefer the one still
32
+ * awaiting a choice, then the current step, and only then any step carrying state. Without
33
+ * that order a run whose earlier generator already settled would have its finished record
34
+ * overwritten by the live one.
35
+ *
36
+ * Only ever called through {@link ExecutionStore.echoAfter}, which drops the echo when the
37
+ * event stream already delivered a newer revision.
38
+ */
39
+ function assign(
40
+ instance: ReturnType<typeof execution.getInstance> & object,
41
+ state: BinaryCandidateStepState,
42
+ ): void {
43
+ const current = instance.steps[instance.currentStep]
44
+ const step =
45
+ instance.steps.find((s) => s.binaryCandidates?.status === 'awaiting_choice') ??
46
+ (current?.binaryCandidates ? current : undefined) ??
47
+ instance.steps.find((s) => s.binaryCandidates)
48
+ if (step) step.binaryCandidates = state
49
+ }
50
+
51
+ /** Warm the live state from the GET (the stream also keeps it fresh). Best-effort. */
52
+ async function load(executionId: string): Promise<void> {
53
+ error.value = null
54
+ try {
55
+ await execution.echoAfter(
56
+ executionId,
57
+ () => api.getBinaryCandidates(workspace.requireId(), executionId),
58
+ (state, instance) => {
59
+ if (state) assign(instance, state as BinaryCandidateStepState)
60
+ },
61
+ )
62
+ } catch (e) {
63
+ error.value = e instanceof Error ? e.message : 'Failed to load'
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Keep the chosen candidates (each with the id it is to be stored under) and discard the rest.
69
+ * The step then re-runs to deliver exactly what survived.
70
+ */
71
+ async function keep(executionId: string, input: KeepBinaryCandidatesInput): Promise<void> {
72
+ error.value = null
73
+ keeping.value = true
74
+ try {
75
+ await execution.echoAfter(
76
+ executionId,
77
+ () => api.keepBinaryCandidates(workspace.requireId(), executionId, input),
78
+ (state, instance) => assign(instance, state as BinaryCandidateStepState),
79
+ )
80
+ } catch (e) {
81
+ error.value = e instanceof Error ? e.message : 'Failed to keep candidates'
82
+ throw e
83
+ } finally {
84
+ keeping.value = false
85
+ }
86
+ }
87
+
88
+ return { keeping, error, load, keep }
89
+ })
@@ -3,9 +3,29 @@ import { useServicesStore } from '~/stores/services'
3
3
  import { useWorkspaceStore } from '~/stores/workspace'
4
4
  import { createBoardDependencies } from './dependencies'
5
5
  import { moveRefusalKey } from './moveRefusal'
6
+ import type { Block } from '~/types/domain'
6
7
  import type { BoardWriteContext } from './context'
7
8
  import { UNDO_WINDOW_MS } from './context'
8
9
 
10
+ /** A field `updateBlock` may patch: the contract's key set, nothing wider. */
11
+ type PatchKey = keyof UpdateBlockInput
12
+ /** The pre-patch values of the fields one call touches, for the rollback. */
13
+ type PatchSnapshot = Partial<Record<PatchKey, unknown>>
14
+
15
+ /**
16
+ * View a block through the patch key set, for the optimistic write's snapshot + rollback.
17
+ *
18
+ * A plain widening, not an assertion: the rollback is inherently keyed by whatever the caller
19
+ * put in the patch, so it has to index the block dynamically, and this bounds that indexing to
20
+ * the contract instead of the `Record<string, unknown>` it used to widen to. Two patch keys
21
+ * (`customTaskTypeFields` / `builtinTaskTypeFields`) are request-only, since the server folds
22
+ * them into the block's `taskTypeFields`, so the view is `Partial`: they read as absent going in
23
+ * and are cleared again by a rollback, which is what the untyped version did.
24
+ */
25
+ function blockAsPatchable(block: Block): PatchSnapshot {
26
+ return block
27
+ }
28
+
9
29
  /**
10
30
  * The board's placement (drag/drop/reparent) and per-block edit operations — the writes that
11
31
  * move a block or patch its fields, including the dependency edges. Extracted from
@@ -194,10 +214,13 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
194
214
  // Snapshot ONLY the fields this patch touches so a rejected write restores them exactly
195
215
  // (a patch may set several at once) rather than leaving a stale optimistic value stuck on
196
216
  // screen with no feedback — the same rollback contract the other mutations here follow.
197
- const prev: Record<string, unknown> = {}
198
- const patchRecord = patch as Record<string, unknown>
199
- const record = b as unknown as Record<string, unknown>
200
- for (const key of Object.keys(patch)) prev[key] = record[key]
217
+ // `Object.keys` is typed `string[]`, so the key set is narrowed to the patch contract once
218
+ // here; every read and write below then goes through `PatchKey`, and a key outside the
219
+ // contract cannot reach the block.
220
+ const keys = Object.keys(patch) as PatchKey[]
221
+ const prev: PatchSnapshot = {}
222
+ const before = blockAsPatchable(b)
223
+ for (const key of keys) prev[key] = before[key]
201
224
  Object.assign(b, patch) // optimistic
202
225
  try {
203
226
  upsert(await api.updateBlock(useWorkspaceStore().requireId(), id, patch))
@@ -206,10 +229,11 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
206
229
  // swaps in a fresh one) while the write was in flight, so `b` can be stale. Only revert
207
230
  // fields that still hold OUR optimistic value, so a newer server value that landed
208
231
  // mid-flight isn't clobbered by the rollback.
209
- const cur = getBlock(id) as unknown as Record<string, unknown> | undefined
210
- if (cur) {
211
- for (const key of Object.keys(patch)) {
212
- if (cur[key] === patchRecord[key]) cur[key] = prev[key]
232
+ const live = getBlock(id)
233
+ if (live) {
234
+ const cur = blockAsPatchable(live)
235
+ for (const key of keys) {
236
+ if (cur[key] === patch[key]) cur[key] = prev[key]
213
237
  }
214
238
  }
215
239
  toast.add({
@@ -164,12 +164,13 @@ export function createUiResultViews() {
164
164
  // The run-scoped openers (a caller that knows only the RUN, so the step index has to be
165
165
  // resolved) live in a sibling module: they share one shape and one hazard, and lifting them out
166
166
  // keeps this factory inside its per-function line budget. Their two seams are bound here.
167
- const { openFollowUps, openForkDecision, openPrReview, openTestEvidence } = createRunStepOpeners({
168
- dispatchStepView: (instanceId, stepIndex) => dispatchStepView(instanceId, stepIndex),
169
- setResultView: (view, instance, stepIndex) => {
170
- resultView.value = { view, blockId: instance.blockId, instanceId: instance.id, stepIndex }
171
- },
172
- })
167
+ const { openFollowUps, openForkDecision, openBinaryCandidates, openPrReview, openTestEvidence } =
168
+ createRunStepOpeners({
169
+ dispatchStepView: (instanceId, stepIndex) => dispatchStepView(instanceId, stepIndex),
170
+ setResultView: (view, instance, stepIndex) => {
171
+ resultView.value = { view, blockId: instance.blockId, instanceId: instance.id, stepIndex }
172
+ },
173
+ })
173
174
 
174
175
  function closeResultView() {
175
176
  resultView.value = null
@@ -209,6 +210,7 @@ export function createUiResultViews() {
209
210
  openInitiativePlanning,
210
211
  openFollowUps,
211
212
  openForkDecision,
213
+ openBinaryCandidates,
212
214
  openPrReview,
213
215
  openTestEvidence,
214
216
  openOutcome,
@@ -93,6 +93,28 @@ export function createRunStepOpeners(deps: RunStepOpenerDeps) {
93
93
  )
94
94
  }
95
95
 
96
+ // Open the generated-candidate comparison window for a run's binary-output step (from the
97
+ // pipeline chip / inspector rail / step overlay). Resolves the step index from the run when not
98
+ // given, preferring the step parked awaiting a choice.
99
+ //
100
+ // Resolved by the CANDIDATE STATE rather than by an agent kind, unlike its neighbours: any kind
101
+ // carrying the `binary-output` trait can run a comparison, and a deployment's own kinds are
102
+ // exactly the ones a hard-coded kind list here would never name.
103
+ function openBinaryCandidates(instanceId: string, stepIndex: number | null = null) {
104
+ withStep(
105
+ instanceId,
106
+ stepIndex,
107
+ (instance) => {
108
+ const awaiting = indexOf(instance, (s) => s.binaryCandidates?.status === 'awaiting_choice')
109
+ if (awaiting >= 0) return awaiting
110
+ const current = instance.steps[instance.currentStep]
111
+ if (current?.binaryCandidates) return instance.currentStep
112
+ return indexOf(instance, (s) => !!s.binaryCandidates)
113
+ },
114
+ (instance, idx) => deps.setResultView('binary-candidates', instance, idx),
115
+ )
116
+ }
117
+
96
118
  // Open the PR deep-review window for a run's `pr-reviewer` step (from the `pr_review_ready`
97
119
  // notification / the step). Resolves the step index from the run when not given, preferring
98
120
  // the step parked awaiting a finding selection.
@@ -139,5 +161,5 @@ export function createRunStepOpeners(deps: RunStepOpenerDeps) {
139
161
  )
140
162
  }
141
163
 
142
- return { openFollowUps, openForkDecision, openPrReview, openTestEvidence }
164
+ return { openFollowUps, openForkDecision, openBinaryCandidates, openPrReview, openTestEvidence }
143
165
  }
@@ -95,6 +95,11 @@ export type {
95
95
  BinaryOutputArtifact,
96
96
  BinaryOutputConfig,
97
97
  BinaryOutputReport,
98
+ // The candidate-comparison set on a step whose selection declares a `comparison`: the staged
99
+ // candidates, the live park state, and the human's keep/discard decision.
100
+ BinaryCandidate,
101
+ BinaryCandidateChoice,
102
+ BinaryCandidateStepState,
98
103
  TesterStepState,
99
104
  HumanTestEnvironment,
100
105
  RunEnvironment,
@@ -0,0 +1,14 @@
1
+ import type { BadgeProps } from '@nuxt/ui'
2
+
3
+ /**
4
+ * The colour names a `UBadge` accepts, derived from the component's own prop type rather
5
+ * than restated as a literal union. Nuxt UI resolves the prop from the app config's badge
6
+ * theme, so deriving keeps a chip map honest if the deployment's palette gains or loses a
7
+ * colour, where a hand-written copy would just drift.
8
+ *
9
+ * A status → chip map types its values against this, which is what lets a `:color="…"`
10
+ * binding pass the value straight through. The maps used to be typed `string`, so every
11
+ * binding needed an `as any` to get past the prop's union; that cast also silently accepted
12
+ * a typo'd colour, which renders as an unstyled badge rather than failing the build.
13
+ */
14
+ export type BadgeColor = NonNullable<BadgeProps['color']>