@cat-factory/app 0.215.2 → 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.
Files changed (37) hide show
  1. package/README.md +133 -2
  2. package/app/components/board/AddTaskModal.vue +54 -78
  3. package/app/components/board/CreateInitiativeModal.vue +8 -6
  4. package/app/components/{board/InitiativePresetFields.vue → common/DescriptorFields.vue} +57 -63
  5. package/app/components/inputGate/InputGateNotice.vue +176 -0
  6. package/app/components/panels/AgentStepDetail.vue +27 -3
  7. package/app/components/panels/inspector/ServiceTestConfig.vue +1 -1
  8. package/app/components/panels/inspector/TaskExecution.vue +32 -3
  9. package/app/components/pipeline/PipelineProgress.vue +1 -1
  10. package/app/components/settings/WorkspaceSettingsPanel.vue +34 -1
  11. package/app/composables/api/inputGate.ts +25 -0
  12. package/app/composables/useApi.ts +2 -0
  13. package/app/composables/usePipelineErrorToast.ts +8 -0
  14. package/app/modular/nav-contributions.spec.ts +1 -1
  15. package/app/modular/nav-contributions.ts +1 -1
  16. package/app/stores/inputGate.ts +58 -0
  17. package/app/stores/ui/resultViews.ts +9 -1
  18. package/app/stores/workspaceSettings.ts +1 -0
  19. package/app/types/domain.ts +7 -0
  20. package/app/utils/descriptorFields.spec.ts +126 -0
  21. package/app/utils/descriptorFields.ts +104 -0
  22. package/app/utils/initiative.ts +0 -30
  23. package/app/utils/inputGate.spec.ts +52 -0
  24. package/app/utils/inputGate.ts +44 -0
  25. package/app/utils/pipelineRender.spec.ts +47 -9
  26. package/app/utils/pipelineRender.ts +23 -2
  27. package/i18n/locales/de.json +62 -2
  28. package/i18n/locales/en.json +65 -2
  29. package/i18n/locales/es.json +77 -5
  30. package/i18n/locales/fr.json +77 -5
  31. package/i18n/locales/he.json +63 -2
  32. package/i18n/locales/it.json +62 -2
  33. package/i18n/locales/ja.json +62 -2
  34. package/i18n/locales/pl.json +77 -5
  35. package/i18n/locales/tr.json +62 -2
  36. package/i18n/locales/uk.json +77 -5
  37. package/package.json +2 -2
@@ -0,0 +1,126 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { DescriptorField } from '~/types/domain'
3
+ import {
4
+ defaultDescriptorValues,
5
+ descriptorGroupValue,
6
+ setDescriptorCheckbox,
7
+ setDescriptorValue,
8
+ toggleDescriptorGroupValue,
9
+ } from './descriptorFields'
10
+
11
+ const field = (over: Partial<DescriptorField> & Pick<DescriptorField, 'key'>): DescriptorField => ({
12
+ label: over.key,
13
+ ...over,
14
+ })
15
+
16
+ describe('defaultDescriptorValues', () => {
17
+ it('seeds each declared default in its own contract shape', () => {
18
+ // The form model is the wire shape, so a default has to arrive typed: the shared validator
19
+ // refuses a `'3'` where a `number` field is declared, and would refuse it at create time too.
20
+ expect(
21
+ defaultDescriptorValues([
22
+ field({ key: 'style', type: 'select', default: 'collection' }),
23
+ field({ key: 'depth', type: 'number', default: '3' }),
24
+ field({ key: 'gate', type: 'checkbox', default: 'true' }),
25
+ field({ key: 'ops', type: 'checkbox-group', defaultValues: ['create', 'list'] }),
26
+ field({ key: 'dir', type: 'path', default: 'docs' }),
27
+ ]),
28
+ ).toEqual({
29
+ style: 'collection',
30
+ depth: 3,
31
+ gate: true,
32
+ ops: ['create', 'list'],
33
+ dir: 'docs',
34
+ })
35
+ })
36
+
37
+ it('leaves a field with no meaningful default ABSENT rather than blank', () => {
38
+ // Absent is what validation reads as unset, so seeding `''`/`[]`/`false` would both freeze an
39
+ // empty answer and (for a required field) look filled to nothing that checks it.
40
+ expect(
41
+ defaultDescriptorValues([
42
+ field({ key: 'entity', type: 'text' }),
43
+ field({ key: 'notes', type: 'textarea', default: '' }),
44
+ field({ key: 'gate', type: 'checkbox' }),
45
+ field({ key: 'gateOff', type: 'checkbox', default: 'false' }),
46
+ field({ key: 'ops', type: 'checkbox-group', defaultValues: [] }),
47
+ field({ key: 'depth', type: 'number', default: 'not-a-number' }),
48
+ ]),
49
+ ).toEqual({})
50
+ })
51
+
52
+ it('copies a multi-select default, so editing the form cannot mutate the descriptor', () => {
53
+ const ops = field({ key: 'ops', type: 'checkbox-group', defaultValues: ['create'] })
54
+ const seeded = defaultDescriptorValues([ops])
55
+ ;(seeded.ops as string[]).push('delete')
56
+ expect(ops.defaultValues).toEqual(['create'])
57
+ })
58
+ })
59
+
60
+ describe('setDescriptorValue', () => {
61
+ it('drops a value the shared rules read as unset rather than freezing it', () => {
62
+ // Absent is what `validateDescriptorFields` treats as unfilled and what
63
+ // `sanitizeDescriptorFields` refuses to freeze, so the form must not hold one either: a
64
+ // cleared field that stayed as `''`/`[]`/`false` would reach the wire as a collected answer.
65
+ expect(setDescriptorValue({ entity: 'Order' }, 'entity', '')).toEqual({})
66
+ expect(setDescriptorValue({ ops: ['create'] }, 'ops', [])).toEqual({})
67
+ expect(setDescriptorValue({ gate: true }, 'gate', false)).toEqual({})
68
+ expect(setDescriptorValue({ entity: 'Order' }, 'entity', undefined)).toEqual({})
69
+ })
70
+
71
+ it('keeps a numeric 0 but drops a half-typed number', () => {
72
+ // `0` is a real answer. `NaN` is what `Number('')`/a partial entry yields, and it serialises to
73
+ // `null` on the wire, which the value schema refuses: the submit would fail with a raw schema
74
+ // error naming nothing the user typed.
75
+ expect(setDescriptorValue({}, 'depth', 0)).toEqual({ depth: 0 })
76
+ expect(setDescriptorValue({ depth: 3 }, 'depth', Number.NaN)).toEqual({})
77
+ })
78
+
79
+ it('does not mutate the bag it was given', () => {
80
+ const before = { entity: 'Order' }
81
+ expect(setDescriptorValue(before, 'style', 'action')).toEqual({
82
+ entity: 'Order',
83
+ style: 'action',
84
+ })
85
+ expect(before).toEqual({ entity: 'Order' })
86
+ })
87
+ })
88
+
89
+ describe('setDescriptorCheckbox', () => {
90
+ it('persists an explicit false ONLY for a default-ON toggle', () => {
91
+ // A default-ON checkbox is the one field where absent and `false` are opposite facts: a
92
+ // consumer reads the opt-out as `inputs[key] !== false` (the tech-migration preset's
93
+ // `humanReview`), so dropping the `false` would make the toggle dead.
94
+ const onByDefault = field({ key: 'humanReview', type: 'checkbox', default: 'true' })
95
+ expect(setDescriptorCheckbox({}, onByDefault, false)).toEqual({ humanReview: false })
96
+ expect(setDescriptorCheckbox({}, onByDefault, true)).toEqual({ humanReview: true })
97
+ // A default-OFF checkbox never freezes a redundant `false`: absent already means unchecked.
98
+ const offByDefault = field({ key: 'breaking', type: 'checkbox' })
99
+ expect(setDescriptorCheckbox({ breaking: true }, offByDefault, false)).toEqual({})
100
+ })
101
+ })
102
+
103
+ describe('toggleDescriptorGroupValue', () => {
104
+ it('adds, removes and de-duplicates without freezing an empty selection', () => {
105
+ expect(toggleDescriptorGroupValue({}, 'ops', 'create', true)).toEqual({ ops: ['create'] })
106
+ expect(toggleDescriptorGroupValue({ ops: ['create'] }, 'ops', 'create', true)).toEqual({
107
+ ops: ['create'],
108
+ })
109
+ expect(toggleDescriptorGroupValue({ ops: ['create', 'list'] }, 'ops', 'create', false)).toEqual(
110
+ {
111
+ ops: ['list'],
112
+ },
113
+ )
114
+ // Unchecking the last option leaves the key ABSENT, not an empty array.
115
+ expect(toggleDescriptorGroupValue({ ops: ['create'] }, 'ops', 'create', false)).toEqual({})
116
+ })
117
+
118
+ it('reads a wrong-shaped stored value as an empty selection', () => {
119
+ // A bag can arrive from a probe prefill or a since-changed descriptor, so the reader narrows
120
+ // rather than assuming: a scalar under a multi-select key must not throw in the renderer.
121
+ expect(descriptorGroupValue({ ops: 'create' }, 'ops')).toEqual([])
122
+ expect(toggleDescriptorGroupValue({ ops: 'create' }, 'ops', 'list', true)).toEqual({
123
+ ops: ['list'],
124
+ })
125
+ })
126
+ })
@@ -0,0 +1,104 @@
1
+ import type { DescriptorField, DescriptorFieldValue, DescriptorFieldValues } from '~/types/domain'
2
+
3
+ // Form-side helpers over the shared descriptor-field vocabulary (`contracts/src/form-fields.ts`),
4
+ // used by every surface that renders one through `DescriptorFields.vue`: an initiative preset's
5
+ // create form and a reusable operation's per-case form on a custom task type.
6
+ //
7
+ // The RULES (visibility, validation, sanitization, prose rendering) live in contracts, because the
8
+ // server has to agree about them. What lives here is what only a FORM decides: which values to
9
+ // start it with, and how one edit changes the bag. Both are pure functions over the value bag
10
+ // rather than methods inside the SFC, so the mutation rules a wrong answer would freeze on an
11
+ // entity are unit-testable without mounting a component.
12
+
13
+ /**
14
+ * The initial, typed values a field list implies: its declared DEFAULTS folded into the
15
+ * `DescriptorFieldValues` shape the renderer and the wire contract expect (`checkbox-group` to
16
+ * `string[]`, `checkbox` to a boolean, `number` to a number, everything else a string). Only fields
17
+ * with a meaningful default are seeded, so an unfilled optional field stays absent (which is what
18
+ * validation reads as unset) and never freezes an empty value. A repo-detection probe's prefill and
19
+ * the user's own edits layer on top.
20
+ */
21
+ export function defaultDescriptorValues(fields: readonly DescriptorField[]): DescriptorFieldValues {
22
+ const values: DescriptorFieldValues = {}
23
+ for (const field of fields) {
24
+ if (field.type === 'checkbox-group') {
25
+ if (field.defaultValues?.length) values[field.key] = [...field.defaultValues]
26
+ } else if (field.type === 'checkbox') {
27
+ if (field.default === 'true') values[field.key] = true
28
+ } else if (field.type === 'number') {
29
+ const parsed = Number(field.default)
30
+ if (field.default !== undefined && field.default !== '' && Number.isFinite(parsed)) {
31
+ values[field.key] = parsed
32
+ }
33
+ } else if (field.default) {
34
+ values[field.key] = field.default
35
+ }
36
+ }
37
+ return values
38
+ }
39
+
40
+ /**
41
+ * A value that must stay ABSENT from the bag rather than freeze on the entity: an unchecked
42
+ * (`false`) checkbox, a blank string, an empty multi-select, or a number that is not one (a
43
+ * half-typed `number` input reads as `NaN`, which serialises to `null` on the wire and is refused
44
+ * by the value schema, so the form must never carry it). A numeric `0` is a real answer and is kept.
45
+ *
46
+ * The same judgement the shared `validateDescriptorFields` / `sanitizeDescriptorFields` make about
47
+ * an unset value, applied at the moment of the edit so the model never holds one at all.
48
+ */
49
+ function isEmptyDescriptorValue(value: DescriptorFieldValue): boolean {
50
+ if (typeof value === 'number') return !Number.isFinite(value)
51
+ return value === false || value === '' || (Array.isArray(value) && value.length === 0)
52
+ }
53
+
54
+ /**
55
+ * One field's value set immutably on the bag, DROPPING an empty one so a cleared field never
56
+ * freezes an empty `''`/`[]`/`false` (mirroring `ProviderConnectionTab`'s delete-when-blank).
57
+ */
58
+ export function setDescriptorValue(
59
+ values: DescriptorFieldValues,
60
+ key: string,
61
+ value: DescriptorFieldValue | undefined,
62
+ ): DescriptorFieldValues {
63
+ const next = { ...values }
64
+ if (value === undefined || isEmptyDescriptorValue(value)) delete next[key]
65
+ else next[key] = value
66
+ return next
67
+ }
68
+
69
+ /**
70
+ * A checkbox's value set on the bag. A checkbox whose descriptor default is ON (`default: 'true'`)
71
+ * must be able to persist an explicit `false`: {@link setDescriptorValue} otherwise drops it (an off
72
+ * box "stays unset"), which for a default-ON field is indistinguishable from "untouched, still on",
73
+ * so a consumer reading the opt-out as `humanReview !== false` (`seedMigrationPlan`) could never
74
+ * observe the unchecked state and the toggle would be dead. A default-OFF checkbox keeps the
75
+ * drop-when-false behaviour (absent === unchecked), so it never freezes a redundant `false`.
76
+ */
77
+ export function setDescriptorCheckbox(
78
+ values: DescriptorFieldValues,
79
+ field: DescriptorField,
80
+ checked: boolean,
81
+ ): DescriptorFieldValues {
82
+ if (!checked && field.default === 'true') return { ...values, [field.key]: false }
83
+ return setDescriptorValue(values, field.key, checked)
84
+ }
85
+
86
+ /** One `checkbox-group` field's current value, as the `string[]` the renderer and wire expect. */
87
+ export function descriptorGroupValue(values: DescriptorFieldValues, key: string): string[] {
88
+ const value = values[key]
89
+ return Array.isArray(value) ? value : []
90
+ }
91
+
92
+ /** One option toggled on/off in a `checkbox-group` field's value (deduped, order-preserving). */
93
+ export function toggleDescriptorGroupValue(
94
+ values: DescriptorFieldValues,
95
+ key: string,
96
+ option: string,
97
+ checked: boolean,
98
+ ): DescriptorFieldValues {
99
+ const current = descriptorGroupValue(values, key)
100
+ const next = checked
101
+ ? [...new Set([...current, option])]
102
+ : current.filter((entry) => entry !== option)
103
+ return setDescriptorValue(values, key, next)
104
+ }
@@ -4,8 +4,6 @@ import type {
4
4
  InitiativeItem,
5
5
  InitiativeItemStatus,
6
6
  InitiativePhase,
7
- InitiativePresetDescriptor,
8
- InitiativePresetInputs,
9
7
  InitiativeQa,
10
8
  InitiativeStatus,
11
9
  } from '~/types/domain'
@@ -140,34 +138,6 @@ export const INITIATIVE_FOLLOWUP_STATUS_CHIPS: Record<InitiativeFollowUp['status
140
138
  dismissed: 'neutral',
141
139
  }
142
140
 
143
- /**
144
- * The initial, typed create-form values a preset descriptor implies — its field DEFAULTS folded
145
- * into the `InitiativePresetInputs` shape the renderer + wire contract expect (`checkbox-group` →
146
- * `string[]`, `checkbox` → boolean, `number` → number, everything else a string). Only fields with
147
- * a meaningful default are seeded, so unfilled optional fields stay absent (equivalent to unset for
148
- * validation) and never freeze an empty value. The probe prefill and the user's edits layer on top.
149
- */
150
- export function defaultPresetInputs(
151
- descriptor: InitiativePresetDescriptor,
152
- ): InitiativePresetInputs {
153
- const inputs: InitiativePresetInputs = {}
154
- for (const field of descriptor.fields) {
155
- if (field.type === 'checkbox-group') {
156
- if (field.defaultValues?.length) inputs[field.key] = [...field.defaultValues]
157
- } else if (field.type === 'checkbox') {
158
- if (field.default === 'true') inputs[field.key] = true
159
- } else if (field.type === 'number') {
160
- const parsed = Number(field.default)
161
- if (field.default !== undefined && field.default !== '' && Number.isFinite(parsed)) {
162
- inputs[field.key] = parsed
163
- }
164
- } else if (field.default) {
165
- inputs[field.key] = field.default
166
- }
167
- }
168
- return inputs
169
- }
170
-
171
141
  /** Completion rollup across an initiative's items, or null when there are none. */
172
142
  export function initiativeProgress(
173
143
  items: InitiativeItem[] | undefined,
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { RunInputGate } from '@cat-factory/contracts'
3
+ import type { ExecutionInstance } from '~/types/execution'
4
+ import { inputGateNoticeFor } from './inputGate'
5
+
6
+ const run = (inputGate?: Partial<RunInputGate>): ExecutionInstance =>
7
+ ({
8
+ id: 'exe_1',
9
+ steps: [],
10
+ ...(inputGate
11
+ ? { inputGate: { mode: 'standard', issues: [], checkedAt: 1, ...inputGate } }
12
+ : {}),
13
+ }) as unknown as ExecutionInstance
14
+
15
+ const thin = [{ code: 'description_thin', severity: 'advisory' }] as RunInputGate['issues']
16
+ const missing = [{ code: 'description_missing', severity: 'blocking' }] as RunInputGate['issues']
17
+
18
+ describe('inputGateNoticeFor', () => {
19
+ it('shows the park, with the tone that carries the two ways out', () => {
20
+ expect(inputGateNoticeFor(run({ status: 'blocked', issues: missing }))?.tone).toBe('blocked')
21
+ })
22
+
23
+ it('keeps a waiver visible, because what was overruled explains the output', () => {
24
+ expect(inputGateNoticeFor(run({ status: 'overridden', issues: missing }))?.tone).toBe('waived')
25
+ })
26
+
27
+ // The regression this pins: advisory findings were recorded on the run and reported over the
28
+ // API while being invisible in the product, which left `advisory` MODE (whose entire purpose is
29
+ // "watch what the gate would have caught before turning it up") with nothing to watch.
30
+ it('shows advisory findings on a PASSED verdict, which is what advisory mode produces', () => {
31
+ const notice = inputGateNoticeFor(run({ status: 'passed', mode: 'advisory', issues: thin }))
32
+ expect(notice?.tone).toBe('advisory')
33
+ expect(notice?.gate.issues).toEqual(thin)
34
+ })
35
+
36
+ it('shows a standard-mode advisory too, which never parks but is still a finding', () => {
37
+ expect(inputGateNoticeFor(run({ status: 'passed', issues: thin }))?.tone).toBe('advisory')
38
+ })
39
+
40
+ it.each(['passed', 'off', 'not_applicable'] as const)(
41
+ 'says nothing about a %s verdict with no findings',
42
+ (status) => {
43
+ expect(inputGateNoticeFor(run({ status, issues: [] }))).toBeNull()
44
+ },
45
+ )
46
+
47
+ it('says nothing when the gate has not evaluated the run yet, or there is no run', () => {
48
+ expect(inputGateNoticeFor(run())).toBeNull()
49
+ expect(inputGateNoticeFor(null)).toBeNull()
50
+ expect(inputGateNoticeFor(undefined)).toBeNull()
51
+ })
52
+ })
@@ -0,0 +1,44 @@
1
+ // Which PRE-DISPATCH INPUT GATE verdicts a run surfaces, and how they are presented.
2
+ //
3
+ // The gate records a verdict for EVERY disposition, including the ones where it did nothing, so
4
+ // "has a verdict" is not the same question as "has something to tell a human". This is the one
5
+ // place that answers the second one, because the run panel and the step-detail overlay both ask
6
+ // it and a per-component `status === 'blocked'` check is how they drift.
7
+
8
+ import type { ExecutionInstance } from '~/types/execution'
9
+ import type { RunInputGate } from '@cat-factory/contracts'
10
+
11
+ /**
12
+ * How a verdict reads to a human:
13
+ *
14
+ * - `blocked`: the run is parked, and the notice carries the two ways out.
15
+ * - `waived`: somebody read the blocking findings and ran anyway. Kept visible on the run that
16
+ * carries it, because what was overruled is part of what explains the output.
17
+ * - `advisory`: findings were recorded and nothing was parked. This is the whole point of
18
+ * `advisory` MODE ("watch what the gate would have caught before turning it up"), and it is
19
+ * also how `standard` mode reports a short description or a spike with no success criteria.
20
+ */
21
+ export type InputGateTone = 'blocked' | 'waived' | 'advisory'
22
+
23
+ /**
24
+ * The verdict a run should show, with the tone to show it in, or `null` when the gate has
25
+ * nothing to say.
26
+ *
27
+ * Nothing to say covers three real and different facts that happen to share a presentation:
28
+ * a verdict that has not been stamped yet, one the workspace turned `off`, and a clean `passed`.
29
+ * None of them is a message, so none of them earns a box on the panel. The distinction between
30
+ * them is preserved on the run and read by the API, not painted over here.
31
+ *
32
+ * Note what this deliberately does NOT gate on: a `passed` status. A `passed` verdict carrying
33
+ * advisories is exactly what advisory mode produces, and keying the notice off the status alone
34
+ * left every advisory finding recorded, reported over the API, and invisible in the product.
35
+ */
36
+ export function inputGateNoticeFor(
37
+ instance: ExecutionInstance | null | undefined,
38
+ ): { gate: RunInputGate; tone: InputGateTone } | null {
39
+ const gate = instance?.inputGate
40
+ if (!gate) return null
41
+ if (gate.status === 'blocked') return { gate, tone: 'blocked' }
42
+ if (gate.status === 'overridden') return { gate, tone: 'waived' }
43
+ return gate.issues.length > 0 ? { gate, tone: 'advisory' } : null
44
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import type { PipelineStep } from '~/types/execution'
2
+ import type { ExecutionInstance, PipelineStep } from '~/types/execution'
3
3
  import { dedicatedParkView } from './pipelineRender'
4
4
 
5
5
  /** A minimal coder step; the predicate only reads approval/followUps/forkDecision. */
@@ -11,6 +11,14 @@ const step = (over: Partial<PipelineStep>): PipelineStep =>
11
11
  ...over,
12
12
  }) as PipelineStep
13
13
 
14
+ /**
15
+ * A run carrying no input-gate verdict: the ordinary case for every step-shaped park below.
16
+ * Passed explicitly because `dedicatedParkView` REQUIRES the run — the gate's park is a fact
17
+ * about the run rather than the step, so a call that omitted it would silently miss it.
18
+ */
19
+ const run = (over: Partial<ExecutionInstance> = {}): ExecutionInstance =>
20
+ ({ id: 'exe_1', steps: [], ...over }) as unknown as ExecutionInstance
21
+
14
22
  const followUps = (statuses: string[]) => ({
15
23
  enabled: true,
16
24
  items: statuses.map((status, i) => ({
@@ -32,13 +40,13 @@ describe('dedicatedParkView', () => {
32
40
  // proceed" rail.
33
41
  it('owns a follow-up park (pending approval + undecided items)', () => {
34
42
  expect(
35
- dedicatedParkView(step({ followUps: followUps(['pending', 'answered']) as never })),
43
+ dedicatedParkView(step({ followUps: followUps(['pending', 'answered']) as never }), run()),
36
44
  ).toBe('follow-ups')
37
45
  })
38
46
 
39
47
  it('does not claim a step whose follow-up items are all decided', () => {
40
48
  expect(
41
- dedicatedParkView(step({ followUps: followUps(['answered', 'dismissed']) as never })),
49
+ dedicatedParkView(step({ followUps: followUps(['answered', 'dismissed']) as never }), run()),
42
50
  ).toBeNull()
43
51
  })
44
52
 
@@ -47,26 +55,56 @@ describe('dedicatedParkView', () => {
47
55
  expect(
48
56
  dedicatedParkView(
49
57
  step({ state: 'working', approval: null, followUps: followUps(['pending']) as never }),
58
+ run(),
50
59
  ),
51
60
  ).toBeNull()
52
61
  })
53
62
 
54
63
  it('owns the fork park while awaiting a choice, and while a chat reply is in flight', () => {
55
- expect(dedicatedParkView(step({ forkDecision: { status: 'awaiting_choice' } as never }))).toBe(
56
- 'fork-decision',
57
- )
58
- expect(dedicatedParkView(step({ forkDecision: { status: 'answering' } as never }))).toBe(
64
+ expect(
65
+ dedicatedParkView(step({ forkDecision: { status: 'awaiting_choice' } as never }), run()),
66
+ ).toBe('fork-decision')
67
+ expect(dedicatedParkView(step({ forkDecision: { status: 'answering' } as never }), run())).toBe(
59
68
  'fork-decision',
60
69
  )
61
70
  })
62
71
 
63
72
  it('releases the step once the fork is resolved (chosen / single_path / skipped)', () => {
64
73
  for (const status of ['chosen', 'single_path', 'skipped', 'proposing']) {
65
- expect(dedicatedParkView(step({ forkDecision: { status } as never }))).toBeNull()
74
+ expect(dedicatedParkView(step({ forkDecision: { status } as never }), run())).toBeNull()
66
75
  }
67
76
  })
68
77
 
69
78
  it('leaves a plain approval park to the generic rail', () => {
70
- expect(dedicatedParkView(step({}))).toBeNull()
79
+ expect(dedicatedParkView(step({}), run())).toBeNull()
80
+ })
81
+
82
+ // The PRE-DISPATCH INPUT GATE parks whatever step 0 happens to be and leaves nothing
83
+ // kind-specific on the step, so it is recognised off the RUN. The generic approve resolver
84
+ // refuses it server-side: approving it would mark the run's first working step done and skip
85
+ // the work the run exists to do.
86
+ it('owns a step whose park is the input gate, read off the run', () => {
87
+ const blocked = run({
88
+ inputGate: { status: 'blocked', mode: 'standard', issues: [], checkedAt: 1 },
89
+ } as never)
90
+ expect(dedicatedParkView(step({}), blocked)).toBe('input-gate')
91
+ })
92
+
93
+ it('releases the step once the gate is waived or passed', () => {
94
+ for (const status of ['overridden', 'passed', 'off', 'not_applicable']) {
95
+ const settled = run({
96
+ inputGate: { status, mode: 'standard', issues: [], checkedAt: 1 },
97
+ } as never)
98
+ expect(dedicatedParkView(step({}), settled)).toBeNull()
99
+ }
100
+ })
101
+
102
+ it('does not claim a step with no pending approval, whatever the gate says', () => {
103
+ // The gate's verdict alone must not turn an unparked step into a dedicated park: a run
104
+ // parked on the gate has exactly one step holding the approval.
105
+ const blocked = run({
106
+ inputGate: { status: 'blocked', mode: 'standard', issues: [], checkedAt: 1 },
107
+ } as never)
108
+ expect(dedicatedParkView(step({ approval: null, state: 'working' }), blocked)).toBeNull()
71
109
  })
72
110
  })
@@ -2,7 +2,7 @@
2
2
  // TaskPipelineMini, AgentStepDetail), so the "is this step still live?" logic stays
3
3
  // in one place rather than being re-derived as inline ternaries per component.
4
4
 
5
- import type { AgentState, PipelineStep } from '~/types/execution'
5
+ import type { AgentState, ExecutionInstance, PipelineStep } from '~/types/execution'
6
6
 
7
7
  /**
8
8
  * Visual state of a conditionally-run companion attached to a gate step (today the
@@ -141,8 +141,29 @@ export function isCompanionKind(kind: string): boolean {
141
141
  * server-side (`assertNotIterativeGate`), so every surface that offers a step's pending
142
142
  * approval must route these to their window instead of the generic "Approve & proceed"
143
143
  * rail — which would blink a 409 and resolve nothing.
144
+ *
145
+ * `input-gate` is the odd one out: it is resolved by an inline NOTICE rather than an overlay,
146
+ * because its remedy is to go and edit the task, which is a board action rather than something
147
+ * a modal could hold.
144
148
  */
145
- export function dedicatedParkView(step: PipelineStep): 'follow-ups' | 'fork-decision' | null {
149
+ export function dedicatedParkView(
150
+ step: PipelineStep,
151
+ instance: ExecutionInstance | null | undefined,
152
+ ): 'follow-ups' | 'fork-decision' | 'input-gate' | null {
153
+ // The PRE-DISPATCH INPUT GATE parks whatever step 0 happens to be, so it leaves nothing on the
154
+ // STEP to recognise it by: its verdict is a fact about the RUN. Checked first, and off the
155
+ // instance: approving it generically would mark the run's first working step done and skip
156
+ // the work the run exists to do.
157
+ //
158
+ // `instance` is REQUIRED, and nullable rather than optional on purpose. Every park surface has
159
+ // the run in hand, and an optional parameter is how one of them silently stops passing it: the
160
+ // function would go on returning `null` for a gate-parked step, which each caller reads as
161
+ // "the generic approve rail applies" — the exact 409-blinking rail this exists to prevent.
162
+ // It still ACCEPTS an absent run (a store lookup that has not resolved), because that is a real
163
+ // state a caller has to be able to express; what it does not accept is not being asked.
164
+ if (instance?.inputGate?.status === 'blocked' && step.approval?.status === 'pending') {
165
+ return 'input-gate'
166
+ }
146
167
  // The fork park sits BEFORE the coder's build dispatch; `answering` (a chat turn in
147
168
  // flight) still belongs to the fork window, which renders the pending reply.
148
169
  const fork = step.forkDecision?.status
@@ -949,6 +949,16 @@
949
949
  "saveFailed": "Einstellungen konnten nicht gespeichert werden",
950
950
  "budgetSaved": "Budget gespeichert",
951
951
  "budgetSaveFailed": "Budget konnte nicht gespeichert werden"
952
+ },
953
+ "inputGate": {
954
+ "heading": "Eingabeprüfung vor dem Start eines Laufs",
955
+ "body": "Prüft den Wortlaut einer Aufgabe, bevor der erste Agentenschritt startet, damit eine unbearbeitbare Aufgabe ohne Verbrauch stoppt. Standard hält den Lauf an bei leerer oder Platzhalter-Beschreibung, einem Fehler ohne Reproduktionskontext oder einer Review-Aufgabe ohne Pull Request.",
956
+ "mode": "Modus",
957
+ "modes": {
958
+ "standard": "Standard (bei blockierender Lücke anhalten)",
959
+ "advisory": "Hinweis (erfassen, nie anhalten)",
960
+ "off": "Aus (Prüfung überspringen)"
961
+ }
952
962
  }
953
963
  },
954
964
  "localModelEndpoints": {
@@ -2661,6 +2671,7 @@
2661
2671
  "continue": "Weiter",
2662
2672
  "submit": "Aufgabe hinzufügen",
2663
2673
  "addFailedTitle": "Aufgabe konnte nicht hinzugefügt werden",
2674
+ "customFieldsInvalid": "Das Formular dieses Aufgabentyps hat sich seit dem Öffnen geändert, daher passen die Antworten nicht mehr zu den Angaben, die es verlangt. Schließen Sie den Dialog und öffnen Sie ihn erneut, um die aktuellen Felder auszufüllen.",
2664
2675
  "linkFailed": "Aufgabe hinzugefügt, aber {count} Anhang konnte nicht verknüpft werden | Aufgabe hinzugefügt, aber {count} Anhänge konnten nicht verknüpft werden",
2665
2676
  "review": {
2666
2677
  "prUrl": "Pull Request",
@@ -4804,7 +4815,6 @@
4804
4815
  "titlePlaceholder": "z. B. Die API auf das neue Auth-Modell migrieren",
4805
4816
  "goalField": "Ziel",
4806
4817
  "goalPlaceholder": "Beschreiben Sie das Ziel, die Einschränkungen und den groben Umfang. Der Planer verfeinert dies zu einem mehrphasigen Plan.",
4807
- "pathInvalid": "Geben Sie einen Pfad innerhalb des Repositorys an (kein \"..\", keine absoluten Pfade und keine Backslashes).",
4808
4818
  "hint": "Es läuft noch nichts: Führen Sie nach dem Erstellen die Initiative-Planning-Pipeline auf dem Block aus. Sie analysiert die Codebasis und entwirft den mehrphasigen Plan zu Ihrer Freigabe.",
4809
4819
  "submit": "Initiative erstellen",
4810
4820
  "failedTitle": "Die Initiative konnte nicht erstellt werden",
@@ -5017,7 +5027,8 @@
5017
5027
  "confirm": "Verwerfen",
5018
5028
  "keep": "Weiter bearbeiten"
5019
5029
  },
5020
- "edit": "Bearbeiten"
5030
+ "edit": "Bearbeiten",
5031
+ "pathInvalid": "Geben Sie einen Pfad innerhalb des Repositorys an (kein \"..\", keine absoluten Pfade und keine Backslashes)."
5021
5032
  },
5022
5033
  "access": {
5023
5034
  "noBoardWrite": "Nur-Lese-Zugriff: Sie können dieses Board ansehen, aber nicht bearbeiten.",
@@ -5142,6 +5153,8 @@
5142
5153
  "binary_output_service_invalid": "Dienst für Binärausgaben nicht auflösbar",
5143
5154
  "binary_output_generator_invalid": "Generator für Binärausgaben nicht auflösbar",
5144
5155
  "foundational_service_not_inherited": "Dieses Board hat den Dienst registriert",
5156
+ "input_gate_not_parked": "Nichts zu beantworten",
5157
+ "input_gate_parked": "Über die Eingabeprüfung der Aufgabe beantworten",
5145
5158
  "ticket_already_linked": "Dieses Ticket hat bereits eine Aufgabe",
5146
5159
  "dry_run_not_mergeable": "Probelauf kann nicht zusammengeführt werden"
5147
5160
  },
@@ -5176,6 +5189,8 @@
5176
5189
  "binary_output_service_invalid": "Ein Schritt, der Binärausgaben erzeugt, wählt einen Basisdienst aus, den der Katalog dieses Workspace nicht auflösen kann: Die ID ist unbekannt, oder der gewählte Speicherdienst trägt nicht die Fähigkeit asset-storage. Korrigieren Sie die Auswahl des Schritts oder registrieren Sie den Dienst und starten Sie erneut.",
5177
5190
  "binary_output_generator_invalid": "Ein Schritt, der Binärausgaben erzeugt, wählt eine generative Integration aus, die diese Installation nicht registriert, oder keine der gewählten Integrationen erzeugt einen Inhaltstyp, den der Schritt liefern muss. Generative Integrationen werden im Code der Installation registriert, nicht in diesem Workspace: registrieren Sie sie oder korrigieren Sie die Auswahl des Schritts und starten Sie erneut.",
5178
5191
  "foundational_service_not_inherited": "Abwählen gilt für einen vom Konto geerbten Dienst. Diese ID ist von diesem Board registriert, es gibt also nichts abzuwählen - lösche stattdessen den eigenen Eintrag des Boards.",
5192
+ "input_gate_not_parked": "Dieser Lauf wartet nicht mehr auf seine Eingabeprüfung. Möglicherweise hat sie jemand schon beantwortet oder der Lauf ist weitergelaufen.",
5193
+ "input_gate_parked": "Dieser Lauf wartet auf seine Eingabeprüfung, die über die Freigabe nicht beantwortet werden kann. Nutzen Sie den Hinweis am Lauf: Aufgabe ergänzen und erneut prüfen, oder trotzdem ausführen.",
5179
5194
  "ticket_already_linked": "Ein Ticket kann nur eine Aufgabe stützen. Es erneut zu verknüpfen würde der bestehenden Aufgabe genau den Kontext entziehen, mit dem sie angelegt wurde. Öffne stattdessen diese Aufgabe oder hebe die Verknüpfung des Tickets zuerst auf.",
5180
5195
  "dry_run_not_mergeable": "Dieser Pull Request stammt aus einem Probelauf und kann hier nicht zusammengeführt werden. Starte die Aufgabe erneut als echten Lauf, um einen Pull Request zu erzeugen, den dieser Arbeitsbereich zusammenführt."
5181
5196
  },
@@ -5414,6 +5429,51 @@
5414
5429
  "fail": "Lauf fehlgeschlagen"
5415
5430
  }
5416
5431
  },
5432
+ "inputGate": {
5433
+ "blockedTitle": "Diese Aufgabe braucht mehr Details, bevor sie laufen kann",
5434
+ "blockedBody": "Der Lauf wurde vor dem ersten Agentenschritt gestoppt, es wurde also nichts verbraucht. Ergänze das Fehlende an der Aufgabe und prüfe erneut.",
5435
+ "waivedTitle": "Trotz unvollständiger Aufgabe gestartet",
5436
+ "waivedBody": "Jemand hat entschieden, diese Aufgabe mit den unten offenen Lücken auszuführen. Sie bleiben als Teil der Laufhistorie erhalten.",
5437
+ "advisoryTitle": "Einige Lücken wurden notiert, der Lauf lief weiter",
5438
+ "advisoryBody": "Die Eingabeprüfung der Aufgabe hat diese Punkte beim Start gefunden. Keiner davon stoppt die Arbeit, daher wurde nichts angehalten. Wer sie ergänzt, erleichtert den nächsten Lauf.",
5439
+ "severity": {
5440
+ "blocking": "Blockierend",
5441
+ "advisory": "Hinweis"
5442
+ },
5443
+ "recheck": "Aufgabe erneut prüfen",
5444
+ "proceed": "Trotzdem ausführen",
5445
+ "recheckHint": "Die erneute Prüfung liest die Aufgabe im aktuellen Stand, bearbeite sie also zuerst.",
5446
+ "issue": {
5447
+ "description_missing": {
5448
+ "title": "Keine Beschreibung",
5449
+ "hint": "Der Titel benennt die Aufgabe, die Beschreibung ist die Arbeitsgrundlage des Agenten. Schreibe, was sich ändern soll und warum."
5450
+ },
5451
+ "description_placeholder": {
5452
+ "title": "Platzhalter-Beschreibung",
5453
+ "hint": "Die Beschreibung ist ein Platzhalter (\"TBD\", \"n/a\", \"fix it\") statt einer Beschreibung der Arbeit."
5454
+ },
5455
+ "description_thin": {
5456
+ "title": "Sehr kurze Beschreibung",
5457
+ "hint": "Wenige Worte legen selten fest, was \"fertig\" heißt. Mehr Details hier sparen später eine Rückfragerunde."
5458
+ },
5459
+ "reproduction_missing": {
5460
+ "title": "Kein Reproduktionskontext",
5461
+ "hint": "Ein Fehler ohne Schritte, ohne Soll-Ist-Vergleich und ohne Stacktrace lässt den Behebenden nicht erkennen, wann er behoben ist."
5462
+ },
5463
+ "review_target_missing": {
5464
+ "title": "Kein Pull Request zum Prüfen",
5465
+ "hint": "Eine Review-Aufgabe braucht die Nummer oder URL des Pull Requests, den sie lesen soll."
5466
+ },
5467
+ "success_criteria_missing": {
5468
+ "title": "Keine Erfolgskriterien",
5469
+ "hint": "Nenne die Frage, die der Spike beantwortet, oder wie ein gutes Ergebnis aussieht, damit die Zeitbox ein Ziel hat."
5470
+ },
5471
+ "unknown": {
5472
+ "title": "Unbekannter Befund",
5473
+ "hint": "Dieser Lauf hat eine Prüfung erfasst, die diese Version nicht mehr kennt. Sieh die Aufgabe von Hand durch, bevor du fortfährst."
5474
+ }
5475
+ }
5476
+ },
5417
5477
  "consensus": {
5418
5478
  "titlePrefix": "Konsens",
5419
5479
  "participantCount": "keine Teilnehmer | ein Teilnehmer | {count} Teilnehmer",