@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.
- package/README.md +133 -2
- 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 +176 -0
- package/app/components/panels/AgentStepDetail.vue +27 -3
- package/app/components/panels/inspector/ServiceTestConfig.vue +1 -1
- package/app/components/panels/inspector/TaskExecution.vue +32 -3
- package/app/components/pipeline/PipelineProgress.vue +1 -1
- package/app/components/settings/WorkspaceSettingsPanel.vue +34 -1
- package/app/composables/api/inputGate.ts +25 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/usePipelineErrorToast.ts +8 -0
- package/app/modular/nav-contributions.spec.ts +1 -1
- package/app/modular/nav-contributions.ts +1 -1
- package/app/stores/inputGate.ts +58 -0
- package/app/stores/ui/resultViews.ts +9 -1
- package/app/stores/workspaceSettings.ts +1 -0
- package/app/types/domain.ts +7 -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.spec.ts +52 -0
- package/app/utils/inputGate.ts +44 -0
- package/app/utils/pipelineRender.spec.ts +47 -9
- package/app/utils/pipelineRender.ts +23 -2
- package/i18n/locales/de.json +62 -2
- package/i18n/locales/en.json +65 -2
- package/i18n/locales/es.json +77 -5
- package/i18n/locales/fr.json +77 -5
- package/i18n/locales/he.json +63 -2
- package/i18n/locales/it.json +62 -2
- package/i18n/locales/ja.json +62 -2
- package/i18n/locales/pl.json +77 -5
- package/i18n/locales/tr.json +62 -2
- package/i18n/locales/uk.json +77 -5
- 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
|
+
}
|
package/app/utils/initiative.ts
CHANGED
|
@@ -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(
|
|
56
|
-
'
|
|
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(
|
|
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
|
package/i18n/locales/de.json
CHANGED
|
@@ -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",
|