@cat-factory/app 0.256.3 → 0.258.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/app/components/binaryCandidates/BinaryCandidatesWindow.vue +284 -0
- package/app/components/binaryOutput/BinaryOutputReport.vue +75 -0
- package/app/components/board/AddTaskModal.vue +38 -12
- package/app/components/board/RecurringPipelineModal.vue +24 -12
- package/app/components/board/nodes/TaskCard.vue +34 -7
- package/app/components/forkDecision/ForkDecisionWindow.vue +3 -1
- package/app/components/panels/AgentStepDetail.vue +42 -20
- package/app/components/panels/InspectorPanel.vue +39 -0
- 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 +119 -1
- package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +115 -1
- package/app/components/pipeline/BinaryOutputStepPicker.vue +463 -1
- package/app/components/pipeline/PipelineBuilder.vue +44 -0
- package/app/components/pipeline/PipelinePreview.vue +20 -1
- package/app/components/pipeline/PipelineProgress.vue +50 -0
- package/app/composables/api/binaryCandidates.ts +36 -0
- package/app/composables/api/execution.ts +19 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/usePipelineHealth.spec.ts +42 -5
- package/app/composables/usePipelineHealth.ts +109 -48
- package/app/modular/agent-kinds.ts +5 -0
- package/app/modular/result-views.ts +4 -0
- package/app/stores/binaryCandidates.ts +89 -0
- package/app/stores/environmentWizard/context.ts +0 -2
- package/app/stores/environmentWizard/flow.ts +11 -6
- package/app/stores/environmentWizard.ts +12 -11
- package/app/stores/execution/commands.ts +26 -1
- package/app/stores/pipelines/draftActions.ts +2 -0
- package/app/stores/pipelines/draftStepConfig.ts +4 -161
- package/app/stores/pipelines/draftStepOptions.ts +204 -0
- package/app/stores/ui/resultViews.ts +8 -6
- package/app/stores/ui/runStepOpeners.ts +23 -1
- package/app/types/domain.ts +6 -0
- package/app/types/execution.ts +5 -0
- package/app/utils/agentPalette.spec.ts +26 -0
- package/app/utils/agentPalette.ts +9 -4
- package/app/utils/binaryCandidates.spec.ts +110 -0
- package/app/utils/binaryCandidates.ts +126 -0
- package/app/utils/binaryOutput.spec.ts +122 -1
- package/app/utils/binaryOutput.ts +149 -2
- package/app/utils/catalog.spec.ts +24 -0
- package/app/utils/catalog.ts +21 -4
- package/app/utils/pipeline.spec.ts +35 -3
- package/app/utils/pipeline.ts +54 -3
- package/app/utils/pipelineRender.spec.ts +122 -1
- package/app/utils/pipelineRender.ts +113 -2
- package/i18n/locales/de.json +107 -3
- package/i18n/locales/en.json +107 -3
- package/i18n/locales/es.json +107 -3
- package/i18n/locales/fr.json +107 -3
- package/i18n/locales/he.json +107 -3
- package/i18n/locales/it.json +107 -3
- package/i18n/locales/ja.json +107 -3
- package/i18n/locales/pl.json +107 -3
- package/i18n/locales/tr.json +107 -3
- package/i18n/locales/uk.json +107 -3
- package/i18n/plural-forms.spec.ts +15 -0
- package/package.json +2 -2
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Generated-candidate comparison window: the dedicated surface for a binary-output step that
|
|
3
|
+
// generated several candidates rather than committing to one producer unobserved
|
|
4
|
+
// (docs/initiatives/binary-output-foundational-storage.md).
|
|
5
|
+
//
|
|
6
|
+
// It reads the live state straight off the run's step (`step.binaryCandidates`, kept fresh by the
|
|
7
|
+
// execution stream) and lets a human KEEP one candidate, or several under distinct ids. Keeping
|
|
8
|
+
// re-runs the same step to deliver exactly what survived and clear the rest.
|
|
9
|
+
//
|
|
10
|
+
// Two properties the surface has to hold on to, both of which are the reason this feature exists:
|
|
11
|
+
//
|
|
12
|
+
// - A candidate WITHOUT a preview is still a candidate. An org's asset store may issue no public
|
|
13
|
+
// link, and the platform will not invent one, so such a row renders its details and says the
|
|
14
|
+
// preview is unavailable rather than disappearing or showing a broken image.
|
|
15
|
+
// - It doubles as the RECORD. Once the choice is made the window keeps rendering, marking what
|
|
16
|
+
// was kept and under which id, because the decision is the only place the run's own rationale
|
|
17
|
+
// lives. An AUTOMATIC keep says so: nobody looked at it.
|
|
18
|
+
import { computed, ref, watch } from 'vue'
|
|
19
|
+
import { useResultView } from '~/composables/useResultView'
|
|
20
|
+
import { useExecutionStore } from '~/stores/execution'
|
|
21
|
+
import { useBoardStore } from '~/stores/board'
|
|
22
|
+
import { useBinaryCandidatesStore } from '~/stores/binaryCandidates'
|
|
23
|
+
import {
|
|
24
|
+
BINARY_CANDIDATE_NO_CHOICE_KEYS,
|
|
25
|
+
binaryCandidateHasWarnings,
|
|
26
|
+
binaryCandidateView,
|
|
27
|
+
} from '~/utils/binaryCandidates'
|
|
28
|
+
import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
|
|
29
|
+
|
|
30
|
+
const execution = useExecutionStore()
|
|
31
|
+
const board = useBoardStore()
|
|
32
|
+
const candidates = useBinaryCandidatesStore()
|
|
33
|
+
const access = useWorkspaceAccess()
|
|
34
|
+
const { t } = useI18n()
|
|
35
|
+
|
|
36
|
+
// The warm-up read is keyed by the RUN, not the block: `load` calls
|
|
37
|
+
// `GET /workspaces/:ws/executions/:executionId/binary-candidates`, and a block id put in that
|
|
38
|
+
// position resolves to no run at all, so the fetch quietly returns nothing and the window shows
|
|
39
|
+
// whatever the stream happened to deliver. `instanceId` is nullable (a view can be opened without
|
|
40
|
+
// a resolved run), so the guard is the same one `PrReviewWindow` makes.
|
|
41
|
+
const { open, blockId, instanceId, stepIndex, close } = useResultView('binary-candidates', {
|
|
42
|
+
onOpen: ({ instanceId }) => {
|
|
43
|
+
if (instanceId) void candidates.load(instanceId)
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|
|
48
|
+
const headerTitle = computed(() =>
|
|
49
|
+
block.value
|
|
50
|
+
? t('binaryCandidates.titleWithBlock', { title: block.value.title })
|
|
51
|
+
: t('binaryCandidates.title'),
|
|
52
|
+
)
|
|
53
|
+
const instance = computed(() =>
|
|
54
|
+
instanceId.value === null ? null : (execution.getInstance(instanceId.value) ?? null),
|
|
55
|
+
)
|
|
56
|
+
const step = computed(() => {
|
|
57
|
+
if (instance.value === null || stepIndex.value === null) return null
|
|
58
|
+
return instance.value.steps[stepIndex.value] ?? null
|
|
59
|
+
})
|
|
60
|
+
const view = computed(() => binaryCandidateView(step.value))
|
|
61
|
+
const warnings = computed(() => (view.value ? binaryCandidateHasWarnings(view.value) : false))
|
|
62
|
+
const noChoiceKey = computed(() => {
|
|
63
|
+
const reason = view.value?.state.noChoiceReason
|
|
64
|
+
return reason ? BINARY_CANDIDATE_NO_CHOICE_KEYS[reason] : null
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/** The ids the human has ticked, and the id each is to be stored under. */
|
|
68
|
+
const selected = ref<string[]>([])
|
|
69
|
+
const aliases = ref<Record<string, string>>({})
|
|
70
|
+
const note = ref('')
|
|
71
|
+
|
|
72
|
+
// Default the selection to the first candidate of the first subject whenever the candidate set
|
|
73
|
+
// changes. Something ticked is the honest default for a single-select comparison (the person is
|
|
74
|
+
// choosing between options, not deciding whether to have one) and it keeps the primary button
|
|
75
|
+
// meaningful from the first render.
|
|
76
|
+
watch(
|
|
77
|
+
() => view.value?.state.candidates.map((c) => c.id).join(','),
|
|
78
|
+
() => {
|
|
79
|
+
const first = view.value?.groups[0]?.rows[0]?.id
|
|
80
|
+
if (selected.value.some((id) => view.value?.state.candidates.some((c) => c.id === id))) return
|
|
81
|
+
selected.value = first ? [first] : []
|
|
82
|
+
},
|
|
83
|
+
{ immediate: true },
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
function toggle(id: string): void {
|
|
87
|
+
if (!view.value?.awaiting) return
|
|
88
|
+
if (view.value.multiSelect) {
|
|
89
|
+
selected.value = selected.value.includes(id)
|
|
90
|
+
? selected.value.filter((existing) => existing !== id)
|
|
91
|
+
: [...selected.value, id]
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
selected.value = [id]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Whether the request would be accepted. Mirrors the backend's own refusals rather than only
|
|
99
|
+
* disabling on emptiness: keeping several candidates under one name would store one artifact and
|
|
100
|
+
* report several, so the alias requirement is stated here, where it can be fixed, instead of
|
|
101
|
+
* arriving as a 422.
|
|
102
|
+
*/
|
|
103
|
+
const missingAliases = computed(() =>
|
|
104
|
+
selected.value.length > 1 ? selected.value.filter((id) => !(aliases.value[id] ?? '').trim()) : [],
|
|
105
|
+
)
|
|
106
|
+
const duplicateAliases = computed(() => {
|
|
107
|
+
const used = selected.value.map((id) => (aliases.value[id] ?? '').trim()).filter(Boolean)
|
|
108
|
+
return new Set(used).size !== used.length
|
|
109
|
+
})
|
|
110
|
+
const canKeep = computed(
|
|
111
|
+
() =>
|
|
112
|
+
view.value?.awaiting === true &&
|
|
113
|
+
!candidates.keeping &&
|
|
114
|
+
selected.value.length > 0 &&
|
|
115
|
+
missingAliases.value.length === 0 &&
|
|
116
|
+
!duplicateAliases.value,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async function onKeep() {
|
|
120
|
+
const id = instanceId.value
|
|
121
|
+
if (!id || !canKeep.value) return
|
|
122
|
+
const keep = selected.value.map((candidateId) => {
|
|
123
|
+
const alias = (aliases.value[candidateId] ?? '').trim()
|
|
124
|
+
return alias ? { candidateId, storeAs: alias } : { candidateId }
|
|
125
|
+
})
|
|
126
|
+
const text = note.value.trim()
|
|
127
|
+
await candidates.keep(id, { keep, ...(text ? { note: text } : {}) }).catch(() => {})
|
|
128
|
+
}
|
|
129
|
+
</script>
|
|
130
|
+
|
|
131
|
+
<template>
|
|
132
|
+
<ResultWindowShell
|
|
133
|
+
:open="open"
|
|
134
|
+
icon="i-lucide-images"
|
|
135
|
+
icon-class="bg-sky-500/15 text-sky-300"
|
|
136
|
+
:title="headerTitle"
|
|
137
|
+
:subtitle="t('binaryCandidates.subtitle')"
|
|
138
|
+
width="5xl"
|
|
139
|
+
testid="binary-candidates-window"
|
|
140
|
+
@close="close"
|
|
141
|
+
>
|
|
142
|
+
<div v-if="view" class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
|
143
|
+
<!-- Why there was nothing to choose between. Its own line per reason: a model that never
|
|
144
|
+
declared its candidates and one whose block was unreadable need different fixes. -->
|
|
145
|
+
<p
|
|
146
|
+
v-if="noChoiceKey"
|
|
147
|
+
class="mb-3 rounded border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-200"
|
|
148
|
+
data-testid="binary-candidates-no-choice"
|
|
149
|
+
>
|
|
150
|
+
{{ t(noChoiceKey) }}
|
|
151
|
+
</p>
|
|
152
|
+
|
|
153
|
+
<!-- An automatic keep is NOT a review, and must never render as one. -->
|
|
154
|
+
<p
|
|
155
|
+
v-if="view.automatic"
|
|
156
|
+
class="mb-3 rounded border border-slate-600/40 bg-slate-800/40 px-3 py-2 text-xs text-slate-300"
|
|
157
|
+
data-testid="binary-candidates-automatic"
|
|
158
|
+
>
|
|
159
|
+
{{ t('binaryCandidates.automatic') }}
|
|
160
|
+
</p>
|
|
161
|
+
|
|
162
|
+
<!-- Every loss the parse counted, so a comparison over three of five cannot read as one
|
|
163
|
+
over all five. -->
|
|
164
|
+
<p
|
|
165
|
+
v-if="warnings"
|
|
166
|
+
class="mb-3 text-xs text-amber-300"
|
|
167
|
+
data-testid="binary-candidates-warnings"
|
|
168
|
+
>
|
|
169
|
+
<span v-if="view.state.omitted">{{
|
|
170
|
+
t('binaryCandidates.warning.omitted', { count: view.state.omitted })
|
|
171
|
+
}}</span>
|
|
172
|
+
<span v-if="view.state.invalidEntries">
|
|
173
|
+
{{ t('binaryCandidates.warning.invalid', { count: view.state.invalidEntries }) }}</span
|
|
174
|
+
>
|
|
175
|
+
<span v-if="view.state.unusablePreviews">
|
|
176
|
+
{{ t('binaryCandidates.warning.previews', { count: view.state.unusablePreviews }) }}</span
|
|
177
|
+
>
|
|
178
|
+
</p>
|
|
179
|
+
|
|
180
|
+
<div v-for="group in view.groups" :key="group.subject ?? '·'" class="mb-6">
|
|
181
|
+
<h3 class="mb-2 text-xs font-medium text-slate-400">
|
|
182
|
+
{{ group.subject ?? t('binaryCandidates.unlabelledSubject') }}
|
|
183
|
+
</h3>
|
|
184
|
+
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
185
|
+
<div
|
|
186
|
+
v-for="row in group.rows"
|
|
187
|
+
:key="row.id"
|
|
188
|
+
class="rounded border p-2 transition"
|
|
189
|
+
:class="
|
|
190
|
+
selected.includes(row.id) || row.kept
|
|
191
|
+
? 'border-sky-400/60 bg-sky-500/5'
|
|
192
|
+
: 'border-slate-700/60'
|
|
193
|
+
"
|
|
194
|
+
data-testid="binary-candidate-card"
|
|
195
|
+
@click="toggle(row.id)"
|
|
196
|
+
>
|
|
197
|
+
<img
|
|
198
|
+
v-if="row.previewUrl"
|
|
199
|
+
:src="row.previewUrl"
|
|
200
|
+
:alt="row.label ?? row.id"
|
|
201
|
+
class="mb-2 max-h-56 w-full rounded object-contain"
|
|
202
|
+
data-testid="binary-candidate-preview"
|
|
203
|
+
/>
|
|
204
|
+
<!-- No preview is ORDINARY (a private asset store issues no link), so it is stated
|
|
205
|
+
rather than left as an empty frame the reader reads as a failed generation. -->
|
|
206
|
+
<p
|
|
207
|
+
v-else
|
|
208
|
+
class="mb-2 flex h-24 items-center justify-center rounded bg-slate-800/60 px-2 text-center text-[10px] text-slate-400"
|
|
209
|
+
data-testid="binary-candidate-no-preview"
|
|
210
|
+
>
|
|
211
|
+
{{ t('binaryCandidates.noPreview') }}
|
|
212
|
+
</p>
|
|
213
|
+
<p class="text-xs text-slate-200">
|
|
214
|
+
{{
|
|
215
|
+
row.generator
|
|
216
|
+
? t('binaryCandidates.fromGenerator', { generator: row.generator })
|
|
217
|
+
: t('binaryCandidates.unattributed')
|
|
218
|
+
}}
|
|
219
|
+
</p>
|
|
220
|
+
<p v-if="row.note" class="mt-1 text-[11px] text-slate-400">{{ row.note }}</p>
|
|
221
|
+
<p class="mt-1 break-all text-[10px] text-slate-500">{{ row.location }}</p>
|
|
222
|
+
<p v-if="row.contentType" class="text-[10px] text-slate-500">{{ row.contentType }}</p>
|
|
223
|
+
<p v-if="row.kept" class="mt-1 text-[11px] text-emerald-300">
|
|
224
|
+
{{
|
|
225
|
+
row.storeAs
|
|
226
|
+
? t('binaryCandidates.keptAs', { id: row.storeAs })
|
|
227
|
+
: t('binaryCandidates.kept')
|
|
228
|
+
}}
|
|
229
|
+
</p>
|
|
230
|
+
<!-- The ALTERNATE ID, shown only where it is load-bearing: keeping two candidates
|
|
231
|
+
under one name stores one artifact and reports two. -->
|
|
232
|
+
<UInput
|
|
233
|
+
v-if="view.awaiting && view.multiSelect && selected.includes(row.id)"
|
|
234
|
+
class="mt-2"
|
|
235
|
+
size="xs"
|
|
236
|
+
:model-value="aliases[row.id] ?? ''"
|
|
237
|
+
:placeholder="t('binaryCandidates.storeAsPlaceholder')"
|
|
238
|
+
data-testid="binary-candidate-store-as"
|
|
239
|
+
@click.stop
|
|
240
|
+
@update:model-value="aliases[row.id] = String($event)"
|
|
241
|
+
/>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<div v-if="view.awaiting" class="mt-2">
|
|
247
|
+
<UTextarea
|
|
248
|
+
v-model="note"
|
|
249
|
+
:rows="2"
|
|
250
|
+
size="xs"
|
|
251
|
+
:placeholder="t('binaryCandidates.notePlaceholder')"
|
|
252
|
+
data-testid="binary-candidates-note"
|
|
253
|
+
/>
|
|
254
|
+
<p
|
|
255
|
+
v-if="missingAliases.length"
|
|
256
|
+
class="mt-1 text-[11px] text-amber-300"
|
|
257
|
+
data-testid="binary-candidates-missing-alias"
|
|
258
|
+
>
|
|
259
|
+
{{ t('binaryCandidates.missingAlias') }}
|
|
260
|
+
</p>
|
|
261
|
+
<p
|
|
262
|
+
v-else-if="duplicateAliases"
|
|
263
|
+
class="mt-1 text-[11px] text-amber-300"
|
|
264
|
+
data-testid="binary-candidates-duplicate-alias"
|
|
265
|
+
>
|
|
266
|
+
{{ t('binaryCandidates.duplicateAlias') }}
|
|
267
|
+
</p>
|
|
268
|
+
<p v-if="candidates.error" class="mt-1 text-[11px] text-red-300">{{ candidates.error }}</p>
|
|
269
|
+
<div class="mt-2 flex justify-end">
|
|
270
|
+
<UButton
|
|
271
|
+
size="xs"
|
|
272
|
+
:disabled="!canKeep || !access.canExecuteRuns.value"
|
|
273
|
+
:title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
|
|
274
|
+
:loading="candidates.keeping"
|
|
275
|
+
data-testid="binary-candidates-keep"
|
|
276
|
+
@click="onKeep"
|
|
277
|
+
>
|
|
278
|
+
{{ t('binaryCandidates.keepAction', { count: selected.length }) }}
|
|
279
|
+
</UButton>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
</ResultWindowShell>
|
|
284
|
+
</template>
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { computed } from 'vue'
|
|
15
15
|
import type { PipelineStep } from '~/types/execution'
|
|
16
16
|
import { BINARY_OUTPUT_STATE_KEYS, binaryOutputView } from '~/utils/binaryOutput'
|
|
17
|
+
import { binaryCandidateView } from '~/utils/binaryCandidates'
|
|
17
18
|
import CopyButton from '~/components/common/CopyButton.vue'
|
|
18
19
|
|
|
19
20
|
// Two callers, one renderer — the same split `StepEffortReport` makes: the generic step-detail
|
|
@@ -27,6 +28,16 @@ const { t } = useI18n()
|
|
|
27
28
|
|
|
28
29
|
const view = computed(() => binaryOutputView(props.step))
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The CANDIDATE decision, when this step compared before delivering.
|
|
33
|
+
*
|
|
34
|
+
* Rendered here rather than left to the comparison window, because that window is reachable only
|
|
35
|
+
* while the run is PARKED on it: once the choice is made, a step click routes to the kind's own
|
|
36
|
+
* result view and the record of what was compared, and by whom, would have nowhere to live. This
|
|
37
|
+
* is the same placement rule the artifacts above it follow, one decision earlier.
|
|
38
|
+
*/
|
|
39
|
+
const candidates = computed(() => binaryCandidateView(props.step))
|
|
40
|
+
|
|
30
41
|
/**
|
|
31
42
|
* The outcome's own copy, from the shared exhaustive map — shared so the collapsed section row
|
|
32
43
|
* above this panel and the sentence inside it can never claim different outcomes. `stored`
|
|
@@ -55,6 +66,28 @@ const state = computed(() => {
|
|
|
55
66
|
<span>{{ t('binaryOutput.heading') }}</span>
|
|
56
67
|
</div>
|
|
57
68
|
|
|
69
|
+
<!-- The CANDIDATE decision, when this step compared before delivering. It sits ABOVE the
|
|
70
|
+
delivery outcome because it happened first, and because "which of four was this" is the
|
|
71
|
+
question the artifacts below cannot answer. An AUTOMATIC keep says so: nobody looked. -->
|
|
72
|
+
<p
|
|
73
|
+
v-if="candidates && candidates.state.candidates.length > 0"
|
|
74
|
+
class="text-[12px] leading-relaxed text-slate-300"
|
|
75
|
+
data-testid="binary-output-candidate-decision"
|
|
76
|
+
>
|
|
77
|
+
{{
|
|
78
|
+
candidates.automatic
|
|
79
|
+
? t('binaryOutput.candidates.automatic', { total: candidates.state.candidates.length })
|
|
80
|
+
: candidates.state.choice
|
|
81
|
+
? t('binaryOutput.candidates.chosen', {
|
|
82
|
+
kept: candidates.state.choice.kept.length,
|
|
83
|
+
total: candidates.state.candidates.length,
|
|
84
|
+
})
|
|
85
|
+
: t('binaryOutput.candidates.awaiting', {
|
|
86
|
+
total: candidates.state.candidates.length,
|
|
87
|
+
})
|
|
88
|
+
}}
|
|
89
|
+
</p>
|
|
90
|
+
|
|
58
91
|
<!-- What happened, in one sentence, before any list. Four of the five states have no list
|
|
59
92
|
at all, and the fifth still needs its qualifications read alongside it. -->
|
|
60
93
|
<p
|
|
@@ -153,6 +186,22 @@ const state = computed(() => {
|
|
|
153
186
|
</UBadge>
|
|
154
187
|
<span v-if="row.entity">{{ row.entity }}</span>
|
|
155
188
|
<span v-if="row.contentType" class="font-mono">{{ row.contentType }}</span>
|
|
189
|
+
<!-- What was actually DELIVERED, beside the media type it was delivered as. Rendered
|
|
190
|
+
whenever the artifact reported it, not only on a step that asked for a size: it is
|
|
191
|
+
a recorded fact about the asset, and it is the one the counted warning below is
|
|
192
|
+
made of. Without it that warning gives a number and no way to tell WHICH. -->
|
|
193
|
+
<span v-if="row.dimensions" class="font-mono" data-testid="binary-output-dimensions"
|
|
194
|
+
>{{ row.dimensions.width }}×{{ row.dimensions.height }}</span
|
|
195
|
+
>
|
|
196
|
+
<UBadge
|
|
197
|
+
v-if="row.missized"
|
|
198
|
+
color="warning"
|
|
199
|
+
variant="subtle"
|
|
200
|
+
size="sm"
|
|
201
|
+
data-testid="binary-output-missized-badge"
|
|
202
|
+
>
|
|
203
|
+
{{ t('binaryOutput.missizedBadge') }}
|
|
204
|
+
</UBadge>
|
|
156
205
|
</div>
|
|
157
206
|
<p v-if="row.description" class="mt-1 text-[11px] leading-relaxed text-slate-400">
|
|
158
207
|
{{ row.description }}
|
|
@@ -227,6 +276,32 @@ const state = computed(() => {
|
|
|
227
276
|
)
|
|
228
277
|
}}
|
|
229
278
|
</li>
|
|
279
|
+
<!-- The same judgement one axis over, on the requirement whose whole point is the delivered
|
|
280
|
+
pixels. The two size lines stay apart because an artifact that reported no dimensions
|
|
281
|
+
is not one that came back wrong: only the first can be fixed by asking the step to
|
|
282
|
+
report, and only the second is evidence the asset is unusable. -->
|
|
283
|
+
<li v-if="view.missized && view.requiredSize" data-testid="binary-output-missized">
|
|
284
|
+
{{
|
|
285
|
+
t(
|
|
286
|
+
'binaryOutput.warning.missized',
|
|
287
|
+
{
|
|
288
|
+
count: view.missized,
|
|
289
|
+
width: view.requiredSize.width,
|
|
290
|
+
height: view.requiredSize.height,
|
|
291
|
+
},
|
|
292
|
+
view.missized,
|
|
293
|
+
)
|
|
294
|
+
}}
|
|
295
|
+
</li>
|
|
296
|
+
<li v-if="view.sizeUnreported" data-testid="binary-output-size-unreported">
|
|
297
|
+
{{
|
|
298
|
+
t(
|
|
299
|
+
'binaryOutput.warning.sizeUnreported',
|
|
300
|
+
{ count: view.sizeUnreported },
|
|
301
|
+
view.sizeUnreported,
|
|
302
|
+
)
|
|
303
|
+
}}
|
|
304
|
+
</li>
|
|
230
305
|
<li v-if="view.misdirected" data-testid="binary-output-misdirected-note">
|
|
231
306
|
{{
|
|
232
307
|
t(
|
|
@@ -32,7 +32,11 @@ import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
|
|
|
32
32
|
import { parseConflict } from '~/composables/usePipelineErrorToast'
|
|
33
33
|
import { apiErrorEnvelope } from '~/composables/api/errors'
|
|
34
34
|
import type { ReviewTargetReason } from '@cat-factory/contracts'
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
defaultBuildPipelineId,
|
|
37
|
+
sanitizeDescriptorFields,
|
|
38
|
+
validateDescriptorFields,
|
|
39
|
+
} from '@cat-factory/contracts'
|
|
36
40
|
import { defaultDescriptorValues } from '~/utils/descriptorFields'
|
|
37
41
|
import { pipelineAllowedForManualStart } from '~/utils/pipeline'
|
|
38
42
|
import { buildTaskTypePickerRows } from '~/utils/taskTypePicker'
|
|
@@ -405,18 +409,38 @@ const DEFAULT_PIPELINE_FOR_TYPE: Partial<Record<TaskTypeChoice, string>> = {
|
|
|
405
409
|
document: 'pl_document',
|
|
406
410
|
review: 'pl_review',
|
|
407
411
|
}
|
|
412
|
+
/**
|
|
413
|
+
* The pipeline a task type opens with: a custom type's registered `defaultPipelineId`, else the
|
|
414
|
+
* built-in map — and for an ordinary IMPLEMENTATION task (feature / bug / chore, which the map
|
|
415
|
+
* deliberately does not name), the build rung this interface mode defaults to. Basic mode gets the
|
|
416
|
+
* fixed Standard build, advanced the Adaptive one; `defaultBuildPipelineId` owns that rule so the
|
|
417
|
+
* create form and the task card's plain "Start" cannot disagree about it. Empty when the resolved
|
|
418
|
+
* preset is not in this workspace's library (an older seed, or a retired rung).
|
|
419
|
+
*
|
|
420
|
+
* ONE definition, read by both the type watcher and the open-reset. They used to compute it
|
|
421
|
+
* separately, the reset consulting `DEFAULT_PIPELINE_FOR_TYPE` alone and falling to `''` for every
|
|
422
|
+
* implementation type — so which default a `feature` opened with depended on whether the previous
|
|
423
|
+
* session had left the modal on a DIFFERENT type: same type ⇒ the watcher never fired and the
|
|
424
|
+
* picker opened empty, different type ⇒ it fired (asynchronously, after the reset) and filled it in.
|
|
425
|
+
*/
|
|
426
|
+
function defaultPipelineIdFor(type: TaskTypeChoice): string {
|
|
427
|
+
const custom = customTaskTypes.value.find((tt) => tt.taskType === type)
|
|
428
|
+
const preset =
|
|
429
|
+
custom?.defaultPipelineId ??
|
|
430
|
+
DEFAULT_PIPELINE_FOR_TYPE[type] ??
|
|
431
|
+
defaultBuildPipelineId(uiMode.isAdvanced)
|
|
432
|
+
return pipelines.pipelines.some((p) => p.id === preset) ? preset : ''
|
|
433
|
+
}
|
|
434
|
+
|
|
408
435
|
watch(taskType, (next) => {
|
|
409
436
|
const custom = customTaskTypes.value.find((tt) => tt.taskType === next)
|
|
410
437
|
// A custom type owns a fresh field bag on every switch (its descriptors differ per type), seeded
|
|
411
438
|
// to whatever defaults the new type declares.
|
|
412
439
|
customFieldValues.value = defaultDescriptorValues(custom?.fields ?? [])
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
if (!preset) return
|
|
418
|
-
const match = pipelines.pipelines.find((p) => p.id === preset)
|
|
419
|
-
if (match) pipelineId.value = match.id
|
|
440
|
+
// An unresolvable preset leaves the current selection alone rather than blanking it: a type
|
|
441
|
+
// switch is an edit to a form the user is already filling in, not a reset.
|
|
442
|
+
const preset = defaultPipelineIdFor(next)
|
|
443
|
+
if (preset) pipelineId.value = preset
|
|
420
444
|
})
|
|
421
445
|
|
|
422
446
|
// Task-level agent config contributed by the selected pipeline's agents (e.g. the
|
|
@@ -537,10 +561,12 @@ watch(open, (isOpen) => {
|
|
|
537
561
|
delete docKindFieldValues[key]
|
|
538
562
|
riskPolicyId.value = ''
|
|
539
563
|
modelPresetId.value = ''
|
|
540
|
-
// Seed the pipeline from the (possibly doc-repo-forced) task type's default, so a document
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
|
|
564
|
+
// Seed the pipeline from the (possibly doc-repo-forced) task type's default, so a document repo
|
|
565
|
+
// opens with `pl_document` pre-selected and an ordinary feature with its build rung. Computed
|
|
566
|
+
// through the shared helper rather than relying on the `taskType` watcher above having run: that
|
|
567
|
+
// watcher fires only when the type actually CHANGED (and asynchronously, after this block), so
|
|
568
|
+
// reopening the modal on the type it was last left on would otherwise open the picker empty.
|
|
569
|
+
pipelineId.value = defaultPipelineIdFor(taskType.value)
|
|
544
570
|
agentConfigValues.value = {}
|
|
545
571
|
pendingContext.value = []
|
|
546
572
|
// Seed from a prefill when opened from another surface (e.g. "create task from
|
|
@@ -141,17 +141,29 @@ const selectedPipeline = computed(() => pipelines.getPipeline(pipelineId.value))
|
|
|
141
141
|
// description (and so we know to show the tracker config).
|
|
142
142
|
//
|
|
143
143
|
// Only the pipelines whose SHAPE is specific to one kind of recurring work can be inferred this
|
|
144
|
-
// way
|
|
145
|
-
// ordinary build tail under a recurring name
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
// `scheduleTemplateSchema`.
|
|
149
|
-
const template = computed<ScheduleTemplate>(() =>
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
144
|
+
// way, and `bug-triage` is now the only one: `dep-update` and `tech-debt` were both retired from
|
|
145
|
+
// the catalog (the first was the ordinary build tail under a recurring name, the second that tail
|
|
146
|
+
// behind an audit head), so those schedules now run an ordinary build rung — which is also what
|
|
147
|
+
// every generic schedule runs, so inferring a template from it would mislabel all of them. Both
|
|
148
|
+
// templates survive for an explicit API caller; see `scheduleTemplateSchema`.
|
|
149
|
+
const template = computed<ScheduleTemplate>(() =>
|
|
150
|
+
pipelineId.value === 'pl_bug_triage' ? 'bug-triage' : 'custom',
|
|
151
|
+
)
|
|
152
|
+
/**
|
|
153
|
+
* Whether the picked pipeline FILES a ticket (an enabled `tracker` step), so the schedule's first
|
|
154
|
+
* run has somewhere to file it. Read off the pipeline's SHAPE, exactly as `isBugIntake` below is,
|
|
155
|
+
* rather than off the inferred template: `pl_tech_debt` — the one preset this used to key on — was
|
|
156
|
+
* retired, and what replaces it is a schedule pointed at a pipeline someone composed with an
|
|
157
|
+
* `analysis` + `tracker` head. Keying on the id would have offered the tracker config to exactly
|
|
158
|
+
* the one pipeline that no longer exists, and to none of the pipelines that now do this work.
|
|
159
|
+
*/
|
|
160
|
+
const filesTicket = computed(() => {
|
|
161
|
+
const pipeline = selectedPipeline.value
|
|
162
|
+
if (!pipeline) return false
|
|
163
|
+
return pipeline.agentKinds.some(
|
|
164
|
+
(kind, i) => kind === 'tracker' && pipeline.enabled?.[i] !== false,
|
|
165
|
+
)
|
|
153
166
|
})
|
|
154
|
-
const isTechDebt = computed(() => template.value === 'tech-debt')
|
|
155
167
|
|
|
156
168
|
// A pipeline whose ENABLED steps include `bug-intake` pulls its work from the tracker board, so
|
|
157
169
|
// the intake config is surfaced + required. Mirrors the backend `pipelineHasEnabledBugIntake`
|
|
@@ -343,7 +355,7 @@ async function add() {
|
|
|
343
355
|
try {
|
|
344
356
|
// Persist the tracker selection first when the tech-debt pipeline needs it, so
|
|
345
357
|
// the very first run can file its ticket.
|
|
346
|
-
if (
|
|
358
|
+
if (filesTicket.value && trackerKind.value) {
|
|
347
359
|
await tracker.save({
|
|
348
360
|
tracker: trackerKind.value,
|
|
349
361
|
jiraProjectKey: trackerKind.value === 'jira' ? jiraProjectKey.value.trim() : null,
|
|
@@ -434,7 +446,7 @@ async function add() {
|
|
|
434
446
|
|
|
435
447
|
<RecurringRecurrenceEditor v-if="!onDemand" v-model="recurrence" />
|
|
436
448
|
|
|
437
|
-
<div v-if="
|
|
449
|
+
<div v-if="filesTicket" class="space-y-3 rounded-lg border border-slate-800 p-3">
|
|
438
450
|
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
439
451
|
{{ t('board.recurring.issueTracker') }}
|
|
440
452
|
</p>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { defaultBuildPipelineId } from '@cat-factory/contracts'
|
|
2
3
|
import type { Block } from '~/types/domain'
|
|
3
4
|
import { STATUS_META, MODULE_META, taskTypeMeta } from '~/utils/catalog'
|
|
4
5
|
import { composeRunOutcome, hasOutcomeToShow } from '~/utils/runOutcome'
|
|
@@ -52,6 +53,8 @@ const { start: startConnect } = useDependencyConnect()
|
|
|
52
53
|
const deps = computed(() =>
|
|
53
54
|
(task.value?.dependsOn ?? []).map((id) => board.getBlock(id)).filter((b): b is Block => !!b),
|
|
54
55
|
)
|
|
56
|
+
const uiMode = useUiModeStore()
|
|
57
|
+
|
|
55
58
|
/** Deps that haven't merged yet — these block this task from running. */
|
|
56
59
|
const unmet = computed(() => board.unmetDeps(props.taskId))
|
|
57
60
|
const runnable = computed(() => board.isRunnable(props.taskId))
|
|
@@ -60,12 +63,37 @@ const runnable = computed(() => board.isRunnable(props.taskId))
|
|
|
60
63
|
const { depLabel: labelDep } = useDepLabels()
|
|
61
64
|
const depLabel = (dep: Block) => labelDep(dep, task.value?.parentId)
|
|
62
65
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
)
|
|
66
|
+
/**
|
|
67
|
+
* The pipeline a plain "Start" will use: the task's pinned pipeline, else the build rung this
|
|
68
|
+
* INTERFACE MODE defaults to (`defaultBuildPipelineId` — the fixed Standard build in basic mode,
|
|
69
|
+
* the Adaptive one in advanced). The workspace's positional first pipeline remains the last
|
|
70
|
+
* resort, for a board whose catalog does not carry the rung (an older seed, or a deployment that
|
|
71
|
+
* retired it).
|
|
72
|
+
*
|
|
73
|
+
* A PIN is honoured even when the library holds no row for it, and that branch is the whole reason
|
|
74
|
+
* this returns a descriptor rather than a `Pipeline`. An INTERNAL pipeline is withheld from the
|
|
75
|
+
* library on purpose (the platform starts it on its own behalf, so no picker may offer it), and a
|
|
76
|
+
* task can legitimately be pinned to one — the docs-refresh preset spawns its tasks onto
|
|
77
|
+
* `pl_code_comments`. Resolving that pin through the library alone answers undefined, and the
|
|
78
|
+
* fallback below then starts a FULL BUILD on a comment-only task while the button still reads as
|
|
79
|
+
* an ordinary Start. The fallback chain exists for a task with NO pin; a pin the library cannot
|
|
80
|
+
* show is still the task's answer, and the backend resolves the id for the run.
|
|
81
|
+
*/
|
|
82
|
+
const defaultPipeline = computed<{ id: string; name: string } | undefined>(() => {
|
|
83
|
+
const pinnedId = task.value?.pipelineId
|
|
84
|
+
if (pinnedId) {
|
|
85
|
+
return (
|
|
86
|
+
pipelines.getPipeline(pinnedId) ?? {
|
|
87
|
+
id: pinnedId,
|
|
88
|
+
// The catalog NAME map spans the whole catalog (unlike the versions map), so an internal
|
|
89
|
+
// pin still names itself here; the generic label covers a pin to something this build's
|
|
90
|
+
// catalog does not know at all.
|
|
91
|
+
name: pipelines.catalogNames[pinnedId] ?? t('board.task.pipelineFallback'),
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
return pipelines.getPipeline(defaultBuildPipelineId(uiMode.isAdvanced)) ?? pipelines.pipelines[0]
|
|
96
|
+
})
|
|
69
97
|
|
|
70
98
|
/** The PR the implementer agent opened for this task, if any. */
|
|
71
99
|
const pr = computed(() => task.value?.pullRequest)
|
|
@@ -85,7 +113,6 @@ const prLabel = computed(() =>
|
|
|
85
113
|
* every section says "nothing here" would teach people the surface is empty. A task marked done
|
|
86
114
|
* by hand, with no pull request and no run, is that task.
|
|
87
115
|
*/
|
|
88
|
-
const uiMode = useUiModeStore()
|
|
89
116
|
const outcomeReadable = computed(() => {
|
|
90
117
|
const block = task.value
|
|
91
118
|
if (!block) return false
|
|
@@ -26,7 +26,9 @@ const { t } = useI18n()
|
|
|
26
26
|
// Hybrid: state rides the coder step (like follow-ups), but warm it from the GET on open too.
|
|
27
27
|
// No `stepRef`: this is a pre-run decision, so there's no "restart from here".
|
|
28
28
|
const { open, blockId, instanceId, stepIndex, close } = useResultView('fork-decision', {
|
|
29
|
-
onOpen: ({
|
|
29
|
+
onOpen: ({ instanceId }) => {
|
|
30
|
+
if (instanceId) void forkDecision.load(instanceId)
|
|
31
|
+
},
|
|
30
32
|
})
|
|
31
33
|
|
|
32
34
|
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|