@cat-factory/app 0.223.0 → 0.224.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/app/components/inputGate/InputGateNotice.vue +31 -9
- package/app/components/panels/AgentStepDetail.vue +63 -6
- package/app/components/panels/inspector/TaskTypeFields.vue +115 -0
- package/app/components/pipeline/GateConfigFields.vue +187 -0
- package/app/components/pipeline/PipelineBuilder.vue +31 -0
- package/app/components/provisioning/ProvisioningLogsDrawer.vue +1 -0
- package/app/composables/useStepApproval.ts +74 -3
- package/app/modular/panels/inspector.logic.spec.ts +5 -0
- package/app/modular/panels/inspector.logic.ts +6 -0
- package/app/modular/panels/inspector.ts +2 -0
- package/app/stores/agents.ts +23 -0
- package/app/stores/pipelines/draftActions.ts +2 -0
- package/app/stores/pipelines/draftGateConfig.ts +55 -0
- package/app/stores/pipelines/draftStepConfig.ts +2 -1
- package/app/stores/pipelines.ts +20 -1
- package/app/stores/workspace/hydrate.ts +3 -0
- package/app/types/domain.ts +5 -0
- package/app/utils/catalog.companions.spec.ts +80 -0
- package/app/utils/catalog.spec.ts +1 -0
- package/app/utils/catalog.ts +77 -9
- package/i18n/locales/de.json +29 -1
- package/i18n/locales/en.json +29 -1
- package/i18n/locales/es.json +29 -1
- package/i18n/locales/fr.json +29 -1
- package/i18n/locales/he.json +29 -1
- package/i18n/locales/it.json +29 -1
- package/i18n/locales/ja.json +29 -1
- package/i18n/locales/pl.json +29 -1
- package/i18n/locales/tr.json +29 -1
- package/i18n/locales/uk.json +29 -1
- package/package.json +2 -2
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { ref, computed, nextTick } from 'vue'
|
|
2
|
+
import { refuseGateResolution, UNATTRIBUTED_GATE_ACTOR } from '@cat-factory/contracts'
|
|
3
|
+
import type { GateActor, GateApprovalRefusal } from '@cat-factory/contracts'
|
|
2
4
|
import type { PipelineStep } from '~/types/execution'
|
|
3
5
|
import { useProseComments } from '~/composables/useProseComments'
|
|
6
|
+
import { useWorkspaceAccess } from '~/composables/useWorkspaceAccess'
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* The GitHub-style approval/review state machine for a pending gate step. When the
|
|
@@ -23,6 +26,8 @@ export function useStepApproval(opts: {
|
|
|
23
26
|
close: () => void
|
|
24
27
|
}) {
|
|
25
28
|
const execution = useExecutionStore()
|
|
29
|
+
const auth = useAuthStore()
|
|
30
|
+
const access = useWorkspaceAccess()
|
|
26
31
|
|
|
27
32
|
const feedback = ref('')
|
|
28
33
|
const submitting = ref(false)
|
|
@@ -60,12 +65,71 @@ export function useStepApproval(opts: {
|
|
|
60
65
|
() => !!feedback.value.trim() || reviewComments.value.length > 0,
|
|
61
66
|
)
|
|
62
67
|
|
|
68
|
+
// ---- The gate's own POLICY, as the pipeline step configured it -------------------------
|
|
69
|
+
//
|
|
70
|
+
// Read from the SAME `@cat-factory/contracts` rules the engine enforces, never a local
|
|
71
|
+
// reimplementation: a button enabled by a second copy of the rule is a request the server
|
|
72
|
+
// refuses, and the person pressing it has no way to tell which of the two is right.
|
|
73
|
+
|
|
74
|
+
const approval = computed(() => opts.step()?.approval ?? null)
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The gate's quorum, or null when it needs the usual single approval. Non-null is what makes
|
|
78
|
+
* the rail say "1 of 2 approvals" — otherwise an approve that correctly leaves the run parked
|
|
79
|
+
* looks exactly like one that failed.
|
|
80
|
+
*/
|
|
81
|
+
const quorum = computed(() => {
|
|
82
|
+
const required = approval.value?.requiredApprovals ?? 1
|
|
83
|
+
if (required <= 1) return null
|
|
84
|
+
return { required, recorded: approval.value?.approvals?.length ?? 0 }
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/** Whether the viewer's own approval is already counted (so the rail can say so). */
|
|
88
|
+
const viewerHasApproved = computed(() => {
|
|
89
|
+
const userId = auth.user?.id
|
|
90
|
+
return !!userId && !!approval.value?.approvals?.some((a) => a.actorId === userId)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether the viewer's approval would be the one that CLEARS the gate. Always true without a
|
|
95
|
+
* quorum; under one it folds the viewer in the way the server does, so a re-approval by someone
|
|
96
|
+
* already counted does not read as a new vote.
|
|
97
|
+
*
|
|
98
|
+
* This is what decides whether "approve with corrections" is offered: a quorum votes on ONE
|
|
99
|
+
* artifact, so an edit that does not clear the gate would rewrite the proposal under the people
|
|
100
|
+
* already counted toward it and the ones still to come. The server refuses that
|
|
101
|
+
* (`proposal_not_editable_until_quorum`); hiding the affordance is what stops a reviewer typing
|
|
102
|
+
* a correction into a dead end, the same disposition as `outputIsRendered`.
|
|
103
|
+
*/
|
|
104
|
+
const approvalWouldClearGate = computed(() => {
|
|
105
|
+
const q = quorum.value
|
|
106
|
+
if (!q) return true
|
|
107
|
+
return (viewerHasApproved.value ? q.recorded : q.recorded + 1) >= q.required
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Why the viewer may not resolve this gate, or null when they may. Drives the disabled state of
|
|
112
|
+
* all three verbs, since the policy governs every resolution and not just approve.
|
|
113
|
+
*
|
|
114
|
+
* With auth off there is no signed-in user, and the actor is `unattributed` — exactly what the
|
|
115
|
+
* server will decide with, so the rail refuses ahead of it rather than offering a button whose
|
|
116
|
+
* request comes back 403.
|
|
117
|
+
*/
|
|
118
|
+
const refusal = computed<GateApprovalRefusal | null>(() => {
|
|
119
|
+
if (!approval.value) return null
|
|
120
|
+
const user = auth.user
|
|
121
|
+
const actor: GateActor = user
|
|
122
|
+
? { id: user.id, kind: 'user', role: access.role.value }
|
|
123
|
+
: { id: UNATTRIBUTED_GATE_ACTOR, kind: 'unattributed', role: access.role.value }
|
|
124
|
+
return refuseGateResolution(approval.value.approverPolicy, actor)
|
|
125
|
+
})
|
|
126
|
+
|
|
63
127
|
// Plain approve: accept the agent's proposal verbatim and advance. Every action below
|
|
64
128
|
// closes the overlay ONLY when the command actually ran — a server refusal (surfaced as
|
|
65
129
|
// a toast by the store) or a cancelled credential prompt keeps the review open.
|
|
66
130
|
async function approve() {
|
|
67
131
|
const id = opts.approvalId()
|
|
68
|
-
if (!opts.instanceId() || !id || submitting.value) return
|
|
132
|
+
if (!opts.instanceId() || !id || submitting.value || refusal.value) return
|
|
69
133
|
submitting.value = true
|
|
70
134
|
try {
|
|
71
135
|
if (await execution.approveStep(opts.instanceId()!, id)) opts.close()
|
|
@@ -88,7 +152,9 @@ export function useStepApproval(opts: {
|
|
|
88
152
|
}
|
|
89
153
|
async function approveWithEdits() {
|
|
90
154
|
const id = opts.approvalId()
|
|
91
|
-
if (!opts.instanceId() || !id || submitting.value) return
|
|
155
|
+
if (!opts.instanceId() || !id || submitting.value || refusal.value) return
|
|
156
|
+
// The server refuses an edit that does not clear the gate; never send one.
|
|
157
|
+
if (!approvalWouldClearGate.value) return
|
|
92
158
|
submitting.value = true
|
|
93
159
|
try {
|
|
94
160
|
if (await execution.approveStep(opts.instanceId()!, id, draftProposal.value)) opts.close()
|
|
@@ -99,6 +165,7 @@ export function useStepApproval(opts: {
|
|
|
99
165
|
async function requestChanges() {
|
|
100
166
|
const id = opts.approvalId()
|
|
101
167
|
if (!opts.instanceId() || !id || submitting.value || !canRequestChanges.value) return
|
|
168
|
+
if (refusal.value) return
|
|
102
169
|
submitting.value = true
|
|
103
170
|
try {
|
|
104
171
|
const ok = await execution.requestStepChanges(opts.instanceId()!, id, {
|
|
@@ -118,7 +185,7 @@ export function useStepApproval(opts: {
|
|
|
118
185
|
}
|
|
119
186
|
async function reject() {
|
|
120
187
|
const id = opts.approvalId()
|
|
121
|
-
if (!opts.instanceId() || !id || submitting.value) return
|
|
188
|
+
if (!opts.instanceId() || !id || submitting.value || refusal.value) return
|
|
122
189
|
submitting.value = true
|
|
123
190
|
try {
|
|
124
191
|
if (await execution.rejectStep(opts.instanceId()!, id, feedback.value.trim() || undefined)) {
|
|
@@ -159,6 +226,10 @@ export function useStepApproval(opts: {
|
|
|
159
226
|
draftProposal,
|
|
160
227
|
rejectArmed,
|
|
161
228
|
canRequestChanges,
|
|
229
|
+
quorum,
|
|
230
|
+
viewerHasApproved,
|
|
231
|
+
approvalWouldClearGate,
|
|
232
|
+
refusal,
|
|
162
233
|
syncHighlights,
|
|
163
234
|
onProseClick,
|
|
164
235
|
addDraftComment,
|
|
@@ -97,6 +97,11 @@ describe('inspector panel group', () => {
|
|
|
97
97
|
'task-dependencies',
|
|
98
98
|
'task-run-settings',
|
|
99
99
|
'task-agent-config',
|
|
100
|
+
// The custom type's own declared fields sit with the other task INPUTS (what the task is),
|
|
101
|
+
// not under run settings (how it runs). It is gated on being a task alone: the panel hides
|
|
102
|
+
// itself unless the type is one this deployment registered with descriptor fields, which
|
|
103
|
+
// the spec here cannot see and should not try to.
|
|
104
|
+
'task-type-fields',
|
|
100
105
|
'task-structure',
|
|
101
106
|
])
|
|
102
107
|
})
|
|
@@ -45,6 +45,7 @@ export const INSPECTOR_PANEL_IDS = [
|
|
|
45
45
|
'task-dependencies',
|
|
46
46
|
'task-run-settings',
|
|
47
47
|
'task-agent-config',
|
|
48
|
+
'task-type-fields',
|
|
48
49
|
'task-structure',
|
|
49
50
|
// service / module body
|
|
50
51
|
'container-summary',
|
|
@@ -134,6 +135,11 @@ export const INSPECTOR_PANEL_SPECS: readonly InspectorPanelSpec[] = [
|
|
|
134
135
|
{ id: 'task-dependencies', order: 60, when: isTask },
|
|
135
136
|
{ id: 'task-run-settings', order: 70, when: isTask },
|
|
136
137
|
{ id: 'task-agent-config', order: 80, when: isTask },
|
|
138
|
+
// The answers to a CUSTOM task type's declared fields. Gated on being a task alone; the panel
|
|
139
|
+
// itself hides unless the task's type is one this deployment registered WITH descriptor fields,
|
|
140
|
+
// which is the only case there is anything to edit. It sits beside the other task inputs rather
|
|
141
|
+
// than under Run settings: these are what the task IS, not how it runs.
|
|
142
|
+
{ id: 'task-type-fields', order: 85, when: isTask },
|
|
137
143
|
{ id: 'task-structure', order: 90, when: isTask },
|
|
138
144
|
{ id: 'container-summary', order: 110, when: isContainer },
|
|
139
145
|
{ id: 'frontend-config', order: 120, when: (b) => isFrame(b) && b.type === 'frontend' },
|
|
@@ -21,6 +21,7 @@ import TaskEstimateBadge from '~/components/panels/inspector/TaskEstimateBadge.v
|
|
|
21
21
|
import TaskDependencies from '~/components/panels/inspector/TaskDependencies.vue'
|
|
22
22
|
import TaskRunSettings from '~/components/panels/inspector/TaskRunSettings.vue'
|
|
23
23
|
import TaskAgentConfig from '~/components/panels/inspector/TaskAgentConfig.vue'
|
|
24
|
+
import TaskTypeFields from '~/components/panels/inspector/TaskTypeFields.vue'
|
|
24
25
|
import TaskStructure from '~/components/panels/inspector/TaskStructure.vue'
|
|
25
26
|
import ContainerSummary from '~/components/panels/inspector/ContainerSummary.vue'
|
|
26
27
|
import FrontendConfig from '~/components/panels/inspector/FrontendConfig.vue'
|
|
@@ -75,6 +76,7 @@ const COMPONENTS: Record<InspectorPanelId, Component> = {
|
|
|
75
76
|
'task-dependencies': TaskDependencies,
|
|
76
77
|
'task-run-settings': TaskRunSettings,
|
|
77
78
|
'task-agent-config': TaskAgentConfig,
|
|
79
|
+
'task-type-fields': TaskTypeFields,
|
|
78
80
|
'task-structure': TaskStructure,
|
|
79
81
|
'container-summary': ContainerSummary,
|
|
80
82
|
'frontend-config': FrontendConfig,
|
package/app/stores/agents.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
AGENT_ARCHETYPES,
|
|
8
8
|
AGENT_BY_KIND,
|
|
9
9
|
setCustomAgentKindMeta,
|
|
10
|
+
setCustomCompanionTargets,
|
|
10
11
|
SYSTEM_AGENT_META,
|
|
11
12
|
uid,
|
|
12
13
|
} from '~/utils/catalog'
|
|
@@ -105,6 +106,28 @@ export const useAgentsStore = defineStore('agents', () => {
|
|
|
105
106
|
// no tick gap. The watch lives in the store's effect scope (disposed with it).
|
|
106
107
|
watch(customByKind, (map) => setCustomAgentKindMeta(map), { immediate: true, flush: 'sync' })
|
|
107
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The custom COMPANION pairings (companion kind → the producer kinds it reviews), read off the
|
|
111
|
+
* same custom-kind sources the palette is built from. Kept apart from `customByKind` because a
|
|
112
|
+
* pairing is not display metadata: the builder uses it to decide a kind is a TOGGLE on its
|
|
113
|
+
* producer rather than a placeable block, and `AgentArchetype` has no business carrying it.
|
|
114
|
+
*/
|
|
115
|
+
const customCompanions = computed<Record<string, readonly AgentKind[]>>(() => {
|
|
116
|
+
const out: Record<string, readonly AgentKind[]> = {}
|
|
117
|
+
const add = (k: CustomAgentKind) => {
|
|
118
|
+
// A pairing with no targets is not a pairing. Recording it would make the kind vanish from
|
|
119
|
+
// the palette (an `isProducerCompanion` hit) with no producer to hang the toggle on.
|
|
120
|
+
if (k.companionTargets?.length) out[k.kind] = k.companionTargets
|
|
121
|
+
}
|
|
122
|
+
for (const k of consumerKinds.value) add(k)
|
|
123
|
+
for (const k of capabilitiesManifest.value?.slots?.agentKinds ?? []) add(k)
|
|
124
|
+
return out
|
|
125
|
+
})
|
|
126
|
+
watch(customCompanions, (map) => setCustomCompanionTargets(map), {
|
|
127
|
+
immediate: true,
|
|
128
|
+
flush: 'sync',
|
|
129
|
+
})
|
|
130
|
+
|
|
108
131
|
/** Display metadata for a KNOWN kind (built-in / system / custom), else undefined. */
|
|
109
132
|
function get(kind: AgentKind): AgentArchetype | undefined {
|
|
110
133
|
return AGENT_BY_KIND[kind] ?? SYSTEM_AGENT_META[kind] ?? customByKind.value[kind]
|
|
@@ -2,6 +2,7 @@ import { computed } from 'vue'
|
|
|
2
2
|
import type { AgentKind, Pipeline } from '~/types/domain'
|
|
3
3
|
import { companionForProducer } from '~/utils/catalog'
|
|
4
4
|
import type { PipelinesContext } from './context'
|
|
5
|
+
import { createPipelineGateConfigActions } from './draftGateConfig'
|
|
5
6
|
import { createPipelineStepConfigActions } from './draftStepConfig'
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -192,6 +193,7 @@ export function createPipelineDraftActions(ctx: PipelinesContext) {
|
|
|
192
193
|
|
|
193
194
|
return {
|
|
194
195
|
...createPipelineStepConfigActions(ctx),
|
|
196
|
+
...createPipelineGateConfigActions(ctx),
|
|
195
197
|
addToDraft,
|
|
196
198
|
removeFromDraft,
|
|
197
199
|
moveInDraft,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { StepGateConfig, StepOptions } from '@cat-factory/contracts'
|
|
2
|
+
import { hasApproverPolicy } from '@cat-factory/contracts'
|
|
3
|
+
import type { PipelinesContext } from './context'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The pipeline-builder draft's per-step GATE configuration: who may clear a step's human approval
|
|
7
|
+
* gate, how many of them must, and the parameters the step's registered gate declares.
|
|
8
|
+
*
|
|
9
|
+
* Its own module rather than another pair of accessors on `./draftStepConfig`, which owns the flat
|
|
10
|
+
* per-step options (a skill id, a variant id, a token ceiling — each one field, read and written
|
|
11
|
+
* whole). Gate config is the one nested value there: it MERGES a patch across two independently
|
|
12
|
+
* edited halves and normalizes at three levels on the way out, which is a different enough shape to
|
|
13
|
+
* be worth reading on its own. (It also put that file over the per-function line budget, which is
|
|
14
|
+
* the ratchet doing its job rather than a number to raise.)
|
|
15
|
+
*/
|
|
16
|
+
export function createPipelineGateConfigActions(ctx: PipelinesContext) {
|
|
17
|
+
const { draftStepOptions } = ctx
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The gate configuration on the draft step at `index`, or undefined when the step takes every
|
|
21
|
+
* default (one approval from anyone entitled to write, and the gate's shipped knobs).
|
|
22
|
+
*/
|
|
23
|
+
function draftGateConfig(index: number): StepGateConfig | undefined {
|
|
24
|
+
return draftStepOptions.value[index]?.gateConfig
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Merge a PATCH into the draft step's gate configuration. A patch rather than a whole-value set
|
|
29
|
+
* because the builder edits the two halves through different controls: a whole-value write from
|
|
30
|
+
* the approver fields would drop the gate's own parameters, and vice versa.
|
|
31
|
+
*
|
|
32
|
+
* Normalizes downward at every level: a field set back to its default is deleted, an emptied
|
|
33
|
+
* `gateConfig` is dropped, and an emptied options bag becomes `null`. So a step returned to the
|
|
34
|
+
* defaults persists nothing at all — the same rule the other per-step options follow, and what
|
|
35
|
+
* keeps an all-default pipeline from growing a `step_options` array it does not need.
|
|
36
|
+
*
|
|
37
|
+
* An empty approver policy is dropped rather than stored, and that one is not just tidiness:
|
|
38
|
+
* `{ roles: [] }` names nobody, and a policy naming nobody would refuse every approver and park
|
|
39
|
+
* the run forever. "No rule" has to persist as an ABSENT rule.
|
|
40
|
+
*/
|
|
41
|
+
function patchDraftGateConfig(index: number, patch: Partial<StepGateConfig>) {
|
|
42
|
+
const gateConfig: StepGateConfig = { ...draftGateConfig(index), ...patch }
|
|
43
|
+
if (!hasApproverPolicy(gateConfig.approvers)) delete gateConfig.approvers
|
|
44
|
+
if (gateConfig.minApprovals !== undefined && gateConfig.minApprovals <= 1) {
|
|
45
|
+
delete gateConfig.minApprovals
|
|
46
|
+
}
|
|
47
|
+
if (gateConfig.fields && Object.keys(gateConfig.fields).length === 0) delete gateConfig.fields
|
|
48
|
+
const next: StepOptions = { ...draftStepOptions.value[index] }
|
|
49
|
+
if (Object.keys(gateConfig).length) next.gateConfig = gateConfig
|
|
50
|
+
else delete next.gateConfig
|
|
51
|
+
draftStepOptions.value[index] = Object.keys(next).length ? next : null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { draftGateConfig, patchDraftGateConfig }
|
|
55
|
+
}
|
|
@@ -7,7 +7,8 @@ import { defaultConsensusConfig, type PipelinesContext } from './context'
|
|
|
7
7
|
* consensus-GROUP tier set), the human approval gate, the estimate gate on a companion step, the
|
|
8
8
|
* follow-up and test-QC companions, the per-step enable flag, and the `StepOptions` bag
|
|
9
9
|
* (requirements auto-recommendation, the picked skill, the picked agent-kind variant, the
|
|
10
|
-
* per-step output-token ceiling, the binary-output storage/context selection).
|
|
10
|
+
* per-step output-token ceiling, the binary-output storage/context selection). The step's GATE
|
|
11
|
+
* configuration is the sibling `./draftGateConfig`, which explains why it is not here.
|
|
11
12
|
*
|
|
12
13
|
* Split out of `./draftActions`, which owns the draft's STRUCTURE (insert / remove / reorder /
|
|
13
14
|
* units). Every function here reads and writes one of the parallel per-step arrays at an index and
|
package/app/stores/pipelines.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { ref } from 'vue'
|
|
3
3
|
import type { Pipeline } from '~/types/domain'
|
|
4
|
-
import type { PipelinePurpose, RetiredPipelineWire } from '@cat-factory/contracts'
|
|
4
|
+
import type { GateConfigForm, PipelinePurpose, RetiredPipelineWire } from '@cat-factory/contracts'
|
|
5
5
|
import { useUpsertList } from '~/composables/useUpsertList'
|
|
6
6
|
import { createDraftStepState, type PipelinesContext } from '~/stores/pipelines/context'
|
|
7
7
|
import { createPipelineDraftActions } from '~/stores/pipelines/draftActions'
|
|
@@ -39,6 +39,13 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
39
39
|
* `usePipelineHealth`). Disjoint from {@link catalogVersions} by construction.
|
|
40
40
|
*/
|
|
41
41
|
const retiredPipelines = ref<RetiredPipelineWire[]>([])
|
|
42
|
+
/**
|
|
43
|
+
* The per-step parameters each registered GATE declares, projected from the deployment's gate
|
|
44
|
+
* registry onto the board snapshot. The builder renders a gated step's own config form from
|
|
45
|
+
* these, so what it can save is exactly what run admission validates. Empty on a deployment
|
|
46
|
+
* whose gates declare nothing.
|
|
47
|
+
*/
|
|
48
|
+
const gateConfigForms = ref<GateConfigForm[]>([])
|
|
42
49
|
|
|
43
50
|
// The per-step, index-aligned draft arrays (kept in lockstep — see `createDraftStepState`).
|
|
44
51
|
const {
|
|
@@ -83,6 +90,16 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
83
90
|
retiredPipelines.value = retired ?? []
|
|
84
91
|
}
|
|
85
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Replace the registered gates' declared config forms from a snapshot. Applied even when EMPTY,
|
|
95
|
+
* for the reason `retired` is: carrying the previous board's forward would offer a form for a
|
|
96
|
+
* parameter this deployment's gates do not declare, and a value saved through it would then be
|
|
97
|
+
* refused at save by the very registry that never declared it.
|
|
98
|
+
*/
|
|
99
|
+
function hydrateGateConfigForms(forms: GateConfigForm[]) {
|
|
100
|
+
gateConfigForms.value = forms
|
|
101
|
+
}
|
|
102
|
+
|
|
86
103
|
function getPipeline(id: string) {
|
|
87
104
|
return pipelines.value.find((p) => p.id === id)
|
|
88
105
|
}
|
|
@@ -117,6 +134,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
117
134
|
pipelines,
|
|
118
135
|
catalogVersions,
|
|
119
136
|
retiredPipelines,
|
|
137
|
+
gateConfigForms,
|
|
120
138
|
draft,
|
|
121
139
|
draftGates,
|
|
122
140
|
draftEnabled,
|
|
@@ -132,6 +150,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
132
150
|
draftDescription,
|
|
133
151
|
editingId,
|
|
134
152
|
hydrate,
|
|
153
|
+
hydrateGateConfigForms,
|
|
135
154
|
getPipeline,
|
|
136
155
|
...draftActions,
|
|
137
156
|
...persistence,
|
|
@@ -114,6 +114,9 @@ export function applySnapshotToStores(snapshot: WorkspaceSnapshot, boardSince?:
|
|
|
114
114
|
snapshot.binaryGeneratorsUnavailable === true,
|
|
115
115
|
)
|
|
116
116
|
useTaskTypesStore().hydrateCapabilities(capabilities)
|
|
117
|
+
// The per-step parameters each registered gate declares, so a gated step's config form in the
|
|
118
|
+
// builder comes from the gate's own registration rather than a form hard-coded per gate.
|
|
119
|
+
usePipelinesStore().hydrateGateConfigForms(snapshot.gateConfigForms ?? [])
|
|
117
120
|
// The account's repo-sourced Claude Skills catalog (shared across its workspaces), so the
|
|
118
121
|
// pipeline builder's per-step skill picker has its options. A straight replace.
|
|
119
122
|
useSkillsStore().hydrate(snapshot.skills ?? [])
|
package/app/types/domain.ts
CHANGED
|
@@ -73,6 +73,11 @@ export type {
|
|
|
73
73
|
DescriptorField,
|
|
74
74
|
DescriptorFieldValue,
|
|
75
75
|
DescriptorFieldValues,
|
|
76
|
+
// Per-step GATE configuration: who may resolve a human approval gate, how many of them, and
|
|
77
|
+
// the parameters the step's registered gate declares (`contracts/src/gate-config.ts`).
|
|
78
|
+
GateApproverPolicy,
|
|
79
|
+
GateConfigForm,
|
|
80
|
+
StepGateConfig,
|
|
76
81
|
Pipeline,
|
|
77
82
|
PipelinePurpose,
|
|
78
83
|
SpendStatus,
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import type { AgentKind } from '~/types/domain'
|
|
3
|
+
import {
|
|
4
|
+
COMPANION_FOR_PRODUCER,
|
|
5
|
+
__resetCustomCompanionTargetsForTest,
|
|
6
|
+
companionForProducer,
|
|
7
|
+
isProducerCompanion,
|
|
8
|
+
setCustomCompanionTargets,
|
|
9
|
+
} from '~/utils/catalog'
|
|
10
|
+
|
|
11
|
+
// The SPA half of the companion registry. A companion is not a placeable palette block: the
|
|
12
|
+
// builder renders it as an "add companion" toggle ON its producer and inserts it immediately
|
|
13
|
+
// after. These two lookups are what decide that, so a deployment's registered pair is either
|
|
14
|
+
// visible as a toggle or invisible entirely, with nothing in between.
|
|
15
|
+
//
|
|
16
|
+
// The backend half is `extension-registries.companions.test.ts`. Both are needed: the backend
|
|
17
|
+
// enforces the adjacency rule on save, and this decides whether a person can ever express the
|
|
18
|
+
// pairing in the first place.
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
__resetCustomCompanionTargetsForTest()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('custom companion pairings in the palette', () => {
|
|
25
|
+
it('has no opinion about a deployment’s pair until the store projects it', () => {
|
|
26
|
+
// The pre-registration state is a real one: the snapshot arrives after first paint, and a
|
|
27
|
+
// custom companion must degrade to an ordinary kind rather than to a broken toggle.
|
|
28
|
+
expect(isProducerCompanion('acme:migration-auditor')).toBe(false)
|
|
29
|
+
expect(companionForProducer('acme:migrator')).toBeUndefined()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('renders a registered pair as a toggle on its producer', () => {
|
|
33
|
+
setCustomCompanionTargets({ 'acme:migration-auditor': ['acme:migrator'] })
|
|
34
|
+
expect(companionForProducer('acme:migrator')).toBe('acme:migration-auditor')
|
|
35
|
+
// ...and the companion itself leaves the palette, which is the other half of "it is a
|
|
36
|
+
// toggle, not a block". Registering only one of these would show the kind twice.
|
|
37
|
+
expect(isProducerCompanion('acme:migration-auditor')).toBe(true)
|
|
38
|
+
// The producer is not a companion just for being reviewed by one.
|
|
39
|
+
expect(isProducerCompanion('acme:migrator')).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('lets one companion review several producers', () => {
|
|
43
|
+
setCustomCompanionTargets({ 'acme:auditor': ['acme:migrator', 'acme:packager'] })
|
|
44
|
+
expect(companionForProducer('acme:migrator')).toBe('acme:auditor')
|
|
45
|
+
expect(companionForProducer('acme:packager')).toBe('acme:auditor')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('never lets a deployment re-point a BUILT-IN producer at its own companion', () => {
|
|
49
|
+
// Built-ins win, matching `agentKindMeta`'s precedence and the backend registry's refusal to
|
|
50
|
+
// shadow a built-in kind. The shipped pairing is what every stock pipeline relies on, so a
|
|
51
|
+
// silent re-point would change what those pipelines do without anyone editing them.
|
|
52
|
+
setCustomCompanionTargets({ 'acme:reviewer': ['coder'] })
|
|
53
|
+
expect(companionForProducer('coder')).toBe(COMPANION_FOR_PRODUCER.coder)
|
|
54
|
+
// The custom kind still leaves the palette: it IS a companion, it just does not get `coder`.
|
|
55
|
+
expect(isProducerCompanion('acme:reviewer')).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('resolves a contested producer deterministically, first registration winning', () => {
|
|
59
|
+
// Only one toggle can hang off a producer, so two companions claiming it is a case with an
|
|
60
|
+
// answer whether or not anyone chose one. Pinning it here is what keeps the answer from
|
|
61
|
+
// being "whichever the object happened to enumerate first".
|
|
62
|
+
const contested: Record<string, readonly AgentKind[]> = {
|
|
63
|
+
'acme:first': ['acme:migrator'],
|
|
64
|
+
'acme:second': ['acme:migrator'],
|
|
65
|
+
}
|
|
66
|
+
setCustomCompanionTargets(contested)
|
|
67
|
+
expect(companionForProducer('acme:migrator')).toBe('acme:first')
|
|
68
|
+
expect(isProducerCompanion('acme:second')).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('drops a pairing when the catalog it came from goes away', () => {
|
|
72
|
+
setCustomCompanionTargets({ 'acme:migration-auditor': ['acme:migrator'] })
|
|
73
|
+
expect(companionForProducer('acme:migrator')).toBe('acme:migration-auditor')
|
|
74
|
+
// A workspace switch re-projects an empty catalog. The lookups must follow it rather than
|
|
75
|
+
// keep answering from the old deployment's registrations.
|
|
76
|
+
setCustomCompanionTargets({})
|
|
77
|
+
expect(companionForProducer('acme:migrator')).toBeUndefined()
|
|
78
|
+
expect(isProducerCompanion('acme:migration-auditor')).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
})
|
package/app/utils/catalog.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { shallowRef } from 'vue'
|
|
1
|
+
import { computed, shallowRef } from 'vue'
|
|
2
2
|
import type {
|
|
3
3
|
AgentArchetype,
|
|
4
4
|
AgentCategory,
|
|
@@ -228,6 +228,21 @@ export const AGENT_ARCHETYPES: AgentArchetype[] = [
|
|
|
228
228
|
// recreate / destroy) instead of the generic prose step-detail panel.
|
|
229
229
|
resultView: 'human-test',
|
|
230
230
|
},
|
|
231
|
+
{
|
|
232
|
+
// The `deployer`'s counterpart at the other end of the environment lifecycle, and a PALETTE
|
|
233
|
+
// block rather than a system kind precisely because deciding WHEN the environment goes away
|
|
234
|
+
// is the point of it: after the automated tester, or after a human has finished with the live
|
|
235
|
+
// URL. Without one, the TTL sweep reclaims environments on a timer long after the run
|
|
236
|
+
// settled, which is a fine backstop and cannot close the run's own teardown proof.
|
|
237
|
+
kind: 'disposer',
|
|
238
|
+
tier: 'intermediate',
|
|
239
|
+
label: 'Disposer',
|
|
240
|
+
icon: 'i-lucide-cloud-off',
|
|
241
|
+
color: '#34d399',
|
|
242
|
+
category: 'test',
|
|
243
|
+
description:
|
|
244
|
+
'Reclaims the ephemeral environments this run provisioned, and confirms they are actually gone. Place it after the last step that needs the environment.',
|
|
245
|
+
},
|
|
231
246
|
{
|
|
232
247
|
kind: 'visual-confirmation',
|
|
233
248
|
tier: 'advanced',
|
|
@@ -322,9 +337,13 @@ export const COMPANION_ARCHETYPES: AgentArchetype[] = [
|
|
|
322
337
|
]
|
|
323
338
|
|
|
324
339
|
/**
|
|
325
|
-
* Producer agent kind → its companion agent kind. Mirrors the backend
|
|
326
|
-
* (`@cat-factory/agents`). The builder shows an "add companion" toggle on
|
|
327
|
-
* found here, and inserts/removes the companion immediately after it.
|
|
340
|
+
* Producer agent kind → its companion agent kind, for the BUILT-IN pairs. Mirrors the backend
|
|
341
|
+
* `COMPANIONS` catalog (`@cat-factory/agents`). The builder shows an "add companion" toggle on
|
|
342
|
+
* a producer step found here, and inserts/removes the companion immediately after it.
|
|
343
|
+
*
|
|
344
|
+
* A DEPLOYMENT's own pair does not live here: it arrives on the snapshot as a custom agent
|
|
345
|
+
* kind carrying `companionTargets`, and is projected into {@link customCompanionTargets} by the
|
|
346
|
+
* agents store. Both are consulted below, built-ins first.
|
|
328
347
|
*/
|
|
329
348
|
export const COMPANION_FOR_PRODUCER: Record<string, AgentKind> = {
|
|
330
349
|
coder: 'reviewer',
|
|
@@ -335,18 +354,67 @@ export const COMPANION_FOR_PRODUCER: Record<string, AgentKind> = {
|
|
|
335
354
|
|
|
336
355
|
const COMPANION_KINDS: ReadonlySet<string> = new Set(COMPANION_ARCHETYPES.map((a) => a.kind))
|
|
337
356
|
|
|
338
|
-
/**
|
|
357
|
+
/**
|
|
358
|
+
* Reactive read-model of the deployment's CUSTOM companion pairings (companion kind → the
|
|
359
|
+
* producer kinds it reviews), kept in sync by the agents store from the snapshot's
|
|
360
|
+
* `customAgentKinds[].companionTargets`.
|
|
361
|
+
*
|
|
362
|
+
* A `shallowRef` for the same reason {@link customAgentKindMeta} is one: the pure lookups below
|
|
363
|
+
* must resolve a registered companion, and re-render when the catalog changes, without importing
|
|
364
|
+
* the store (circular) or mutating the frozen built-in map. Empty until the store first
|
|
365
|
+
* populates it, so a custom companion degrades to "not a companion" (an ordinary palette block),
|
|
366
|
+
* exactly as before registration.
|
|
367
|
+
*/
|
|
368
|
+
const customCompanionTargets = shallowRef<Record<string, readonly AgentKind[]>>({})
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The same projection INVERTED: producer kind → the companion that reviews it, the direction
|
|
372
|
+
* {@link companionForProducer} actually asks in. Derived rather than scanned per call, because
|
|
373
|
+
* that lookup runs once per step of every pipeline the builder renders.
|
|
374
|
+
*
|
|
375
|
+
* Inverting is also where an ambiguity has to be RESOLVED rather than left to iteration order:
|
|
376
|
+
* two registered companions may both claim a producer, and only one toggle can hang off it.
|
|
377
|
+
* First registration wins, stated here once, instead of "whichever `Object.entries` reached
|
|
378
|
+
* first" being the answer at each call site.
|
|
379
|
+
*/
|
|
380
|
+
const customCompanionByProducer = computed<Record<string, AgentKind>>(() => {
|
|
381
|
+
const out: Record<string, AgentKind> = {}
|
|
382
|
+
for (const [companion, targets] of Object.entries(customCompanionTargets.value)) {
|
|
383
|
+
for (const producer of targets) if (!(producer in out)) out[producer] = companion
|
|
384
|
+
}
|
|
385
|
+
return out
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
/** Replace the custom companion projection (called only by the agents store). */
|
|
389
|
+
export function setCustomCompanionTargets(map: Record<string, readonly AgentKind[]>): void {
|
|
390
|
+
customCompanionTargets.value = map
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Test-only: clear the custom companion projection so a spec starts from built-ins only. */
|
|
394
|
+
export function __resetCustomCompanionTargetsForTest(): void {
|
|
395
|
+
customCompanionTargets.value = {}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The companion kind that depends on a producer kind, or undefined if it has none.
|
|
400
|
+
*
|
|
401
|
+
* Built-ins win. A deployment cannot re-point `coder` at its own reviewer by registering one,
|
|
402
|
+
* for the same reason `agentKindMeta`'s precedence puts built-ins first and the backend registry
|
|
403
|
+
* never shadows a built-in kind: the shipped pairing is the one the engine's own pipelines rely
|
|
404
|
+
* on, and a silent re-point would change what every stock pipeline does.
|
|
405
|
+
*/
|
|
339
406
|
export function companionForProducer(kind: string): AgentKind | undefined {
|
|
340
|
-
return COMPANION_FOR_PRODUCER[kind]
|
|
407
|
+
return COMPANION_FOR_PRODUCER[kind] ?? customCompanionByProducer.value[kind]
|
|
341
408
|
}
|
|
342
409
|
|
|
343
410
|
/**
|
|
344
411
|
* Whether a kind is a dependent producer-companion (reviewer / architect-companion /
|
|
345
|
-
* spec-companion)
|
|
346
|
-
* Distinct from `pipelineRender`'s `isCompanionKind`, which also
|
|
412
|
+
* spec-companion, or a deployment's own): rendered as a toggle on its producer, not a
|
|
413
|
+
* standalone palette block. Distinct from `pipelineRender`'s `isCompanionKind`, which also
|
|
414
|
+
* counts the Tester's `fixer`.
|
|
347
415
|
*/
|
|
348
416
|
export function isProducerCompanion(kind: string): boolean {
|
|
349
|
-
return COMPANION_KINDS.has(kind)
|
|
417
|
+
return COMPANION_KINDS.has(kind) || kind in customCompanionTargets.value
|
|
350
418
|
}
|
|
351
419
|
|
|
352
420
|
/**
|
package/i18n/locales/de.json
CHANGED
|
@@ -1533,6 +1533,12 @@
|
|
|
1533
1533
|
"inherited": "vom Service geerbt",
|
|
1534
1534
|
"frozen": "Eingefroren: der Agent hat gestartet"
|
|
1535
1535
|
},
|
|
1536
|
+
"taskTypeFields": {
|
|
1537
|
+
"title": "Aufgabentyp-Felder",
|
|
1538
|
+
"hint": "Die Antworten, die dieser Aufgabentyp benötigt. Fehlt eine Pflichtangabe, wird ein Lauf vor dem Start angehalten.",
|
|
1539
|
+
"save": "Speichern",
|
|
1540
|
+
"revert": "Zurücksetzen"
|
|
1541
|
+
},
|
|
1536
1542
|
"dependencies": {
|
|
1537
1543
|
"title": "Hängt ab von",
|
|
1538
1544
|
"hint": "Aufgaben, die gemergt sein müssen, bevor diese laufen kann. Das Run-Steuerelement bleibt gesperrt, bis jede Abhängigkeit erledigt ist.",
|
|
@@ -1979,7 +1985,12 @@
|
|
|
1979
1985
|
"notRun": "nicht ausgeführt",
|
|
1980
1986
|
"attempts": "Versuch {attempts} von {maxAttempts}",
|
|
1981
1987
|
"omittedTestPaths": "{count} deklarierte Testdatei wurde vor dem Nachweis verworfen, daher wurde der Stand vor dem Fix aus einer unvollständigen Reproduktion aufgebaut. | {count} deklarierte Testdateien wurden vor dem Nachweis verworfen, daher wurde der Stand vor dem Fix aus einer unvollständigen Reproduktion aufgebaut."
|
|
1982
|
-
}
|
|
1988
|
+
},
|
|
1989
|
+
"quorumProgress": "{recorded} von {required} Freigaben",
|
|
1990
|
+
"quorumYours": "(deine ist gezählt)",
|
|
1991
|
+
"quorumEditLocked": "Korrekturen sind möglich, sobald deine Freigabe dieses Gate auflöst. Bis dahin unverändert freigeben oder Änderungen anfordern.",
|
|
1992
|
+
"notAnApprover": "Dieses Gate benennt, wer es auflösen darf, und du gehörst nicht dazu.",
|
|
1993
|
+
"approverIdentityRequired": "Dieses Gate benennt, wer es auflösen darf; eine dieser Personen muss dafür angemeldet sein."
|
|
1983
1994
|
},
|
|
1984
1995
|
"inspector": {
|
|
1985
1996
|
"frameStatus": {
|
|
@@ -4123,6 +4134,17 @@
|
|
|
4123
4134
|
"hint": "Der Höchstumfang einer einzelnen Antwort dieses Agenten. Leer lassen heißt vom nächsthöheren Level erben, nicht das Limit aufheben. Der Wert eines Pipeline-Schritts sticht die Vorgabe des Workspace.",
|
|
4124
4135
|
"inherits": "Übernommen",
|
|
4125
4136
|
"inheritsValue": "Übernommen ({tokens})"
|
|
4137
|
+
},
|
|
4138
|
+
"gateConfig": {
|
|
4139
|
+
"approversLabel": "Wer freigeben darf",
|
|
4140
|
+
"roleAdmin": "Administratoren",
|
|
4141
|
+
"roleMember": "Mitglieder",
|
|
4142
|
+
"namedApproversLabel": "Benannte Freigebende",
|
|
4143
|
+
"namedApproversPlaceholder": "Alle mit Schreibrecht",
|
|
4144
|
+
"requiredApprovalsLabel": "Erforderliche Freigaben",
|
|
4145
|
+
"requiredApprovalsHint": "Jeweils von einer anderen Person, bevor der Lauf fortgesetzt wird.",
|
|
4146
|
+
"anyoneHint": "Alle, die dieses Board bearbeiten dürfen, können diesen Schritt freigeben.",
|
|
4147
|
+
"gateParametersLabel": "Gate-Einstellungen"
|
|
4126
4148
|
}
|
|
4127
4149
|
},
|
|
4128
4150
|
"agentPrompt": {
|
|
@@ -5561,6 +5583,11 @@
|
|
|
5561
5583
|
"title": "Keine Erfolgskriterien",
|
|
5562
5584
|
"hint": "Nenne die Frage, die der Spike beantwortet, oder wie ein gutes Ergebnis aussieht, damit die Zeitbox ein Ziel hat."
|
|
5563
5585
|
},
|
|
5586
|
+
"required_field_missing": {
|
|
5587
|
+
"title": "Fehlt: {field}",
|
|
5588
|
+
"hint": "Dieser Aufgabentyp verlangt „{field}“, die Aufgabe beantwortet es aber nicht. Trage es an der Aufgabe nach und prüfe erneut.",
|
|
5589
|
+
"unnamedField": "ein Pflichtfeld"
|
|
5590
|
+
},
|
|
5564
5591
|
"unknown": {
|
|
5565
5592
|
"title": "Unbekannter Befund",
|
|
5566
5593
|
"hint": "Dieser Lauf hat eine Prüfung erfasst, die diese Version nicht mehr kennt. Sieh die Aufgabe von Hand durch, bevor du fortfährst."
|
|
@@ -5914,6 +5941,7 @@
|
|
|
5914
5941
|
"operation": {
|
|
5915
5942
|
"provision": "Hochfahren",
|
|
5916
5943
|
"teardown": "Abbauen",
|
|
5944
|
+
"teardown-verify": "Abbau-Prüfung",
|
|
5917
5945
|
"status": "Statusprüfung",
|
|
5918
5946
|
"dispatch": "Hochfahren",
|
|
5919
5947
|
"release": "Abbauen",
|