@cat-factory/app 0.257.0 → 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/binaryOutput/BinaryOutputReport.vue +42 -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/panels/InspectorPanel.vue +39 -0
- package/app/components/pipeline/BinaryOutputStepPicker.logic.spec.ts +29 -0
- package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +23 -0
- package/app/components/pipeline/BinaryOutputStepPicker.vue +109 -0
- package/app/components/pipeline/PipelineBuilder.vue +44 -0
- package/app/components/pipeline/PipelinePreview.vue +20 -1
- package/app/components/pipeline/PipelineProgress.vue +13 -0
- package/app/composables/api/execution.ts +19 -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/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/types/domain.ts +6 -0
- package/app/utils/agentPalette.spec.ts +26 -0
- package/app/utils/agentPalette.ts +9 -4
- package/app/utils/binaryOutput.spec.ts +122 -1
- package/app/utils/binaryOutput.ts +101 -0
- 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 +78 -2
- package/app/utils/pipelineRender.ts +43 -0
- package/i18n/locales/de.json +29 -1
- package/i18n/locales/en.json +29 -1
- package/i18n/locales/es.json +29 -1
- package/i18n/locales/fr.json +29 -1
- package/i18n/locales/he.json +29 -1
- package/i18n/locales/it.json +29 -1
- package/i18n/locales/ja.json +29 -1
- package/i18n/locales/pl.json +29 -1
- package/i18n/locales/tr.json +29 -1
- package/i18n/locales/uk.json +29 -1
- package/package.json +2 -2
|
@@ -186,6 +186,22 @@ const state = computed(() => {
|
|
|
186
186
|
</UBadge>
|
|
187
187
|
<span v-if="row.entity">{{ row.entity }}</span>
|
|
188
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>
|
|
189
205
|
</div>
|
|
190
206
|
<p v-if="row.description" class="mt-1 text-[11px] leading-relaxed text-slate-400">
|
|
191
207
|
{{ row.description }}
|
|
@@ -260,6 +276,32 @@ const state = computed(() => {
|
|
|
260
276
|
)
|
|
261
277
|
}}
|
|
262
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>
|
|
263
305
|
<li v-if="view.misdirected" data-testid="binary-output-misdirected-note">
|
|
264
306
|
{{
|
|
265
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
|
|
@@ -7,6 +7,7 @@ import { inspectorPanels } from '~/modular/panels/inspector.logic'
|
|
|
7
7
|
import IconButton from '~/components/common/IconButton.vue'
|
|
8
8
|
import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
|
|
9
9
|
import AgentStopButton from '~/components/board/AgentStopButton.vue'
|
|
10
|
+
import { BLUEPRINT_AGENT_KIND } from '@cat-factory/contracts'
|
|
10
11
|
import { VCS_PROVIDER_ICONS } from '~/utils/vcs'
|
|
11
12
|
|
|
12
13
|
const board = useBoardStore()
|
|
@@ -244,6 +245,22 @@ const runMenu = computed(() => {
|
|
|
244
245
|
]
|
|
245
246
|
})
|
|
246
247
|
|
|
248
|
+
// Mapping a service: one run of the mapping agent against this frame, started by KIND (no
|
|
249
|
+
// pipeline). The button owns only its in-flight state — a refusal is already surfaced as a toast
|
|
250
|
+
// by the command, and the run itself then reports through the ordinary board/run projection, so
|
|
251
|
+
// there is nothing for this component to remember about it afterwards.
|
|
252
|
+
const mappingService = ref(false)
|
|
253
|
+
async function mapService() {
|
|
254
|
+
const id = block.value?.id
|
|
255
|
+
if (!id) return
|
|
256
|
+
mappingService.value = true
|
|
257
|
+
try {
|
|
258
|
+
await execution.startAgentKind(id, BLUEPRINT_AGENT_KIND)
|
|
259
|
+
} finally {
|
|
260
|
+
mappingService.value = false
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
247
264
|
// Delegate to the shared confirm-gated deletion so the button and the keyboard shortcut
|
|
248
265
|
// (Delete/Backspace) follow the exact same prompt + optimistic-delete + rollback path.
|
|
249
266
|
const { deleteBlock, archiveBlock } = useBlockDeletion()
|
|
@@ -514,6 +531,28 @@ const showOriginalDescription = ref(false)
|
|
|
514
531
|
{{ t('panels.inspector.viewRequirements') }}
|
|
515
532
|
</UButton>
|
|
516
533
|
|
|
534
|
+
<!-- service (frame): (re)map the repository into the service → modules blueprint and
|
|
535
|
+
populate the board. A SINGLE-KIND run of the mapping agent, not a pipeline — the
|
|
536
|
+
preset that used to wrap this one step is retired. Needs a linked repo to read, so it
|
|
537
|
+
is disabled (with the reason) until the frame has one. -->
|
|
538
|
+
<UButton
|
|
539
|
+
v-if="isFrame"
|
|
540
|
+
block
|
|
541
|
+
color="neutral"
|
|
542
|
+
variant="soft"
|
|
543
|
+
size="sm"
|
|
544
|
+
icon="i-lucide-map"
|
|
545
|
+
:loading="mappingService"
|
|
546
|
+
:disabled="!serviceRepo || mappingService"
|
|
547
|
+
:title="serviceRepo ? undefined : t('panels.inspector.mapServiceNoRepo')"
|
|
548
|
+
@click="mapService"
|
|
549
|
+
>
|
|
550
|
+
{{ t('panels.inspector.mapService') }}
|
|
551
|
+
</UButton>
|
|
552
|
+
<p v-if="isFrame && !serviceRepo" class="text-[11px] text-slate-500">
|
|
553
|
+
{{ t('panels.inspector.mapServiceNoRepo') }}
|
|
554
|
+
</p>
|
|
555
|
+
|
|
517
556
|
<!-- The level/type-keyed inspector body: the `inspectorPanels` panel group
|
|
518
557
|
(slice 4 of the modular-vue adoption). `<PanelsOutlet>` renders every
|
|
519
558
|
panel whose `when(block)` matches, ordered, with the selected block
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { MAX_BINARY_PIXEL_EXTENT } from '@cat-factory/contracts'
|
|
2
3
|
import {
|
|
4
|
+
formatExtent,
|
|
3
5
|
formatReferenceImages,
|
|
4
6
|
generationControlOffer,
|
|
7
|
+
parseExtent,
|
|
5
8
|
parseMediaTypeRequirement,
|
|
6
9
|
parseReferenceImages,
|
|
7
10
|
sameFormats,
|
|
@@ -91,6 +94,32 @@ describe('parseReferenceImages', () => {
|
|
|
91
94
|
})
|
|
92
95
|
})
|
|
93
96
|
|
|
97
|
+
describe('parseExtent', () => {
|
|
98
|
+
it('accepts exactly what the save boundary accepts', () => {
|
|
99
|
+
expect(parseExtent(' 96 ')).toBe(96)
|
|
100
|
+
expect(parseExtent(String(MAX_BINARY_PIXEL_EXTENT))).toBe(MAX_BINARY_PIXEL_EXTENT)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('refuses everything the schema would refuse, so a typed size is a saveable one', () => {
|
|
104
|
+
// Including ZERO, which is the one that mattered: an unset half written as 0 stored a config
|
|
105
|
+
// the schema rejects, so the step became unsaveable behind an opaque validation error and the
|
|
106
|
+
// untouched field rendered "0" back at whoever had not touched it.
|
|
107
|
+
expect(parseExtent('0')).toBeNull()
|
|
108
|
+
expect(parseExtent('-8')).toBeNull()
|
|
109
|
+
expect(parseExtent('96.5')).toBeNull()
|
|
110
|
+
expect(parseExtent('wide')).toBeNull()
|
|
111
|
+
expect(parseExtent(String(MAX_BINARY_PIXEL_EXTENT + 1))).toBeNull()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('reads a blank field as nothing typed', () => {
|
|
115
|
+
expect(parseExtent('')).toBeNull()
|
|
116
|
+
expect(parseExtent(' ')).toBeNull()
|
|
117
|
+
// And the round trip holds: what a stored size renders as is what parses back to it.
|
|
118
|
+
expect(parseExtent(formatExtent(96))).toBe(96)
|
|
119
|
+
expect(formatExtent(undefined)).toBe('')
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
94
123
|
describe('generationControlOffer', () => {
|
|
95
124
|
const declaring = (...capabilities: string[]) => ({ capabilities }) as never
|
|
96
125
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
binaryReferenceImageSchema,
|
|
3
|
+
MAX_BINARY_PIXEL_EXTENT,
|
|
3
4
|
mediaTypeSchema,
|
|
4
5
|
normalizeMediaType,
|
|
5
6
|
requiredBinaryCapabilities,
|
|
@@ -111,6 +112,28 @@ export function formatReferenceImages(
|
|
|
111
112
|
.join('\n')
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Read one half of an exact output size out of the field, or null when what is typed is not a
|
|
117
|
+
* pixel extent the step could carry.
|
|
118
|
+
*
|
|
119
|
+
* Held to the SAME bounds the schema holds it to, {@link MAX_BINARY_PIXEL_EXTENT} imported rather
|
|
120
|
+
* than repeated, so a number this accepts is one the save accepts. Blank is null like anything
|
|
121
|
+
* else here: the caller's job is to tell "nothing typed yet" from "typed and unusable", and it has
|
|
122
|
+
* the raw text to do it with.
|
|
123
|
+
*/
|
|
124
|
+
export function parseExtent(raw: string): number | null {
|
|
125
|
+
const text = raw.trim()
|
|
126
|
+
if (!text) return null
|
|
127
|
+
const value = Number(text)
|
|
128
|
+
if (!Number.isInteger(value) || value < 1 || value > MAX_BINARY_PIXEL_EXTENT) return null
|
|
129
|
+
return value
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Render a stored extent back into the text its field shows; absent is an empty field. */
|
|
133
|
+
export function formatExtent(value: number | undefined): string {
|
|
134
|
+
return value === undefined ? '' : String(value)
|
|
135
|
+
}
|
|
136
|
+
|
|
114
137
|
/**
|
|
115
138
|
* Build the predicate that decides whether the control for a generation option is OFFERED, given
|
|
116
139
|
* what the step's selected integrations declare and what its stored options already require.
|
|
@@ -29,11 +29,14 @@ import {
|
|
|
29
29
|
type BinaryGeneratorCapability,
|
|
30
30
|
type BinaryModality,
|
|
31
31
|
type BinaryOutputConfig,
|
|
32
|
+
type ConflictingOutputSizeOption,
|
|
32
33
|
} from '@cat-factory/contracts'
|
|
33
34
|
import { binaryOutputPickIssues, type BinaryOutputPickIssue } from '~/utils/binaryOutput'
|
|
34
35
|
import {
|
|
36
|
+
formatExtent,
|
|
35
37
|
formatReferenceImages,
|
|
36
38
|
generationControlOffer,
|
|
39
|
+
parseExtent,
|
|
37
40
|
parseMediaTypeRequirement,
|
|
38
41
|
parseReferenceImages,
|
|
39
42
|
sameFormats,
|
|
@@ -262,12 +265,24 @@ const CAPABILITY_LABELS: Record<BinaryGeneratorCapability, () => string> = {
|
|
|
262
265
|
'negative-prompt': () => t('pipeline.builder.binaryCapability.negative-prompt'),
|
|
263
266
|
seed: () => t('pipeline.builder.binaryCapability.seed'),
|
|
264
267
|
'aspect-ratio': () => t('pipeline.builder.binaryCapability.aspect-ratio'),
|
|
268
|
+
'exact-size': () => t('pipeline.builder.binaryCapability.exact-size'),
|
|
265
269
|
'candidate-batch': () => t('pipeline.builder.binaryCapability.candidate-batch'),
|
|
266
270
|
upscale: () => t('pipeline.builder.binaryCapability.upscale'),
|
|
267
271
|
'transparent-background': () => t('pipeline.builder.binaryCapability.transparent-background'),
|
|
268
272
|
tileable: () => t('pipeline.builder.binaryCapability.tileable'),
|
|
269
273
|
}
|
|
270
274
|
|
|
275
|
+
/**
|
|
276
|
+
* The options that restate a delivered size, named by the FIELD LABEL each one carries in this
|
|
277
|
+
* form, so the refusal points at a control the reader can see. Static literal keys, the
|
|
278
|
+
* enum-keyed-set rule again; the union is closed here (not a persisted vocabulary a stale step
|
|
279
|
+
* could carry a retired member of), so the lookup needs no membership guard.
|
|
280
|
+
*/
|
|
281
|
+
const SIZE_CONFLICT_LABELS: Record<ConflictingOutputSizeOption, () => string> = {
|
|
282
|
+
aspectRatio: () => t('pipeline.builder.binaryAspectRatio'),
|
|
283
|
+
upscale: () => t('pipeline.builder.binaryUpscale'),
|
|
284
|
+
}
|
|
285
|
+
|
|
271
286
|
/**
|
|
272
287
|
* A capability in the reader's language, INCLUDING one this build does not define.
|
|
273
288
|
*
|
|
@@ -322,6 +337,44 @@ function setGeneration(fields: Partial<BinaryGenerationOptions>) {
|
|
|
322
337
|
})
|
|
323
338
|
}
|
|
324
339
|
|
|
340
|
+
/**
|
|
341
|
+
* The size fields' own text, held locally like {@link referenceText} and for the same reason: a
|
|
342
|
+
* control has to render what is being typed, and the step must not store what is being typed.
|
|
343
|
+
*
|
|
344
|
+
* The pair is ONE requirement, so it reaches the step only once BOTH halves read as a pixel
|
|
345
|
+
* extent. Writing the unset half as 0 while the first number is typed would store a value the
|
|
346
|
+
* schema refuses (the minimum is 1), which makes the step unsaveable behind an opaque validation
|
|
347
|
+
* error and renders "0" back into a field nobody touched. A half-entered pair is instead simply
|
|
348
|
+
* not stored, and {@link outputSizeIncomplete} says so where it is being typed.
|
|
349
|
+
*/
|
|
350
|
+
const outputSizeText = ref({
|
|
351
|
+
width: formatExtent(config.value?.generation?.outputSize?.width),
|
|
352
|
+
height: formatExtent(config.value?.generation?.outputSize?.height),
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
watch(
|
|
356
|
+
() => config.value?.generation?.outputSize,
|
|
357
|
+
(size) => {
|
|
358
|
+
outputSizeText.value = { width: formatExtent(size?.width), height: formatExtent(size?.height) }
|
|
359
|
+
},
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
/** One half is filled and the other is not, so nothing is stored and the composer is told why. */
|
|
363
|
+
const outputSizeIncomplete = computed(() => {
|
|
364
|
+
const filled = [outputSizeText.value.width, outputSizeText.value.height].filter(
|
|
365
|
+
(half) => half.trim() !== '',
|
|
366
|
+
)
|
|
367
|
+
return filled.length === 1
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
/** Patch ONE half of the exact output size, committing the pair only once both halves read. */
|
|
371
|
+
function setOutputSize(axis: 'width' | 'height', raw: string) {
|
|
372
|
+
outputSizeText.value = { ...outputSizeText.value, [axis]: raw }
|
|
373
|
+
const width = parseExtent(outputSizeText.value.width)
|
|
374
|
+
const height = parseExtent(outputSizeText.value.height)
|
|
375
|
+
setGeneration({ outputSize: width !== null && height !== null ? { width, height } : undefined })
|
|
376
|
+
}
|
|
377
|
+
|
|
325
378
|
/** The reference-image field's text, and the lines it refused (see `parseReferenceImages`). */
|
|
326
379
|
const referenceText = ref(formatReferenceImages(config.value?.generation?.referenceImages))
|
|
327
380
|
const unusableReferences = ref<string[]>([])
|
|
@@ -582,6 +635,48 @@ const declaredFormats = computed(() => {
|
|
|
582
635
|
/>
|
|
583
636
|
</div>
|
|
584
637
|
|
|
638
|
+
<!-- Two numbers rather than one free-text "WxH": the requirement is a pair of integers and
|
|
639
|
+
a parser over a string is a second place for it to be wrong. Offered only where an
|
|
640
|
+
integration declares `exact-size`, because a bucketed endpoint cannot be asked for
|
|
641
|
+
dimensions at all and a control implying otherwise is the misconception this axis
|
|
642
|
+
exists to remove. -->
|
|
643
|
+
<div v-if="offers('exact-size')" class="flex flex-col gap-1">
|
|
644
|
+
<div class="flex items-center gap-2">
|
|
645
|
+
<span class="text-[10px] text-slate-500">{{
|
|
646
|
+
t('pipeline.builder.binaryOutputSize')
|
|
647
|
+
}}</span>
|
|
648
|
+
<UInput
|
|
649
|
+
class="w-24"
|
|
650
|
+
type="number"
|
|
651
|
+
size="xs"
|
|
652
|
+
:model-value="outputSizeText.width"
|
|
653
|
+
:placeholder="t('pipeline.builder.binaryOutputSizeWidth')"
|
|
654
|
+
data-testid="binary-output-size-width"
|
|
655
|
+
@change="setOutputSize('width', ($event.target as HTMLInputElement).value)"
|
|
656
|
+
/>
|
|
657
|
+
<span class="text-[10px] text-slate-500">×</span>
|
|
658
|
+
<UInput
|
|
659
|
+
class="w-24"
|
|
660
|
+
type="number"
|
|
661
|
+
size="xs"
|
|
662
|
+
:model-value="outputSizeText.height"
|
|
663
|
+
:placeholder="t('pipeline.builder.binaryOutputSizeHeight')"
|
|
664
|
+
data-testid="binary-output-size-height"
|
|
665
|
+
@change="setOutputSize('height', ($event.target as HTMLInputElement).value)"
|
|
666
|
+
/>
|
|
667
|
+
</div>
|
|
668
|
+
<!-- Nothing is stored until both halves read, so the half-entered state is stated where
|
|
669
|
+
it is being typed. Silence there would be a field that looks filled in and a step
|
|
670
|
+
that does not carry the requirement. -->
|
|
671
|
+
<p
|
|
672
|
+
v-if="outputSizeIncomplete"
|
|
673
|
+
class="text-[10px] text-amber-400"
|
|
674
|
+
data-testid="binary-output-size-incomplete"
|
|
675
|
+
>
|
|
676
|
+
{{ t('pipeline.builder.binaryOutputSizeIncomplete') }}
|
|
677
|
+
</p>
|
|
678
|
+
</div>
|
|
679
|
+
|
|
585
680
|
<div v-if="offers('seed')" class="flex items-center gap-2">
|
|
586
681
|
<span class="text-[10px] text-slate-500">{{ t('pipeline.builder.binarySeed') }}</span>
|
|
587
682
|
<UInput
|
|
@@ -786,6 +881,20 @@ const declaredFormats = computed(() => {
|
|
|
786
881
|
})
|
|
787
882
|
}}
|
|
788
883
|
</p>
|
|
884
|
+
<!-- A refusal the SAVE makes on the step's own fields, so it is stated here rather than
|
|
885
|
+
waited for: all three controls are offered together, and the remedy is deleting one of
|
|
886
|
+
two values on this form. -->
|
|
887
|
+
<p
|
|
888
|
+
v-if="has('output_size_ambiguous')"
|
|
889
|
+
class="text-[10px] text-amber-400"
|
|
890
|
+
data-testid="binary-output-size-ambiguous"
|
|
891
|
+
>
|
|
892
|
+
{{
|
|
893
|
+
t('pipeline.builder.binaryOutputSizeAmbiguous', {
|
|
894
|
+
options: pick.conflictingSizeOptions.map((o) => SIZE_CONFLICT_LABELS[o]()).join(', '),
|
|
895
|
+
})
|
|
896
|
+
}}
|
|
897
|
+
</p>
|
|
789
898
|
<!-- ADVISORY, grouped with the other two: an integration that declared no capabilities has
|
|
790
899
|
said only that they are unknown, and every integration registered before this axis
|
|
791
900
|
existed is in exactly that state. Styling it as a refusal would flag most working
|
|
@@ -12,11 +12,20 @@ import BinaryOutputStepPicker from '~/components/pipeline/BinaryOutputStepPicker
|
|
|
12
12
|
import { ESTIMATE_AXES, ESTIMATE_AXIS_FIELD, type EstimateAxis } from '~/utils/estimateGating'
|
|
13
13
|
import { showOverrideField } from '~/utils/uiMode'
|
|
14
14
|
import { narrowPipelineLibrary } from '~/utils/pipelineLibrary'
|
|
15
|
+
import { CONDITION_MARKERS, stepConditionsAt } from '~/utils/pipeline'
|
|
16
|
+
|
|
17
|
+
/** The cycle button's icon per state — the two condition markers, plus the unconditional one. */
|
|
18
|
+
const CONDITION_ICONS = {
|
|
19
|
+
always: 'i-lucide-infinity',
|
|
20
|
+
frontend: CONDITION_MARKERS.frontend.icon,
|
|
21
|
+
backend: CONDITION_MARKERS.backend.icon,
|
|
22
|
+
} as const
|
|
15
23
|
import {
|
|
16
24
|
agentKindMeta,
|
|
17
25
|
companionForProducer,
|
|
18
26
|
isConsensusEligibleKind,
|
|
19
27
|
isTesterKind,
|
|
28
|
+
mayCarrySkipAxis,
|
|
20
29
|
} from '~/utils/catalog'
|
|
21
30
|
import type { ConsensusStrategy } from '~/types/consensus'
|
|
22
31
|
|
|
@@ -611,6 +620,31 @@ async function clone(p: Pipeline) {
|
|
|
611
620
|
"
|
|
612
621
|
@click="toggleEnabled(unit)"
|
|
613
622
|
/>
|
|
623
|
+
<!-- Run condition: restrict this step to tasks that change a frontend
|
|
624
|
+
service, or to tasks that change anything else. Cycles through the three
|
|
625
|
+
states (see `cycleDraftStepCondition`). Shown in BOTH interface tiers, and
|
|
626
|
+
not behind `showOverrideField`: cloning a built-in carries the tester pair's
|
|
627
|
+
conditions in, so a control that hid them by default would leave a basic-mode
|
|
628
|
+
editor saving a step whose "when does this run" they were never shown.
|
|
629
|
+
|
|
630
|
+
Offered only where the step MAY be skipped at all. A condition is a skip axis,
|
|
631
|
+
so the engine holds it to the same gatability rule as an estimate gate
|
|
632
|
+
(`assertValidRunConditions`) — without this the builder invited a condition on
|
|
633
|
+
`merger` and answered the save with a 422. -->
|
|
634
|
+
<UButton
|
|
635
|
+
v-if="mayCarrySkipAxis(unit.kind)"
|
|
636
|
+
:icon="CONDITION_ICONS[pipelines.draftStepCondition(unit.index) ?? 'always']"
|
|
637
|
+
:color="pipelines.draftStepCondition(unit.index) ? 'info' : 'neutral'"
|
|
638
|
+
variant="ghost"
|
|
639
|
+
size="xs"
|
|
640
|
+
:title="
|
|
641
|
+
t(
|
|
642
|
+
`pipeline.builder.condition.${pipelines.draftStepCondition(unit.index) ?? 'always'}`,
|
|
643
|
+
)
|
|
644
|
+
"
|
|
645
|
+
data-testid="pipeline-step-condition"
|
|
646
|
+
@click="pipelines.cycleDraftStepCondition(unit.index)"
|
|
647
|
+
/>
|
|
614
648
|
<!-- Approval gate: pause after this step so a human reviews (and
|
|
615
649
|
can edit) its proposal before the next step runs. -->
|
|
616
650
|
<UButton
|
|
@@ -1308,6 +1342,16 @@ async function clone(p: Pipeline) {
|
|
|
1308
1342
|
i + 1
|
|
1309
1343
|
}}</span>
|
|
1310
1344
|
<AgentKindIcon :kind="k" show-label />
|
|
1345
|
+
<!-- A step that does not run on every task says so HERE, in the library, because
|
|
1346
|
+
this list is what a reader compares two pipelines by: a preset whose testers
|
|
1347
|
+
are conditional and one whose testers always run look identical otherwise. -->
|
|
1348
|
+
<UIcon
|
|
1349
|
+
v-for="c in stepConditionsAt(p, i)"
|
|
1350
|
+
:key="c"
|
|
1351
|
+
:name="CONDITION_MARKERS[c].icon"
|
|
1352
|
+
class="h-3 w-3 shrink-0 text-sky-400"
|
|
1353
|
+
:title="t(CONDITION_MARKERS[c].key)"
|
|
1354
|
+
/>
|
|
1311
1355
|
</li>
|
|
1312
1356
|
</ol>
|
|
1313
1357
|
</li>
|