@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.
Files changed (59) hide show
  1. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +284 -0
  2. package/app/components/binaryOutput/BinaryOutputReport.vue +75 -0
  3. package/app/components/board/AddTaskModal.vue +38 -12
  4. package/app/components/board/RecurringPipelineModal.vue +24 -12
  5. package/app/components/board/nodes/TaskCard.vue +34 -7
  6. package/app/components/forkDecision/ForkDecisionWindow.vue +3 -1
  7. package/app/components/panels/AgentStepDetail.vue +42 -20
  8. package/app/components/panels/InspectorPanel.vue +39 -0
  9. package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
  10. package/app/components/panels/inspector/TaskExecution.vue +34 -34
  11. package/app/components/pipeline/BinaryOutputStepPicker.logic.spec.ts +119 -1
  12. package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +115 -1
  13. package/app/components/pipeline/BinaryOutputStepPicker.vue +463 -1
  14. package/app/components/pipeline/PipelineBuilder.vue +44 -0
  15. package/app/components/pipeline/PipelinePreview.vue +20 -1
  16. package/app/components/pipeline/PipelineProgress.vue +50 -0
  17. package/app/composables/api/binaryCandidates.ts +36 -0
  18. package/app/composables/api/execution.ts +19 -0
  19. package/app/composables/useApi.ts +2 -0
  20. package/app/composables/usePipelineHealth.spec.ts +42 -5
  21. package/app/composables/usePipelineHealth.ts +109 -48
  22. package/app/modular/agent-kinds.ts +5 -0
  23. package/app/modular/result-views.ts +4 -0
  24. package/app/stores/binaryCandidates.ts +89 -0
  25. package/app/stores/environmentWizard/context.ts +0 -2
  26. package/app/stores/environmentWizard/flow.ts +11 -6
  27. package/app/stores/environmentWizard.ts +12 -11
  28. package/app/stores/execution/commands.ts +26 -1
  29. package/app/stores/pipelines/draftActions.ts +2 -0
  30. package/app/stores/pipelines/draftStepConfig.ts +4 -161
  31. package/app/stores/pipelines/draftStepOptions.ts +204 -0
  32. package/app/stores/ui/resultViews.ts +8 -6
  33. package/app/stores/ui/runStepOpeners.ts +23 -1
  34. package/app/types/domain.ts +6 -0
  35. package/app/types/execution.ts +5 -0
  36. package/app/utils/agentPalette.spec.ts +26 -0
  37. package/app/utils/agentPalette.ts +9 -4
  38. package/app/utils/binaryCandidates.spec.ts +110 -0
  39. package/app/utils/binaryCandidates.ts +126 -0
  40. package/app/utils/binaryOutput.spec.ts +122 -1
  41. package/app/utils/binaryOutput.ts +149 -2
  42. package/app/utils/catalog.spec.ts +24 -0
  43. package/app/utils/catalog.ts +21 -4
  44. package/app/utils/pipeline.spec.ts +35 -3
  45. package/app/utils/pipeline.ts +54 -3
  46. package/app/utils/pipelineRender.spec.ts +122 -1
  47. package/app/utils/pipelineRender.ts +113 -2
  48. package/i18n/locales/de.json +107 -3
  49. package/i18n/locales/en.json +107 -3
  50. package/i18n/locales/es.json +107 -3
  51. package/i18n/locales/fr.json +107 -3
  52. package/i18n/locales/he.json +107 -3
  53. package/i18n/locales/it.json +107 -3
  54. package/i18n/locales/ja.json +107 -3
  55. package/i18n/locales/pl.json +107 -3
  56. package/i18n/locales/tr.json +107 -3
  57. package/i18n/locales/uk.json +107 -3
  58. package/i18n/plural-forms.spec.ts +15 -0
  59. 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 { sanitizeDescriptorFields, validateDescriptorFields } from '@cat-factory/contracts'
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
- // Pre-select the type's default pipeline: a custom type's registered `defaultPipelineId`, else
414
- // the built-in map. (For a custom type with no default, `BoardService` applies the registry
415
- // default at creation, so leaving the picker unset is fine.)
416
- const preset = custom?.defaultPipelineId ?? DEFAULT_PIPELINE_FOR_TYPE[next]
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
- // repo opens with `pl_document` pre-selected rather than empty. This runs AFTER the `taskType`
542
- // watcher fired during this reset, so it is the authoritative default (see DEFAULT_PIPELINE_FOR_TYPE).
543
- pipelineId.value = DEFAULT_PIPELINE_FOR_TYPE[taskType.value] ?? ''
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. `dep-update` no longer can: its pipeline was retired in the catalog collapse (it was the
145
- // ordinary build tail under a recurring name), so a dependency-update schedule now runs an ordinary
146
- // build rung which is also what every generic schedule runs, so inferring the template from it
147
- // would mislabel all of them. The template itself survives for an explicit API caller; see
148
- // `scheduleTemplateSchema`.
149
- const template = computed<ScheduleTemplate>(() => {
150
- if (pipelineId.value === 'pl_tech_debt') return 'tech-debt'
151
- if (pipelineId.value === 'pl_bug_triage') return 'bug-triage'
152
- return 'custom'
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 (isTechDebt.value && trackerKind.value) {
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="isTechDebt" class="space-y-3 rounded-lg border border-slate-800 p-3">
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
- /** The pipeline a plain "Start" will use: the task's pinned pipeline, else the first. */
64
- const defaultPipeline = computed(
65
- () =>
66
- (task.value?.pipelineId ? pipelines.getPipeline(task.value.pipelineId) : undefined) ??
67
- pipelines.pipelines[0],
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: ({ blockId }) => void forkDecision.load(blockId),
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))