@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 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/PREnv windows, and the workspace /
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. Cleared when the type changes / the modal reopens.
159
- const customFieldValues = ref<Record<string, string | number>>({})
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
- // Whether every REQUIRED descriptor field of the selected custom type has a value. Only the
172
- // descriptor path is enforced up front a bespoke `formPanel` owns its own validation, so we
173
- // don't block on it (nor can we read its required semantics). No custom type selected (or none
174
- // of its fields are required) trivially satisfied. Folded into `canAdd` so the required marker
175
- // the form renders is actually honoured before submit.
176
- const customRequiredFieldsFilled = computed(() => {
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 true
179
- return (custom.fields ?? []).every((field) => {
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); the
262
- // descriptor path reads only the declared fields, coercing a `number` descriptor's string input.
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
- const bag: Record<string, string | number> = {}
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
- for (const field of custom.fields ?? []) {
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
- // A custom type owns a fresh field bag on every switch (its descriptors differ per type).
420
- customFieldValues.value = {}
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 required descriptor fields must be filled (its form renders them as required).
614
- if (!customRequiredFieldsFilled.value) return false
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: reviewTargetMessage(e) ?? (e instanceof Error ? e.message : String(e)),
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
- function reviewTargetMessage(error: unknown): string | null {
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. None of the built-in `v-if` branches above match a
1049
- namespaced custom type, so this renders on its own. -->
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
- <template v-else>
1059
- <UFormField
1060
- v-for="field in selectedCustomType.fields ?? []"
1061
- :key="field.key"
1062
- :label="field.label"
1063
- :help="field.help"
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` (InitiativePresetFields) zero
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 { defaultPresetInputs } from '~/utils/initiative'
24
+ import { defaultDescriptorValues } from '~/utils/descriptorFields'
24
25
  import { GENERIC_PRESET_ID } from '~/stores/initiative'
25
- import InitiativePresetFields from '~/components/board/InitiativePresetFields.vue'
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 ? defaultPresetInputs(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
- <InitiativePresetFields
231
+ <DescriptorFields
231
232
  v-if="selectedPreset"
232
233
  v-model="inputs"
233
- :descriptor="selectedPreset"
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 for an initiative preset's create-time FORM. Extends the
3
- // `ProviderConnectionTab.vue` flat-field pattern with the three shapes a preset form adds:
4
- // `checkbox-group` (multi-select `string[]`), `path` (a repo-relative dir with inline
5
- // safety validation), and single-condition `showWhen` visibility. Every preset renders through
6
- // THIS component with zero per-preset frontend code — the backend descriptor supplies the fields
7
- // (labels/help/options are backend-supplied English, per the `describeConfig` convention). The
8
- // model is the typed `InitiativePresetInputs` map (scalars stay strings, `number` a number,
9
- // `checkbox` a boolean, `checkbox-group` a `string[]`) so it round-trips the wire contract and the
10
- // shared `validateInitiativePresetInputs` unchanged.
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 { isPresetFieldVisible, isSafeRepoDirPath } from '@cat-factory/contracts'
13
- import type {
14
- InitiativePresetDescriptor,
15
- InitiativePresetField,
16
- InitiativePresetInputs,
17
- InitiativePresetInputValue,
18
- } from '~/types/domain'
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 = defineProps<{ descriptor: InitiativePresetDescriptor }>()
21
- const model = defineModel<InitiativePresetInputs>({ required: true })
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.descriptor.fields.filter((f) => isPresetFieldVisible(f, model.value)),
46
+ props.fields.filter((f) => isDescriptorFieldVisible(f, model.value)),
29
47
  )
30
48
 
31
- /**
32
- * An "empty" value that must stay ABSENT from the model rather than freeze on the entity: an
33
- * unchecked (`false`) checkbox, a blank string, or an empty multi-select. A numeric `0` is a real
34
- * value and is kept (strict `=== false`/`=== ''` never match it).
35
- */
36
- function isEmptyValue(value: InitiativePresetInputValue): boolean {
37
- return value === false || value === '' || (Array.isArray(value) && value.length === 0)
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
- * Immutably set one field's value on the model, DROPPING empty values so a cleared field never
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
- * Set a checkbox value. A checkbox whose descriptor default is ON (`default: 'true'`) must be able
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: InitiativePresetField): boolean {
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: InitiativePresetField) {
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('initiative.create.pathInvalid') : undefined"
110
- :data-testid="`initiative-preset-field-${field.key}`"
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-TOKEN INPUT GATE's notice: what the structural check found in the task's authored
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-TOKEN INPUT GATE's verdict when it is what holds this step. Answered INLINE here
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-token input gate holds this step: answered here, in place -->
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 ("Kargo project 'X' was not found"), so wrap it in localized prose rather than
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-TOKEN INPUT GATE notice: the park while it holds the run, the waiver once
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-TOKEN INPUT GATE, which rides `step.approval` too
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-token input gate: the structural check of a task's own wording, run
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-TOKEN INPUT GATE: a run whose task states nothing an agent could act on parks before
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 PREnv/runner plumbing.
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 PREnv + runner plumbing (`infrastructure`, which is also the only route
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
  */
@@ -6,7 +6,7 @@ import { useWorkspaceStore } from '~/stores/workspace'
6
6
  import { useExecutionStore } from '~/stores/execution'
7
7
 
8
8
  /**
9
- * The PRE-TOKEN INPUT GATE's action surface. The verdict itself lives on the run
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-TOKEN INPUT GATE is the one dedicated park with no window of its own: it is
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') {