@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.
- package/README.md +4 -0
- package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +284 -0
- package/app/components/binaryOutput/BinaryOutputReport.vue +33 -0
- package/app/components/board/nodes/BlockNode.vue +3 -7
- package/app/components/board/nodes/InitiativeCard.vue +1 -1
- package/app/components/focus/BlockFocusView.vue +1 -1
- package/app/components/forkDecision/ForkDecisionWindow.vue +3 -1
- package/app/components/outcome/OutcomeSummaryWindow.vue +1 -2
- package/app/components/panels/AgentStepDetail.vue +42 -20
- package/app/components/panels/InspectorPanel.vue +6 -11
- package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
- package/app/components/panels/inspector/TaskExecution.vue +34 -34
- package/app/components/pipeline/BinaryOutputStepPicker.logic.spec.ts +90 -1
- package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +92 -1
- package/app/components/pipeline/BinaryOutputStepPicker.vue +354 -1
- package/app/components/pipeline/PipelineProgress.vue +48 -10
- package/app/components/settings/KubernetesEngineForm.vue +98 -46
- package/app/components/spec/ServiceSpecWindow.vue +5 -4
- package/app/composables/api/binaryCandidates.ts +36 -0
- package/app/composables/useApi.ts +2 -0
- package/app/docs/architecture.md +1 -1
- package/app/modular/panels/inspector.ts +3 -3
- package/app/modular/result-views.ts +4 -0
- package/app/stores/binaryCandidates.ts +89 -0
- package/app/stores/board/placement.ts +32 -8
- package/app/stores/ui/resultViews.ts +8 -6
- package/app/stores/ui/runStepOpeners.ts +23 -1
- package/app/types/execution.ts +5 -0
- package/app/utils/badge.ts +14 -0
- package/app/utils/binaryCandidates.spec.ts +110 -0
- package/app/utils/binaryCandidates.ts +126 -0
- package/app/utils/binaryOutput.ts +48 -2
- package/app/utils/catalog.ts +2 -1
- package/app/utils/initiative.ts +1 -4
- package/app/utils/pipelineRender.spec.ts +46 -1
- package/app/utils/pipelineRender.ts +70 -2
- package/i18n/locales/de.json +78 -2
- package/i18n/locales/en.json +78 -2
- package/i18n/locales/es.json +78 -2
- package/i18n/locales/fr.json +78 -2
- package/i18n/locales/he.json +78 -2
- package/i18n/locales/it.json +78 -2
- package/i18n/locales/ja.json +78 -2
- package/i18n/locales/pl.json +78 -2
- package/i18n/locales/tr.json +78 -2
- package/i18n/locales/uk.json +78 -2
- package/i18n/plural-forms.spec.ts +15 -0
- 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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 =
|
|
110
|
-
form.imageTemplate =
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
form
|
|
116
|
-
|
|
117
|
-
form
|
|
118
|
-
form
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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 }
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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),
|
package/app/docs/architecture.md
CHANGED
|
@@ -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
|
-
[
|
|
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).
|
|
8
|
-
*
|
|
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
|
-
})
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
210
|
-
if (
|
|
211
|
-
|
|
212
|
-
|
|
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 } =
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
}
|
package/app/types/execution.ts
CHANGED
|
@@ -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']>
|