@cat-factory/app 0.216.0 → 0.217.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -1
- package/app/components/board/AddTaskModal.vue +54 -78
- package/app/components/board/CreateInitiativeModal.vue +8 -6
- package/app/components/{board/InitiativePresetFields.vue → common/DescriptorFields.vue} +57 -63
- package/app/components/inputGate/InputGateNotice.vue +1 -1
- package/app/components/panels/AgentStepDetail.vue +2 -2
- package/app/components/panels/inspector/ServiceTestConfig.vue +1 -1
- package/app/components/panels/inspector/TaskExecution.vue +2 -2
- package/app/components/settings/WorkspaceSettingsPanel.vue +1 -1
- package/app/composables/api/inputGate.ts +1 -1
- package/app/modular/nav-contributions.spec.ts +1 -1
- package/app/modular/nav-contributions.ts +1 -1
- package/app/stores/inputGate.ts +1 -1
- package/app/stores/ui/resultViews.ts +1 -1
- package/app/types/domain.ts +6 -0
- package/app/utils/descriptorFields.spec.ts +126 -0
- package/app/utils/descriptorFields.ts +104 -0
- package/app/utils/initiative.ts +0 -30
- package/app/utils/inputGate.ts +1 -1
- package/app/utils/pipelineRender.spec.ts +1 -1
- package/app/utils/pipelineRender.ts +1 -1
- package/i18n/locales/de.json +3 -2
- package/i18n/locales/en.json +6 -2
- package/i18n/locales/es.json +18 -5
- package/i18n/locales/fr.json +18 -5
- package/i18n/locales/he.json +4 -2
- package/i18n/locales/it.json +3 -2
- package/i18n/locales/ja.json +3 -2
- package/i18n/locales/pl.json +18 -5
- package/i18n/locales/tr.json +3 -2
- package/i18n/locales/uk.json +18 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -79,6 +79,29 @@ earlier than before takes the entire SPA down at boot, and the unit suite cannot
|
|
|
79
79
|
(nothing there installs the plugin). Every e2e spec does, because every one of them boots
|
|
80
80
|
the app.
|
|
81
81
|
|
|
82
|
+
### A backend-DECLARED form renders through `DescriptorFields.vue`
|
|
83
|
+
|
|
84
|
+
When the backend declares the fields and the SPA only collects them, render them with the shared
|
|
85
|
+
`components/common/DescriptorFields.vue` over the contracts vocabulary
|
|
86
|
+
(`contracts/src/form-fields.ts`), and never hand-roll a second renderer for the same shapes. Two
|
|
87
|
+
surfaces use it: an initiative preset's create form and a reusable operation's per-case form on a
|
|
88
|
+
custom task type (`AddTaskModal`). Adding a third is a `:fields` binding, not a component.
|
|
89
|
+
|
|
90
|
+
Four rules travel with it. **Validate with the shared `validateDescriptorFields`** so the submit
|
|
91
|
+
button reflects exactly what the server will refuse, and **submit the shared
|
|
92
|
+
`sanitizeDescriptorFields` result** so a stale answer on a since-hidden `showWhen` field never
|
|
93
|
+
reaches the wire. **The labels are deployment-authored English rendered verbatim**: only the chrome
|
|
94
|
+
around them (a path-invalid message, section captions) is i18n, so no descriptor string enters a
|
|
95
|
+
locale catalog. And **the value-bag rules live in `utils/descriptorFields.ts`, not in the SFC**
|
|
96
|
+
(`defaultDescriptorValues` for the initial values, `setDescriptorValue` / `setDescriptorCheckbox` /
|
|
97
|
+
`toggleDescriptorGroupValue` for one edit): what an edit freezes on an entity is what a unit test
|
|
98
|
+
must be able to reach, and a rule inside a component is only reachable by mounting one.
|
|
99
|
+
|
|
100
|
+
Mirroring the server's check leaves one refusal still reachable, deliberately: the deployment can
|
|
101
|
+
re-register the descriptor while the dialog sits open, so a create can come back `422` with
|
|
102
|
+
`details.reason: 'task_type_fields_invalid'`. Map it to translated copy like any other reason
|
|
103
|
+
(`AddTaskModal`'s `createRefusalMessage`) rather than showing the server's field-key prose.
|
|
104
|
+
|
|
82
105
|
### Always import a layer component explicitly
|
|
83
106
|
|
|
84
107
|
**Import a component under `components/` by path before using it in a template.** Do not lean on Nuxt's auto-registration. This layer sets no `components` config, so the default `pathPrefix: true` applies and a component is registered under its path-prefixed name: `components/panels/StepEffortReport.vue` becomes `PanelsStepEffortReport`, and a bare `<StepEffortReport>` matches nothing.
|
|
@@ -135,7 +158,7 @@ hoc where it can be avoided:
|
|
|
135
158
|
and the deployment-wide operator + reports rollups).
|
|
136
159
|
|
|
137
160
|
Sole-route items stay in basic when the delivery loop runs on them: the pipeline builder,
|
|
138
|
-
add-from-repo, the fragment library, the infrastructure/
|
|
161
|
+
add-from-repo, the fragment library, the infrastructure/ephemeral-env windows, and the workspace /
|
|
139
162
|
model configuration a run actually reads. `nav-contributions.spec.ts` pins the advanced set
|
|
140
163
|
against a table naming each item's kind and reason, so promoting one forces that claim to be
|
|
141
164
|
written down rather than assumed.
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// (see useContextLinking) — the same context the agents see for every step of the run.
|
|
16
16
|
import type {
|
|
17
17
|
CreateTaskType,
|
|
18
|
+
DescriptorFieldValues,
|
|
18
19
|
DocKind,
|
|
19
20
|
DocKindFieldKey,
|
|
20
21
|
TaskSourceKind,
|
|
@@ -25,11 +26,14 @@ import { resolveComponentRegistry } from '@modular-vue/core'
|
|
|
25
26
|
import { useReactiveSlots } from '@modular-vue/runtime'
|
|
26
27
|
import type { AppSlots, ResultViewContribution } from '~/modular/slots'
|
|
27
28
|
import ContextAttachmentFields from '~/components/context/ContextAttachmentFields.vue'
|
|
29
|
+
import DescriptorFields from '~/components/common/DescriptorFields.vue'
|
|
28
30
|
import FragmentSelector from '~/components/fragments/FragmentSelector.vue'
|
|
29
31
|
import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
|
|
30
32
|
import { parseConflict } from '~/composables/usePipelineErrorToast'
|
|
31
33
|
import { apiErrorEnvelope } from '~/composables/api/errors'
|
|
32
34
|
import type { ReviewTargetReason } from '@cat-factory/contracts'
|
|
35
|
+
import { sanitizeDescriptorFields, validateDescriptorFields } from '@cat-factory/contracts'
|
|
36
|
+
import { defaultDescriptorValues } from '~/utils/descriptorFields'
|
|
33
37
|
import { pipelineAllowedForManualStart } from '~/utils/pipeline'
|
|
34
38
|
|
|
35
39
|
const ui = useUiStore()
|
|
@@ -155,8 +159,9 @@ const selectedCustomType = computed(() =>
|
|
|
155
159
|
customTaskTypes.value.find((tt) => tt.taskType === taskType.value),
|
|
156
160
|
)
|
|
157
161
|
// Descriptor-field values for a selected custom type (or a bespoke form panel's own bag), folded
|
|
158
|
-
// into `taskTypeFields.custom` on submit.
|
|
159
|
-
|
|
162
|
+
// into `taskTypeFields.custom` on submit. Re-seeded to the descriptor's own defaults when the type
|
|
163
|
+
// changes / the modal reopens (its fields differ per type, so a carried-over bag would be foreign).
|
|
164
|
+
const customFieldValues = ref<DescriptorFieldValues>({})
|
|
160
165
|
// A bespoke create-form section paired to the custom type's `formPanel` id via the
|
|
161
166
|
// `taskTypeFormPanels` slot; shown INSTEAD of the descriptor fields. Unpaired ⇒ descriptor fields
|
|
162
167
|
// (degrade, never crash) — the same pairing shape as the result-view windows.
|
|
@@ -168,19 +173,15 @@ const customFormPanel = computed(() => {
|
|
|
168
173
|
const id = selectedCustomType.value?.formPanel
|
|
169
174
|
return id ? (formPanelRegistry.value.get(id) ?? null) : null
|
|
170
175
|
})
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
const
|
|
176
|
+
// Client-side mirror of the server's creation check (the SAME shared function `BoardService` runs),
|
|
177
|
+
// so the submit button reflects an invalid form: a missing required answer, a value outside its
|
|
178
|
+
// declared options, an over-long string. Only the descriptor path is checked up front, since a
|
|
179
|
+
// bespoke `formPanel` owns its own validation and the platform cannot read its required semantics.
|
|
180
|
+
// The per-field path error is rendered inline by `DescriptorFields`.
|
|
181
|
+
const customFieldProblems = computed(() => {
|
|
177
182
|
const custom = selectedCustomType.value
|
|
178
|
-
if (!custom || customFormPanel.value) return
|
|
179
|
-
return (custom.fields ?? []
|
|
180
|
-
if (!field.required) return true
|
|
181
|
-
const raw = customFieldValues.value[field.key]
|
|
182
|
-
return raw !== undefined && String(raw).trim() !== ''
|
|
183
|
-
})
|
|
183
|
+
if (!custom || customFormPanel.value) return []
|
|
184
|
+
return validateDescriptorFields(custom.fields ?? [], customFieldValues.value)
|
|
184
185
|
})
|
|
185
186
|
// The type picker: the built-in choices (i18n labels) + the custom types (their wire presentation).
|
|
186
187
|
const typeChoices = computed<{ value: TaskTypeChoice; label: string; icon: string }[]>(() => [
|
|
@@ -258,28 +259,21 @@ const DOC_FIELD_PLACEHOLDER_KEYS: Record<DocKindFieldKey, string> = {
|
|
|
258
259
|
const SEVERITIES = ['low', 'medium', 'high', 'critical'] as const
|
|
259
260
|
|
|
260
261
|
// A CUSTOM (deployment-registered) task type: fold the collected values into the sparse
|
|
261
|
-
// `taskTypeFields.custom` bag. A bespoke form panel owns the whole bag (taken verbatim
|
|
262
|
-
// descriptor path
|
|
262
|
+
// `taskTypeFields.custom` bag. A bespoke form panel owns the whole bag (taken verbatim, minus blank
|
|
263
|
+
// entries); the descriptor path sends the SANITIZED subset (declared, currently-visible fields), so
|
|
264
|
+
// a stale answer on a since-hidden `showWhen` field never reaches the wire. The renderer already
|
|
265
|
+
// keeps each value in its contract shape, so nothing needs coercing here.
|
|
263
266
|
function buildCustomTypeFields(): TaskTypeFields | undefined {
|
|
264
267
|
const custom = selectedCustomType.value
|
|
265
268
|
if (!custom) return undefined
|
|
266
|
-
|
|
269
|
+
let bag: DescriptorFieldValues
|
|
267
270
|
if (customFormPanel.value) {
|
|
271
|
+
bag = {}
|
|
268
272
|
for (const [key, value] of Object.entries(customFieldValues.value)) {
|
|
269
273
|
if (value !== undefined && value !== '') bag[key] = value
|
|
270
274
|
}
|
|
271
275
|
} else {
|
|
272
|
-
|
|
273
|
-
const raw = customFieldValues.value[field.key]
|
|
274
|
-
if (raw === undefined || raw === '') continue
|
|
275
|
-
if (field.type === 'number') {
|
|
276
|
-
// Skip a non-numeric value rather than sending NaN (which serialises to null on the wire).
|
|
277
|
-
const n = Number(raw)
|
|
278
|
-
if (Number.isFinite(n)) bag[field.key] = n
|
|
279
|
-
} else {
|
|
280
|
-
bag[field.key] = raw
|
|
281
|
-
}
|
|
282
|
-
}
|
|
276
|
+
bag = sanitizeDescriptorFields(custom.fields ?? [], customFieldValues.value)
|
|
283
277
|
}
|
|
284
278
|
return Object.keys(bag).length ? { custom: bag } : undefined
|
|
285
279
|
}
|
|
@@ -416,12 +410,13 @@ const DEFAULT_PIPELINE_FOR_TYPE: Partial<Record<TaskTypeChoice, string>> = {
|
|
|
416
410
|
review: 'pl_review',
|
|
417
411
|
}
|
|
418
412
|
watch(taskType, (next) => {
|
|
419
|
-
|
|
420
|
-
|
|
413
|
+
const custom = customTaskTypes.value.find((tt) => tt.taskType === next)
|
|
414
|
+
// A custom type owns a fresh field bag on every switch (its descriptors differ per type), seeded
|
|
415
|
+
// to whatever defaults the new type declares.
|
|
416
|
+
customFieldValues.value = defaultDescriptorValues(custom?.fields ?? [])
|
|
421
417
|
// Pre-select the type's default pipeline: a custom type's registered `defaultPipelineId`, else
|
|
422
418
|
// the built-in map. (For a custom type with no default, `BoardService` applies the registry
|
|
423
419
|
// default at creation, so leaving the picker unset is fine.)
|
|
424
|
-
const custom = customTaskTypes.value.find((tt) => tt.taskType === next)
|
|
425
420
|
const preset = custom?.defaultPipelineId ?? DEFAULT_PIPELINE_FOR_TYPE[next]
|
|
426
421
|
if (!preset) return
|
|
427
422
|
const match = pipelines.pipelines.find((p) => p.id === preset)
|
|
@@ -526,6 +521,9 @@ watch(open, (isOpen) => {
|
|
|
526
521
|
docOutlineHints.value = ''
|
|
527
522
|
reviewPrRef.value = ''
|
|
528
523
|
reviewFocus.value = ''
|
|
524
|
+
// Empty rather than default-seeded: `taskType` was just reset to a BUILT-IN above, which
|
|
525
|
+
// declares no descriptor fields. Picking a custom type from here runs the `taskType` watcher,
|
|
526
|
+
// and that is the one place the new type's declared defaults are seeded.
|
|
529
527
|
customFieldValues.value = {}
|
|
530
528
|
// Pre-seed the best-practice fragments from the enclosing service's standards, so a new task
|
|
531
529
|
// ships with its service's fragments already selected (and freely add/removable here). The task
|
|
@@ -610,8 +608,8 @@ const canAdd = computed(() => {
|
|
|
610
608
|
configValue(RALPH_VALIDATION_COMMAND_ID, '').trim().length === 0
|
|
611
609
|
)
|
|
612
610
|
return false
|
|
613
|
-
// A custom type's
|
|
614
|
-
if (
|
|
611
|
+
// A custom type's collected form must satisfy its descriptor (the same rule the server enforces).
|
|
612
|
+
if (customFieldProblems.value.length > 0) return false
|
|
615
613
|
return true
|
|
616
614
|
})
|
|
617
615
|
|
|
@@ -684,7 +682,7 @@ async function submitCreate(acknowledgeReviewDebt: boolean) {
|
|
|
684
682
|
}
|
|
685
683
|
toast.add({
|
|
686
684
|
title: t('board.addTask.addFailedTitle'),
|
|
687
|
-
description:
|
|
685
|
+
description: createRefusalMessage(e) ?? (e instanceof Error ? e.message : String(e)),
|
|
688
686
|
icon: 'i-lucide-triangle-alert',
|
|
689
687
|
color: 'error',
|
|
690
688
|
})
|
|
@@ -714,10 +712,22 @@ const REVIEW_TARGET_MESSAGES: Record<
|
|
|
714
712
|
: null,
|
|
715
713
|
}
|
|
716
714
|
|
|
717
|
-
|
|
715
|
+
/**
|
|
716
|
+
* Translated copy for a machine-readable creation refusal, or null to fall back to the server's
|
|
717
|
+
* own English prose. Two reasons are recognised: a review task's unresolvable target PR, and a
|
|
718
|
+
* custom type's collected values contradicting its descriptor.
|
|
719
|
+
*
|
|
720
|
+
* The second one is reachable here even though `canAdd` mirrors the same check client-side, and
|
|
721
|
+
* that is the whole point of the server-side check: the descriptor can be re-registered while this
|
|
722
|
+
* dialog sits open, so the form the user filled is not the form the server now validates against.
|
|
723
|
+
* The individual problems stay out of the copy: they are backend English naming field keys, so
|
|
724
|
+
* what the user is told is the ONE thing they can act on (reopen the dialog).
|
|
725
|
+
*/
|
|
726
|
+
function createRefusalMessage(error: unknown): string | null {
|
|
718
727
|
const details = (apiErrorEnvelope(error)?.details ?? {}) as Record<string, unknown>
|
|
719
728
|
const reason = details.reason
|
|
720
729
|
if (typeof reason !== 'string') return null
|
|
730
|
+
if (reason === 'task_type_fields_invalid') return t('board.addTask.customFieldsInvalid')
|
|
721
731
|
return REVIEW_TARGET_MESSAGES[reason as ReviewTargetReason]?.(details) ?? null
|
|
722
732
|
}
|
|
723
733
|
|
|
@@ -1045,8 +1055,9 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
|
|
|
1045
1055
|
|
|
1046
1056
|
<!-- A CUSTOM (deployment-registered) task type: a bespoke create-form section when its
|
|
1047
1057
|
`formPanel` is paired to the `taskTypeFormPanels` slot, else the descriptor-driven
|
|
1048
|
-
`fields` the type declares
|
|
1049
|
-
|
|
1058
|
+
`fields` the type declares, rendered by the SHARED renderer the initiative-preset form
|
|
1059
|
+
uses. None of the built-in `v-if` branches above match a namespaced custom type, so
|
|
1060
|
+
this renders on its own. -->
|
|
1050
1061
|
<div v-if="selectedCustomType" class="space-y-3" data-testid="custom-task-fields">
|
|
1051
1062
|
<component
|
|
1052
1063
|
:is="customFormPanel"
|
|
@@ -1055,47 +1066,12 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
|
|
|
1055
1066
|
:model-value="customFieldValues"
|
|
1056
1067
|
@update:model-value="customFieldValues = $event"
|
|
1057
1068
|
/>
|
|
1058
|
-
<
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
:required="field.required"
|
|
1065
|
-
>
|
|
1066
|
-
<div v-if="field.type === 'select'" class="flex flex-wrap gap-1">
|
|
1067
|
-
<UButton
|
|
1068
|
-
v-for="opt in field.options ?? []"
|
|
1069
|
-
:key="opt.value"
|
|
1070
|
-
:color="customFieldValues[field.key] === opt.value ? 'primary' : 'neutral'"
|
|
1071
|
-
:variant="customFieldValues[field.key] === opt.value ? 'soft' : 'ghost'"
|
|
1072
|
-
size="xs"
|
|
1073
|
-
:data-testid="`custom-field-${field.key}-${opt.value}`"
|
|
1074
|
-
@click="() => (customFieldValues[field.key] = opt.value)"
|
|
1075
|
-
>
|
|
1076
|
-
{{ opt.label }}
|
|
1077
|
-
</UButton>
|
|
1078
|
-
</div>
|
|
1079
|
-
<UTextarea
|
|
1080
|
-
v-else-if="field.type === 'textarea'"
|
|
1081
|
-
v-model="customFieldValues[field.key]"
|
|
1082
|
-
:rows="2"
|
|
1083
|
-
:maxlength="field.maxLength"
|
|
1084
|
-
:placeholder="field.placeholder"
|
|
1085
|
-
:data-testid="`custom-field-${field.key}`"
|
|
1086
|
-
class="w-full"
|
|
1087
|
-
/>
|
|
1088
|
-
<UInput
|
|
1089
|
-
v-else
|
|
1090
|
-
v-model="customFieldValues[field.key]"
|
|
1091
|
-
:type="field.type === 'number' ? 'number' : 'text'"
|
|
1092
|
-
:maxlength="field.maxLength"
|
|
1093
|
-
:placeholder="field.placeholder"
|
|
1094
|
-
:data-testid="`custom-field-${field.key}`"
|
|
1095
|
-
class="w-full"
|
|
1096
|
-
/>
|
|
1097
|
-
</UFormField>
|
|
1098
|
-
</template>
|
|
1069
|
+
<DescriptorFields
|
|
1070
|
+
v-else
|
|
1071
|
+
v-model="customFieldValues"
|
|
1072
|
+
:fields="selectedCustomType.fields ?? []"
|
|
1073
|
+
testid-prefix="custom-field"
|
|
1074
|
+
/>
|
|
1099
1075
|
</div>
|
|
1100
1076
|
|
|
1101
1077
|
<!-- One column in basic mode, where the pipeline picker is the only survivor and a
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// sanitized) preset inputs on the entity. Nothing is planned here: the user then runs the preset's
|
|
7
7
|
// planning pipeline on the block from the inspector.
|
|
8
8
|
//
|
|
9
|
-
// The preset form is rendered GENERICALLY from `descriptor.fields`
|
|
9
|
+
// The preset form is rendered GENERICALLY from `descriptor.fields` by the shared
|
|
10
|
+
// `DescriptorFields` renderer (the same one a custom task type's per-case form uses): zero
|
|
10
11
|
// per-preset frontend code. A preset with a repo-detection probe prefills its form from the frame's
|
|
11
12
|
// repo on selection (best-effort; failures fall back to descriptor defaults and never block create).
|
|
12
13
|
//
|
|
@@ -20,9 +21,9 @@ import {
|
|
|
20
21
|
validateInitiativePresetInputs,
|
|
21
22
|
} from '@cat-factory/contracts'
|
|
22
23
|
import type { InitiativePresetInputs, InitiativePresetInputValue } from '~/types/domain'
|
|
23
|
-
import {
|
|
24
|
+
import { defaultDescriptorValues } from '~/utils/descriptorFields'
|
|
24
25
|
import { GENERIC_PRESET_ID } from '~/stores/initiative'
|
|
25
|
-
import
|
|
26
|
+
import DescriptorFields from '~/components/common/DescriptorFields.vue'
|
|
26
27
|
import ContextAttachmentFields from '~/components/context/ContextAttachmentFields.vue'
|
|
27
28
|
import type { PendingContext } from '~/composables/useContextLinking'
|
|
28
29
|
|
|
@@ -62,7 +63,7 @@ let probeSeq = 0
|
|
|
62
63
|
/** Seed the form to the selected preset's descriptor defaults, then fire its detection probe. */
|
|
63
64
|
function applyPreset(): void {
|
|
64
65
|
const descriptor = selectedPreset.value
|
|
65
|
-
inputs.value = descriptor ?
|
|
66
|
+
inputs.value = descriptor ? defaultDescriptorValues(descriptor.fields) : {}
|
|
66
67
|
void runProbe()
|
|
67
68
|
}
|
|
68
69
|
|
|
@@ -227,10 +228,11 @@ async function create() {
|
|
|
227
228
|
</UFormField>
|
|
228
229
|
|
|
229
230
|
<!-- The preset's descriptor-driven form (renders nothing for the fieldless generic preset). -->
|
|
230
|
-
<
|
|
231
|
+
<DescriptorFields
|
|
231
232
|
v-if="selectedPreset"
|
|
232
233
|
v-model="inputs"
|
|
233
|
-
:
|
|
234
|
+
:fields="selectedPreset.fields"
|
|
235
|
+
testid-prefix="initiative-preset-field"
|
|
234
236
|
/>
|
|
235
237
|
|
|
236
238
|
<!-- Attached requirements / issues, staged here and linked once the block exists. The
|
|
@@ -1,68 +1,68 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// Generic, descriptor-driven renderer
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
2
|
+
// Generic, descriptor-driven form renderer: ONE component behind every surface where the BACKEND
|
|
3
|
+
// declares the fields and the SPA only collects them. Two use it today, and they used to carry
|
|
4
|
+
// near-duplicate renderers of the same vocabulary:
|
|
5
|
+
//
|
|
6
|
+
// - an initiative PRESET's create-time form (`CreateInitiativeModal`)
|
|
7
|
+
// - a reusable OPERATION's per-case brief on a custom task type (`AddTaskModal`)
|
|
8
|
+
//
|
|
9
|
+
// It extends `ProviderConnectionTab.vue`'s flat-field pattern with the shapes a declared form needs:
|
|
10
|
+
// `checkbox-group` (multi-select whose value is `string[]`), `path` (a repo-relative dir with inline
|
|
11
|
+
// safety validation), and single-condition `showWhen` visibility. Labels/help/option captions are
|
|
12
|
+
// backend-supplied English (the `describeConfig` convention); only the chrome is i18n.
|
|
13
|
+
//
|
|
14
|
+
// The model is the typed `DescriptorFieldValues` map (scalars stay strings, `number` a number,
|
|
15
|
+
// `checkbox` a boolean, `checkbox-group` a `string[]`), so it round-trips the wire contract and the
|
|
16
|
+
// shared `validateDescriptorFields` / `sanitizeDescriptorFields` rules unchanged.
|
|
11
17
|
import { computed } from 'vue'
|
|
12
|
-
import {
|
|
13
|
-
import type {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
import { isDescriptorFieldVisible, isSafeRepoDirPath } from '@cat-factory/contracts'
|
|
19
|
+
import type { DescriptorField, DescriptorFieldValue, DescriptorFieldValues } from '~/types/domain'
|
|
20
|
+
import {
|
|
21
|
+
descriptorGroupValue,
|
|
22
|
+
setDescriptorCheckbox,
|
|
23
|
+
setDescriptorValue,
|
|
24
|
+
toggleDescriptorGroupValue,
|
|
25
|
+
} from '~/utils/descriptorFields'
|
|
19
26
|
|
|
20
|
-
const props =
|
|
21
|
-
|
|
27
|
+
const props = withDefaults(
|
|
28
|
+
defineProps<{
|
|
29
|
+
/** The fields to render, in declaration order (a preset's `fields`, a task type's `fields`). */
|
|
30
|
+
fields: readonly DescriptorField[]
|
|
31
|
+
/**
|
|
32
|
+
* Prefix for each field's `data-testid`, so a spec targets a field by the SURFACE it is on
|
|
33
|
+
* (`custom-field-entity`) rather than by which component happens to render it.
|
|
34
|
+
*/
|
|
35
|
+
testidPrefix?: string
|
|
36
|
+
}>(),
|
|
37
|
+
{ testidPrefix: 'descriptor-field' },
|
|
38
|
+
)
|
|
39
|
+
const model = defineModel<DescriptorFieldValues>({ required: true })
|
|
22
40
|
const { t } = useI18n()
|
|
23
41
|
|
|
24
42
|
// Only fields whose `showWhen` holds against the current values are shown; a hidden field's stale
|
|
25
43
|
// value is kept in the model (so re-showing restores it) but the server + client both drop it at
|
|
26
44
|
// sanitize/validate time, so it can never freeze an unvalidated value.
|
|
27
45
|
const visibleFields = computed(() =>
|
|
28
|
-
props.
|
|
46
|
+
props.fields.filter((f) => isDescriptorFieldVisible(f, model.value)),
|
|
29
47
|
)
|
|
30
48
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
49
|
+
// The value-mutation rules live in `utils/descriptorFields.ts` as pure functions over the bag (what
|
|
50
|
+
// an edit does to it, including the drop-when-empty rule that keeps an unset answer from freezing),
|
|
51
|
+
// so they are unit-tested without mounting this component. Here they are only bound to the model.
|
|
52
|
+
function set(key: string, value: DescriptorFieldValue | undefined): void {
|
|
53
|
+
model.value = setDescriptorValue(model.value, key, value)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function setCheckbox(field: DescriptorField, checked: boolean): void {
|
|
57
|
+
model.value = setDescriptorCheckbox(model.value, field, checked)
|
|
38
58
|
}
|
|
39
59
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
* freezes an empty `''`/`[]`/`false` (mirrors `ProviderConnectionTab`'s delete-when-blank and what
|
|
43
|
-
* the shared `validate`/`sanitize` treat as unset — an unchecked box / blank field stays absent).
|
|
44
|
-
*/
|
|
45
|
-
function set(key: string, value: InitiativePresetInputValue | undefined): void {
|
|
46
|
-
const next = { ...model.value }
|
|
47
|
-
if (value === undefined || isEmptyValue(value)) delete next[key]
|
|
48
|
-
else next[key] = value
|
|
49
|
-
model.value = next
|
|
60
|
+
function toggleGroup(key: string, option: string, checked: boolean): void {
|
|
61
|
+
model.value = toggleDescriptorGroupValue(model.value, key, option, checked)
|
|
50
62
|
}
|
|
51
63
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
* to persist an explicit `false`: {@link set} otherwise drops a `false` (an off box "stays unset"),
|
|
55
|
-
* which for a default-ON field is indistinguishable from "untouched, still on" — so a consumer that
|
|
56
|
-
* reads the opt-out as `humanReview !== false` (e.g. `seedMigrationPlan`) could never observe the
|
|
57
|
-
* unchecked state and the toggle would be dead. A default-OFF checkbox keeps the drop-when-false
|
|
58
|
-
* behaviour (absent === unchecked), so it never freezes a redundant `false`.
|
|
59
|
-
*/
|
|
60
|
-
function setCheckbox(field: InitiativePresetField, checked: boolean): void {
|
|
61
|
-
if (!checked && field.default === 'true') {
|
|
62
|
-
model.value = { ...model.value, [field.key]: false }
|
|
63
|
-
return
|
|
64
|
-
}
|
|
65
|
-
set(field.key, checked)
|
|
64
|
+
function groupValue(key: string): string[] {
|
|
65
|
+
return descriptorGroupValue(model.value, key)
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
function stringValue(key: string): string {
|
|
@@ -76,24 +76,15 @@ function numberStr(key: string): string {
|
|
|
76
76
|
const v = model.value[key]
|
|
77
77
|
return typeof v === 'number' ? String(v) : ''
|
|
78
78
|
}
|
|
79
|
-
function groupValue(key: string): string[] {
|
|
80
|
-
const v = model.value[key]
|
|
81
|
-
return Array.isArray(v) ? v : []
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function toggleGroup(key: string, option: string, checked: boolean): void {
|
|
85
|
-
const current = groupValue(key)
|
|
86
|
-
set(key, checked ? [...new Set([...current, option])] : current.filter((o) => o !== option))
|
|
87
|
-
}
|
|
88
79
|
|
|
89
80
|
/** A `path` field is flagged only when non-empty AND unsafe (empty is handled by `required`). */
|
|
90
|
-
function pathInvalid(field:
|
|
81
|
+
function pathInvalid(field: DescriptorField): boolean {
|
|
91
82
|
if (field.type !== 'path') return false
|
|
92
83
|
const value = stringValue(field.key)
|
|
93
84
|
return value.trim().length > 0 && !isSafeRepoDirPath(value)
|
|
94
85
|
}
|
|
95
86
|
|
|
96
|
-
function selectItems(field:
|
|
87
|
+
function selectItems(field: DescriptorField) {
|
|
97
88
|
return (field.options ?? []).map((o) => ({ label: o.label, value: o.value }))
|
|
98
89
|
}
|
|
99
90
|
</script>
|
|
@@ -106,8 +97,8 @@ function selectItems(field: InitiativePresetField) {
|
|
|
106
97
|
:label="field.label"
|
|
107
98
|
:help="field.help"
|
|
108
99
|
:required="field.required"
|
|
109
|
-
:error="pathInvalid(field) ? t('
|
|
110
|
-
:data-testid="
|
|
100
|
+
:error="pathInvalid(field) ? t('common.pathInvalid') : undefined"
|
|
101
|
+
:data-testid="`${testidPrefix}-${field.key}`"
|
|
111
102
|
>
|
|
112
103
|
<!-- checkbox-group: a vertical list of toggles whose value is the checked option set. -->
|
|
113
104
|
<div v-if="field.type === 'checkbox-group'" class="space-y-1.5">
|
|
@@ -116,6 +107,7 @@ function selectItems(field: InitiativePresetField) {
|
|
|
116
107
|
:key="opt.value"
|
|
117
108
|
:model-value="groupValue(field.key).includes(opt.value)"
|
|
118
109
|
:label="opt.label"
|
|
110
|
+
:data-testid="`${testidPrefix}-${field.key}-${opt.value}`"
|
|
119
111
|
@update:model-value="
|
|
120
112
|
(v: boolean | 'indeterminate') => toggleGroup(field.key, opt.value, v === true)
|
|
121
113
|
"
|
|
@@ -143,6 +135,7 @@ function selectItems(field: InitiativePresetField) {
|
|
|
143
135
|
:rows="3"
|
|
144
136
|
autoresize
|
|
145
137
|
class="w-full"
|
|
138
|
+
:maxlength="field.maxLength"
|
|
146
139
|
:placeholder="field.placeholder"
|
|
147
140
|
@update:model-value="(v: string) => set(field.key, v)"
|
|
148
141
|
/>
|
|
@@ -163,6 +156,7 @@ function selectItems(field: InitiativePresetField) {
|
|
|
163
156
|
:type="field.type === 'password' ? 'password' : 'text'"
|
|
164
157
|
class="w-full"
|
|
165
158
|
:class="{ 'font-mono': field.type === 'path' }"
|
|
159
|
+
:maxlength="field.maxLength"
|
|
166
160
|
:placeholder="field.placeholder"
|
|
167
161
|
@update:model-value="(v: string) => set(field.key, v)"
|
|
168
162
|
/>
|
|
@@ -4,7 +4,7 @@ import type { InputGateIssue, InputGateIssueCode, RunInputGate } from '@cat-fact
|
|
|
4
4
|
import type { InputGateTone } from '~/utils/inputGate'
|
|
5
5
|
import { useInputGateStore } from '~/stores/inputGate'
|
|
6
6
|
|
|
7
|
-
// The PRE-
|
|
7
|
+
// The PRE-DISPATCH INPUT GATE's notice: what the structural check found in the task's authored
|
|
8
8
|
// input, and the two ways out. Shown wherever a run parked on the gate is surfaced (the
|
|
9
9
|
// inspector's execution panel, the step-detail overlay), so it is a plain component over a
|
|
10
10
|
// verdict rather than an overlay of its own, its remedy is to go and edit the task, which is a
|
|
@@ -173,7 +173,7 @@ const dedicatedPark = computed(() =>
|
|
|
173
173
|
step.value ? dedicatedParkView(step.value, instance.value) : null,
|
|
174
174
|
)
|
|
175
175
|
/**
|
|
176
|
-
* The PRE-
|
|
176
|
+
* The PRE-DISPATCH INPUT GATE's verdict when it is what holds this step. Answered INLINE here
|
|
177
177
|
* (unlike the other dedicated parks, which redirect to a window): its remedy is to edit the
|
|
178
178
|
* task, so there is no second modal to send anyone to.
|
|
179
179
|
*
|
|
@@ -448,7 +448,7 @@ async function copyOutput() {
|
|
|
448
448
|
<!-- a park a dedicated window owns (fork choice / follow-up triage): the
|
|
449
449
|
generic approval rail can't resolve it (the server refuses), so point
|
|
450
450
|
the human at the window that can -->
|
|
451
|
-
<!-- the pre-
|
|
451
|
+
<!-- the pre-dispatch input gate holds this step: answered here, in place -->
|
|
452
452
|
<InputGateNotice
|
|
453
453
|
v-if="inputGateVerdict && instance"
|
|
454
454
|
:gate="inputGateVerdict"
|
|
@@ -320,7 +320,7 @@ function buildEnvTestError(e: unknown): EnvTestError {
|
|
|
320
320
|
}
|
|
321
321
|
}
|
|
322
322
|
// The handler resolved but its live connection probe failed. The provider's OWN message is the
|
|
323
|
-
// actionable part ("
|
|
323
|
+
// actionable part ("project 'X' was not found"), so wrap it in localized prose rather than
|
|
324
324
|
// replacing it with a generic sentence — and offer the same jump, since the fix is in the
|
|
325
325
|
// handler's connection config.
|
|
326
326
|
if (reason === 'env_test_connection_failed' && parsed?.message) {
|
|
@@ -61,7 +61,7 @@ const isEmpty = computed(
|
|
|
61
61
|
// its live "Spinning up…" phase (the shared failure banner renders below).
|
|
62
62
|
const runFailed = computed(() => instance.value?.status === 'failed')
|
|
63
63
|
/**
|
|
64
|
-
* The run's PRE-
|
|
64
|
+
* The run's PRE-DISPATCH INPUT GATE notice: the park while it holds the run, the waiver once
|
|
65
65
|
* somebody overruled it, and the ADVISORY findings a `passed` verdict still carries (which is
|
|
66
66
|
* the entire product of `advisory` mode, and how `standard` mode reports a thin description).
|
|
67
67
|
* Read off the RUN, not a step: the gate guards the first dispatch and leaves nothing
|
|
@@ -483,7 +483,7 @@ async function mergePr() {
|
|
|
483
483
|
</UButton>
|
|
484
484
|
<!-- The generic approve/review rail. Reached only once no dedicated surface owns
|
|
485
485
|
the park: the branches above took the fork and follow-up windows, so the one
|
|
486
|
-
left to exclude is the PRE-
|
|
486
|
+
left to exclude is the PRE-DISPATCH INPUT GATE, which rides `step.approval` too
|
|
487
487
|
but is refused by the generic resolver server-side (approving it would mark the
|
|
488
488
|
run's first working step done and skip the work). It is answered by the notice
|
|
489
489
|
above the list. Asked of `dedicatedParkView` rather than re-derived here, so
|
|
@@ -368,7 +368,7 @@ async function save() {
|
|
|
368
368
|
</div>
|
|
369
369
|
</section>
|
|
370
370
|
|
|
371
|
-
<!-- The pre-
|
|
371
|
+
<!-- The pre-dispatch input gate: the structural check of a task's own wording, run
|
|
372
372
|
before a run's first agent step is dispatched. -->
|
|
373
373
|
<section class="space-y-2">
|
|
374
374
|
<h3 class="text-sm font-semibold text-slate-200">
|
|
@@ -2,7 +2,7 @@ import { resolveInputGateContract, type ResolveInputGateChoice } from '@cat-fact
|
|
|
2
2
|
import type { ApiContext } from './context'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* The PRE-
|
|
5
|
+
* The PRE-DISPATCH INPUT GATE: a run whose task states nothing an agent could act on parks before
|
|
6
6
|
* its first dispatch, having spent no tokens. This resolves that park: `recheck` re-evaluates
|
|
7
7
|
* the task as it now stands (the fix is verified, not asserted), `proceed` waives the findings.
|
|
8
8
|
*
|
|
@@ -111,7 +111,7 @@ describe('navSlotFilter', () => {
|
|
|
111
111
|
// Model providers are NOT an integration: the split is what makes the engines findable.
|
|
112
112
|
expect(kept).toContain('model-providers')
|
|
113
113
|
// ...and so does everything the everyday delivery loop runs on, however deep it feels:
|
|
114
|
-
// authoring a flow, the standards library, and the
|
|
114
|
+
// authoring a flow, the standards library, and the ephemeral-env/runner plumbing.
|
|
115
115
|
expect(kept).toContain('build-pipeline')
|
|
116
116
|
expect(kept).toContain('fragments')
|
|
117
117
|
// Also the only route to the guided per-service Compose environment setup, which folded
|
|
@@ -279,7 +279,7 @@ const S = (...s: NavSurface[]) => s as readonly NavSurface[]
|
|
|
279
279
|
*
|
|
280
280
|
* Everything else stays in basic because the delivery loop needs it: authoring a flow
|
|
281
281
|
* (`build-pipeline`), adding a repo (`add-from-repo`), the standards/skills library
|
|
282
|
-
* (`fragments`), the
|
|
282
|
+
* (`fragments`), the ephemeral-env + runner plumbing (`infrastructure`, which is also the only route
|
|
283
283
|
* to the guided per-service Compose environment setup), and the workspace/model configuration
|
|
284
284
|
* a run actually reads (`workspace-settings`, `model-config`).
|
|
285
285
|
*/
|
package/app/stores/inputGate.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { useWorkspaceStore } from '~/stores/workspace'
|
|
|
6
6
|
import { useExecutionStore } from '~/stores/execution'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* The PRE-
|
|
9
|
+
* The PRE-DISPATCH INPUT GATE's action surface. The verdict itself lives on the run
|
|
10
10
|
* (`instance.inputGate`) and is kept fresh by the execution stream, so this store only wraps the
|
|
11
11
|
* `resolve` action, tracks the in-flight state so the notice can disable its buttons, and
|
|
12
12
|
* reflects the returned verdict back so the UI updates before the stream echoes it.
|
|
@@ -86,7 +86,7 @@ export function createUiResultViews() {
|
|
|
86
86
|
: step
|
|
87
87
|
? (park ?? agentKindMeta(step.agentKind).resultView)
|
|
88
88
|
: undefined
|
|
89
|
-
// The PRE-
|
|
89
|
+
// The PRE-DISPATCH INPUT GATE is the one dedicated park with no window of its own: it is
|
|
90
90
|
// answered by an inline notice, which the generic step detail renders. Routing to the
|
|
91
91
|
// step's usual result view instead would open a window about work that has not run.
|
|
92
92
|
if (park === 'input-gate') {
|