@cat-factory/app 0.255.0 → 0.256.0
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 +19 -2
- package/app/components/pipeline/PipelineBuilder.vue +50 -81
- package/app/composables/usePipelineDraftWarnings.ts +136 -0
- package/app/modular/agent-kinds.ts +3 -0
- package/app/modular/capabilities.spec.ts +49 -0
- package/app/modular/capabilities.ts +29 -26
- package/app/stores/pipelines/draftRetainEnvironment.spec.ts +62 -0
- package/app/stores/pipelines/draftStepConfig.ts +57 -27
- package/app/types/domain.ts +7 -1
- package/app/utils/agentPalette.spec.ts +30 -0
- package/app/utils/agentPalette.ts +11 -10
- package/app/utils/catalog.spec.ts +35 -0
- package/app/utils/catalog.ts +38 -6
- package/app/utils/pipeline.spec.ts +69 -0
- package/i18n/locales/de.json +7 -0
- package/i18n/locales/en.json +7 -0
- package/i18n/locales/es.json +7 -0
- package/i18n/locales/fr.json +7 -0
- package/i18n/locales/he.json +7 -0
- package/i18n/locales/it.json +7 -0
- package/i18n/locales/ja.json +7 -0
- package/i18n/locales/pl.json +7 -0
- package/i18n/locales/tr.json +7 -0
- package/i18n/locales/uk.json +7 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -286,9 +286,13 @@ browser older than a new member, a row older than a retired one).
|
|
|
286
286
|
Purpose is filtered by three predicates in `@cat-factory/contracts`, and the difference between
|
|
287
287
|
the first two is the point:
|
|
288
288
|
|
|
289
|
-
- `
|
|
289
|
+
- `purposeSuggestsAgentKind` is **relevance**: what the palette OFFERS. Opinionated (a
|
|
290
290
|
review pipeline designs nothing; a planning pipeline has no pull request to gate), because a
|
|
291
|
-
wrong guess costs one purpose switch.
|
|
291
|
+
wrong guess costs one purpose switch. It reads the kind's `category` through
|
|
292
|
+
`purposeSuggestsAgentCategory` and then the kind's OWN `presentation.purposes`, and the two
|
|
293
|
+
INTERSECT: a declaration may only hide more, never buy a kind back into a purpose its section
|
|
294
|
+
is not offered to, which is what keeps relevance inside compatibility whatever a deployment
|
|
295
|
+
declares.
|
|
292
296
|
- `purposeAllowsAgentCategory` is **compatibility**: what the builder will SAVE. It states only
|
|
293
297
|
what is contradictory (a pipeline that writes no code carrying an implementation step) and
|
|
294
298
|
drives the draft's conflict warning.
|
|
@@ -311,6 +315,19 @@ it that way: the palette may hide what the save gate tolerates, so tightening th
|
|
|
311
315
|
never turns a stored pipeline into one its own editor refuses, but offering a kind the save gate
|
|
312
316
|
then rejects would be a dead end with the refusal arriving after the work.
|
|
313
317
|
|
|
318
|
+
**A category is a shelf label, not a statement of what a kind does**, which is why relevance is
|
|
319
|
+
asked of the KIND. Keeping `docs` for a `review` pipeline so the Domain Rules Reviewer survives
|
|
320
|
+
also handed it the two kinds that WRITE documentation into the repo, and `document` and `research`
|
|
321
|
+
had identical rows, so moving the dial between them narrowed nothing at all. A kind that belongs
|
|
322
|
+
to one use-case says so in `presentation.purposes` and leaves a section its siblings stay in; the
|
|
323
|
+
section keeps deciding for every kind that declares nothing, which is the normal case and the one
|
|
324
|
+
a deployment-registered kind falls into for free. Declare it only to opt OUT: it can never widen,
|
|
325
|
+
and a list naming only purposes this build cannot name is read as no declaration at all rather
|
|
326
|
+
than as excluding everything, the same default-open reading the unknown `purpose` gets. An EMPTY
|
|
327
|
+
list is refused at registration instead (`agentPresentationSchema`, and `catalog.spec.ts` for the
|
|
328
|
+
static half valibot never parses): the reader cannot tell one from declaring nothing, so it would
|
|
329
|
+
offer the kind everywhere its section is offered, which is the inverse of what writing it means.
|
|
330
|
+
|
|
314
331
|
**Each hint counts what relaxing THAT dial alone would reveal**, which is why each reduction is one
|
|
315
332
|
function (`utils/agentPalette.ts` for the catalog, `utils/pipelineLibrary.ts` for the library)
|
|
316
333
|
rather than chained filters at the call site. Chaining them
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, ref, watch } from 'vue'
|
|
3
|
-
import {
|
|
3
|
+
import { DEPLOYER_AGENT_KIND } from '@cat-factory/contracts'
|
|
4
4
|
import type { AgentKind, Pipeline } from '~/types/domain'
|
|
5
5
|
import AgentPalette from '~/components/palettes/AgentPalette.vue'
|
|
6
6
|
import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
|
|
@@ -189,31 +189,6 @@ const skills = useSkillsStore()
|
|
|
189
189
|
// skill picker. A `skill` step is parametrized by the picked skill (`stepOptions.skillId`).
|
|
190
190
|
const skillSelectItems = computed(() => skills.catalog.map((s) => ({ label: s.name, value: s.id })))
|
|
191
191
|
|
|
192
|
-
// An enabled `skill` step with no picked skill — mirrors the backend save/start rejection
|
|
193
|
-
// (`assertValidSkillSteps`), surfaced as an inline hint so the user fixes it before saving.
|
|
194
|
-
const skillStepNeedsPick = computed(() =>
|
|
195
|
-
pipelines.draft.some(
|
|
196
|
-
(k, i) => k === 'skill' && pipelines.draftEnabled[i] !== false && !pipelines.draftSkillId(i),
|
|
197
|
-
),
|
|
198
|
-
)
|
|
199
|
-
|
|
200
|
-
// Steps whose agent category the chosen purpose CONTRADICTS (a non-`build` purpose writes no code
|
|
201
|
-
// and runs no tests, so the Implementation/Testing categories are disallowed, see
|
|
202
|
-
// `purposeAllowsAgentCategory`). Only reachable by switching an existing draft to a non-`build`
|
|
203
|
-
// purpose AFTER such steps were added, since the palette offers neither. The backend has no
|
|
204
|
-
// kind→category map to gate on, so the builder is the enforcement point: save is blocked until the
|
|
205
|
-
// offending steps are removed (or the purpose set back to Build).
|
|
206
|
-
//
|
|
207
|
-
// Deliberately the compatibility predicate, not the palette's narrower relevance one: a purpose
|
|
208
|
-
// that merely stops SUGGESTING a category must not turn a pipeline somebody already built into
|
|
209
|
-
// one they cannot save.
|
|
210
|
-
const stepsDisallowedByPurpose = computed(() =>
|
|
211
|
-
pipelines.draft.filter((kind) => {
|
|
212
|
-
const category = agentKindMeta(kind).category
|
|
213
|
-
return !!category && !purposeAllowsAgentCategory(pipelines.draftPurpose, category)
|
|
214
|
-
}),
|
|
215
|
-
)
|
|
216
|
-
|
|
217
192
|
// The workspace's foundational-services catalog, for the binary-output storage/context picker.
|
|
218
193
|
const foundational = useFoundationalServicesStore()
|
|
219
194
|
|
|
@@ -231,18 +206,22 @@ function showBinaryOutputPicker(kind: AgentKind): boolean {
|
|
|
231
206
|
return agentKindMeta(kind).binaryOutput === true
|
|
232
207
|
}
|
|
233
208
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Whether to offer the Deployer's "keep this environment past the run" declaration on the step at
|
|
211
|
+
* `index`. An OVERRIDE in the `showOverrideField` sense — reclaiming what a run stood up is the
|
|
212
|
+
* default and the everyday shape, and a deliberately retained preview environment is not the
|
|
213
|
+
* everyday delivery loop — so it is `advanced`-tier, and a step that already CARRIES the
|
|
214
|
+
* declaration keeps showing it in either mode (never hide the way back).
|
|
215
|
+
*
|
|
216
|
+
* Hiding it strands nobody: the fault it answers (`deployer_without_disposer`) always has the
|
|
217
|
+
* tier-neutral fix of adding the Disposer back, which both the inline hint and the save refusal
|
|
218
|
+
* name FIRST. That is what makes this different from the binary-output picker above, where the
|
|
219
|
+
* selection is required and hiding it would leave a step with no savable form at all.
|
|
220
|
+
*/
|
|
221
|
+
function showRetainEnvironmentToggle(kind: AgentKind, index: number): boolean {
|
|
222
|
+
if (kind !== DEPLOYER_AGENT_KIND) return false
|
|
223
|
+
return showOverrideField(uiMode.isAdvanced, pipelines.draftRetainEnvironment(index) || null)
|
|
224
|
+
}
|
|
246
225
|
|
|
247
226
|
// A step's picked skill id is no longer in the account catalog (the source dir was renamed or
|
|
248
227
|
// unlinked). The step will fail cleanly at dispatch; flag it so the user re-picks.
|
|
@@ -370,22 +349,12 @@ function companionLabel(kind: string): string | null {
|
|
|
370
349
|
return companion ? agentKindMeta(companion).label : null
|
|
371
350
|
}
|
|
372
351
|
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
const hasEstimatorBefore = (i: number) =>
|
|
380
|
-
kinds.slice(0, i).some((k, j) => k === 'task-estimator' && pipelines.draftEnabled[j] !== false)
|
|
381
|
-
for (let i = 0; i < kinds.length; i++) {
|
|
382
|
-
if (pipelines.draftEnabled[i] === false) continue
|
|
383
|
-
const gated =
|
|
384
|
-
pipelines.draftGating[i]?.enabled || pipelines.draftTesterQuality[i]?.gating?.enabled
|
|
385
|
-
if (gated && !hasEstimatorBefore(i)) return true
|
|
386
|
-
}
|
|
387
|
-
return false
|
|
388
|
-
})
|
|
352
|
+
// Everything that is WRONG with the draft, as an ordered list of hints the template renders once,
|
|
353
|
+
// plus the purpose conflict that also disables Save. Lives in its own composable because each of
|
|
354
|
+
// these is one predicate beside one identically-styled line, and five of them had crowded out the
|
|
355
|
+
// component (`usePipelineDraftWarnings`).
|
|
356
|
+
const { hints: draftWarnings, stepsDisallowedByPurpose } =
|
|
357
|
+
usePipelineDraftWarnings(showBinaryOutputPicker)
|
|
389
358
|
|
|
390
359
|
// ---- draft labels ----------------------------------------------------------
|
|
391
360
|
const newLabel = ref('')
|
|
@@ -563,37 +532,16 @@ async function clone(p: Pipeline) {
|
|
|
563
532
|
/>
|
|
564
533
|
</div>
|
|
565
534
|
|
|
535
|
+
<!-- Every draft fault the builder can name, each mirroring a refusal the save boundary
|
|
536
|
+
makes, so the user fixes it before the round trip (`usePipelineDraftWarnings`). -->
|
|
566
537
|
<p
|
|
567
|
-
v-
|
|
568
|
-
|
|
569
|
-
>
|
|
570
|
-
<UIcon name="i-lucide-alert-triangle" class="h-3.5 w-3.5 shrink-0" />
|
|
571
|
-
{{ t('pipeline.builder.gatingNeedsEstimator') }}
|
|
572
|
-
</p>
|
|
573
|
-
|
|
574
|
-
<p
|
|
575
|
-
v-if="skillStepNeedsPick"
|
|
538
|
+
v-for="warning in draftWarnings"
|
|
539
|
+
:key="warning.key"
|
|
576
540
|
class="mb-2 flex items-center gap-1.5 rounded-md border border-amber-800/50 bg-amber-950/30 px-2 py-1 text-[11px] text-amber-300"
|
|
541
|
+
:data-testid="warning.testId"
|
|
577
542
|
>
|
|
578
543
|
<UIcon name="i-lucide-alert-triangle" class="h-3.5 w-3.5 shrink-0" />
|
|
579
|
-
{{ t(
|
|
580
|
-
</p>
|
|
581
|
-
|
|
582
|
-
<p
|
|
583
|
-
v-if="binaryOutputStepNeedsPick"
|
|
584
|
-
class="mb-2 flex items-center gap-1.5 rounded-md border border-amber-800/50 bg-amber-950/30 px-2 py-1 text-[11px] text-amber-300"
|
|
585
|
-
data-testid="binary-output-needs-pick"
|
|
586
|
-
>
|
|
587
|
-
<UIcon name="i-lucide-alert-triangle" class="h-3.5 w-3.5 shrink-0" />
|
|
588
|
-
{{ t('pipeline.builder.binaryOutputNeedsPick') }}
|
|
589
|
-
</p>
|
|
590
|
-
|
|
591
|
-
<p
|
|
592
|
-
v-if="stepsDisallowedByPurpose.length"
|
|
593
|
-
class="mb-2 flex items-center gap-1.5 rounded-md border border-amber-800/50 bg-amber-950/30 px-2 py-1 text-[11px] text-amber-300"
|
|
594
|
-
>
|
|
595
|
-
<UIcon name="i-lucide-alert-triangle" class="h-3.5 w-3.5 shrink-0" />
|
|
596
|
-
{{ t('pipeline.builder.purposeStepsConflict') }}
|
|
544
|
+
{{ t(warning.key) }}
|
|
597
545
|
</p>
|
|
598
546
|
|
|
599
547
|
<div
|
|
@@ -766,6 +714,27 @@ async function clone(p: Pipeline) {
|
|
|
766
714
|
"
|
|
767
715
|
@click="pipelines.toggleDraftAutoRecommend(unit.index)"
|
|
768
716
|
/>
|
|
717
|
+
<!-- Keep the environment this Deployer stands up past the end of the run: the
|
|
718
|
+
preview a reviewer pokes at after the PR is open. Off by default, and the
|
|
719
|
+
save boundary refuses a Deployer that neither reclaims nor declares this,
|
|
720
|
+
so the tick is how an unreclaimed environment says it is deliberate. -->
|
|
721
|
+
<UButton
|
|
722
|
+
v-if="showRetainEnvironmentToggle(unit.kind, unit.index)"
|
|
723
|
+
:icon="
|
|
724
|
+
pipelines.draftRetainEnvironment(unit.index)
|
|
725
|
+
? 'i-lucide-lock'
|
|
726
|
+
: 'i-lucide-cloud-off'
|
|
727
|
+
"
|
|
728
|
+
:color="pipelines.draftRetainEnvironment(unit.index) ? 'warning' : 'neutral'"
|
|
729
|
+
variant="ghost"
|
|
730
|
+
size="xs"
|
|
731
|
+
:title="
|
|
732
|
+
pipelines.draftRetainEnvironment(unit.index)
|
|
733
|
+
? t('pipeline.builder.retainEnvironmentClearTooltip')
|
|
734
|
+
: t('pipeline.builder.retainEnvironmentSetTooltip')
|
|
735
|
+
"
|
|
736
|
+
@click="pipelines.toggleDraftRetainEnvironment(unit.index)"
|
|
737
|
+
/>
|
|
769
738
|
<!-- System prompt: replace what this agent kind ships with, for every run in
|
|
770
739
|
this workspace, with the full revision history to switch back through. -->
|
|
771
740
|
<UButton
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { computed, type ComputedRef } from 'vue'
|
|
2
|
+
import {
|
|
3
|
+
pipelineEnvironmentProblems,
|
|
4
|
+
purposeAllowsAgentCategory,
|
|
5
|
+
type PipelineEnvironmentProblemReason,
|
|
6
|
+
} from '@cat-factory/contracts'
|
|
7
|
+
import type { AgentKind } from '~/types/domain'
|
|
8
|
+
import { agentKindMeta } from '~/utils/catalog'
|
|
9
|
+
import { usePipelinesStore } from '~/stores/pipelines'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Everything the pipeline builder can say is WRONG with the draft in front of it, as an ordered
|
|
13
|
+
* list of translation keys, plus the one finding that also blocks the save.
|
|
14
|
+
*
|
|
15
|
+
* Extracted from `PipelineBuilder.vue` because these grew into one concern wearing five copies of
|
|
16
|
+
* the same coat: each was a `computed` predicate beside a `<p>` with byte-identical classes, so
|
|
17
|
+
* every new rule cost another near-duplicate pair in a component that had run out of room for
|
|
18
|
+
* them. Here they are a list the template renders once.
|
|
19
|
+
*
|
|
20
|
+
* Every entry MIRRORS a refusal the backend already makes at save (or, for the purpose conflict,
|
|
21
|
+
* one only the builder can make, since the backend has no kind→category map). The point is to
|
|
22
|
+
* surface it before the round trip, never to be the sole enforcement: a hint that disagrees with
|
|
23
|
+
* the boundary would let an unsavable draft look clean, so each is derived from the same shared
|
|
24
|
+
* rule the boundary runs rather than from a hand-written restatement of it.
|
|
25
|
+
*/
|
|
26
|
+
export interface PipelineDraftWarnings {
|
|
27
|
+
/** One entry per distinct finding, in the order the builder shows them. */
|
|
28
|
+
hints: ComputedRef<PipelineDraftHint[]>
|
|
29
|
+
/**
|
|
30
|
+
* The draft steps whose agent category the chosen purpose CONTRADICTS. Exposed as the list
|
|
31
|
+
* rather than folded into {@link hints} alone because it is the one finding that also disables
|
|
32
|
+
* the Save button: the builder is the enforcement point, so an empty list is a precondition.
|
|
33
|
+
*/
|
|
34
|
+
stepsDisallowedByPurpose: ComputedRef<AgentKind[]>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One rendered warning: its i18n key, plus the test id where a spec asserts on it. */
|
|
38
|
+
export interface PipelineDraftHint {
|
|
39
|
+
key: string
|
|
40
|
+
testId?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The environment-lifecycle faults, mapped onto the copy that names the fix for each. An
|
|
45
|
+
* exhaustive `Record` over the reason union, so a fault added to the shared rule fails the build
|
|
46
|
+
* here rather than rendering as a blank hint.
|
|
47
|
+
*/
|
|
48
|
+
const ENVIRONMENT_HINT_KEYS: Record<PipelineEnvironmentProblemReason, string> = {
|
|
49
|
+
consumer_without_deployer: 'pipeline.builder.envNeedsDeployer',
|
|
50
|
+
consumer_after_disposer: 'pipeline.builder.envConsumerAfterDisposer',
|
|
51
|
+
deployer_without_disposer: 'pipeline.builder.envNeedsDisposer',
|
|
52
|
+
disposer_without_deployer: 'pipeline.builder.envDisposerNeedsDeployer',
|
|
53
|
+
retained_deployer_reclaimed: 'pipeline.builder.envRetainedButReclaimed',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function usePipelineDraftWarnings(
|
|
57
|
+
showBinaryOutputPicker: (kind: AgentKind) => boolean,
|
|
58
|
+
): PipelineDraftWarnings {
|
|
59
|
+
const pipelines = usePipelinesStore()
|
|
60
|
+
|
|
61
|
+
const enabled = (i: number) => pipelines.draftEnabled[i] !== false
|
|
62
|
+
|
|
63
|
+
// A gated step with no task-estimator before it (mirrors `assertValidGating`, which rejects the
|
|
64
|
+
// save and the start). Both the step's own estimate gate (`draftGating`) and the Tester QC
|
|
65
|
+
// companion's (`draftTesterQuality[i].gating`) count.
|
|
66
|
+
const gatingNeedsEstimator = computed(() => {
|
|
67
|
+
const kinds = pipelines.draft
|
|
68
|
+
const hasEstimatorBefore = (i: number) =>
|
|
69
|
+
kinds.slice(0, i).some((k, j) => k === 'task-estimator' && enabled(j))
|
|
70
|
+
return kinds.some((_, i) => {
|
|
71
|
+
if (!enabled(i)) return false
|
|
72
|
+
const gated =
|
|
73
|
+
pipelines.draftGating[i]?.enabled || pipelines.draftTesterQuality[i]?.gating?.enabled
|
|
74
|
+
return !!gated && !hasEstimatorBefore(i)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// The environment lifecycle a draft has to spell out: provision (Deployer) → consume (a tester /
|
|
79
|
+
// acceptance / human-test step) → reclaim (Disposer, or a Deployer that declares its environment
|
|
80
|
+
// outlives the run). One message per DISTINCT fault, so a draft missing both a Deployer and a
|
|
81
|
+
// Disposer says so at once instead of over two rejected saves.
|
|
82
|
+
const environmentHintKeys = computed(() => {
|
|
83
|
+
const problems = pipelineEnvironmentProblems(
|
|
84
|
+
pipelines.draft,
|
|
85
|
+
pipelines.draftEnabled,
|
|
86
|
+
pipelines.draftStepOptions,
|
|
87
|
+
)
|
|
88
|
+
return [...new Set(problems.map((p) => ENVIRONMENT_HINT_KEYS[p.reason]))]
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// An enabled `skill` step with no picked skill (mirrors `assertValidSkillSteps`).
|
|
92
|
+
const skillStepNeedsPick = computed(() =>
|
|
93
|
+
pipelines.draft.some((k, i) => k === 'skill' && enabled(i) && !pipelines.draftSkillId(i)),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
// An enabled generator step with no storage selection (mirrors `assertValidBinaryOutputSteps`).
|
|
97
|
+
// Same disposition as `skillStepNeedsPick`: both are a step parametrized by a selection it
|
|
98
|
+
// cannot run without.
|
|
99
|
+
const binaryOutputStepNeedsPick = computed(() =>
|
|
100
|
+
pipelines.draft.some(
|
|
101
|
+
(kind, i) =>
|
|
102
|
+
showBinaryOutputPicker(kind) &&
|
|
103
|
+
enabled(i) &&
|
|
104
|
+
!pipelines.draftBinaryOutput(i)?.storageServiceId,
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
// Steps whose agent category the chosen purpose CONTRADICTS (a non-`build` purpose writes no
|
|
109
|
+
// code and runs no tests, so the Implementation/Testing categories are disallowed). Only
|
|
110
|
+
// reachable by switching an existing draft to a non-`build` purpose AFTER such steps were added,
|
|
111
|
+
// since the palette offers neither.
|
|
112
|
+
//
|
|
113
|
+
// Deliberately the COMPATIBILITY predicate, not the palette's narrower relevance one: a purpose
|
|
114
|
+
// that merely stops SUGGESTING a category must not turn a pipeline somebody already built into
|
|
115
|
+
// one they cannot save.
|
|
116
|
+
const stepsDisallowedByPurpose = computed(() =>
|
|
117
|
+
pipelines.draft.filter((kind) => {
|
|
118
|
+
const category = agentKindMeta(kind).category
|
|
119
|
+
return !!category && !purposeAllowsAgentCategory(pipelines.draftPurpose, category)
|
|
120
|
+
}),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const hints = computed<PipelineDraftHint[]>(() => [
|
|
124
|
+
...(gatingNeedsEstimator.value ? [{ key: 'pipeline.builder.gatingNeedsEstimator' }] : []),
|
|
125
|
+
...environmentHintKeys.value.map((key) => ({ key, testId: 'env-lifecycle-hint' })),
|
|
126
|
+
...(skillStepNeedsPick.value ? [{ key: 'pipeline.builder.skillNeedsPick' }] : []),
|
|
127
|
+
...(binaryOutputStepNeedsPick.value
|
|
128
|
+
? [{ key: 'pipeline.builder.binaryOutputNeedsPick', testId: 'binary-output-needs-pick' }]
|
|
129
|
+
: []),
|
|
130
|
+
...(stepsDisallowedByPurpose.value.length
|
|
131
|
+
? [{ key: 'pipeline.builder.purposeStepsConflict' }]
|
|
132
|
+
: []),
|
|
133
|
+
])
|
|
134
|
+
|
|
135
|
+
return { hints, stepsDisallowedByPurpose }
|
|
136
|
+
}
|
|
@@ -27,6 +27,9 @@ export function customKindToArchetype(kind: CustomAgentKind): AgentArchetype {
|
|
|
27
27
|
color: p.color,
|
|
28
28
|
description: p.description,
|
|
29
29
|
...(p.category ? { category: p.category } : {}),
|
|
30
|
+
// Carried verbatim, INCLUDING a list this build cannot fully name: `purposeSuggestsAgentKind`
|
|
31
|
+
// owns the reading of an unrecognised member, so filtering here would fork that rule.
|
|
32
|
+
...(p.purposes?.length ? { purposes: p.purposes } : {}),
|
|
30
33
|
// A kind that declares no tier is left WITHOUT one rather than stamped with the default
|
|
31
34
|
// here, so the single fallback stays in `agentTierVisibleAt` — filling it in at the
|
|
32
35
|
// projection would fork the rule the moment the default changes.
|
|
@@ -70,6 +70,55 @@ describe('buildWorkspaceCapabilitiesManifest', () => {
|
|
|
70
70
|
expect(workspaceCapabilitiesVersion([], [])).not.toBe(base)
|
|
71
71
|
})
|
|
72
72
|
|
|
73
|
+
it('changes the version for EVERY declared field, including the ones nothing renders', () => {
|
|
74
|
+
// The signature covers the whole entry rather than a list of fields somebody kept in step,
|
|
75
|
+
// because an omitted one is not a cosmetic miss: `hydrateCapabilities` no-ops on an unchanged
|
|
76
|
+
// version, so an open tab keeps filtering its palette on the declaration the backend just
|
|
77
|
+
// replaced. Asserted field by field over the ones that steer the builder rather than the
|
|
78
|
+
// label, which is the class the old field list kept missing.
|
|
79
|
+
const base = workspaceCapabilitiesVersion([kind()], [])
|
|
80
|
+
expect(workspaceCapabilitiesVersion([kind({ purposes: ['review'] })], [])).not.toBe(base)
|
|
81
|
+
expect(workspaceCapabilitiesVersion([kind({ category: 'docs' })], [])).not.toBe(base)
|
|
82
|
+
expect(workspaceCapabilitiesVersion([kind({ tier: 'basic' })], [])).not.toBe(base)
|
|
83
|
+
expect(workspaceCapabilitiesVersion([{ ...kind(), container: false }], [])).not.toBe(base)
|
|
84
|
+
expect(workspaceCapabilitiesVersion([{ ...kind(), binaryOutput: true }], [])).not.toBe(base)
|
|
85
|
+
expect(
|
|
86
|
+
workspaceCapabilitiesVersion([{ ...kind(), companionTargets: ['coder' as AgentKind] }], []),
|
|
87
|
+
).not.toBe(base)
|
|
88
|
+
// And a purposes list is ORDER-bearing content, not a set: two spellings of the same
|
|
89
|
+
// declaration are two declarations, so re-signing is the honest answer over guessing.
|
|
90
|
+
expect(workspaceCapabilitiesVersion([kind({ purposes: ['review', 'build'] })], [])).not.toBe(
|
|
91
|
+
workspaceCapabilitiesVersion([kind({ purposes: ['build', 'review'] })], []),
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('ignores the KEY ORDER a snapshot happened to serialize with', () => {
|
|
96
|
+
// The whole point of canonicalizing rather than hashing the raw JSON: a re-serialization
|
|
97
|
+
// that reorders keys is the same catalog, and re-swapping the manifest for it would
|
|
98
|
+
// invalidate every `agentKindMeta` consumer for nothing.
|
|
99
|
+
const ordered: CustomAgentKind = {
|
|
100
|
+
kind: 'acme-audit' as AgentKind,
|
|
101
|
+
container: true,
|
|
102
|
+
presentation: { label: 'Audit', icon: 'i-lucide-shield', color: '#fff', description: 'd' },
|
|
103
|
+
}
|
|
104
|
+
const reordered: CustomAgentKind = {
|
|
105
|
+
presentation: { description: 'd', color: '#fff', icon: 'i-lucide-shield', label: 'Audit' },
|
|
106
|
+
container: true,
|
|
107
|
+
kind: 'acme-audit' as AgentKind,
|
|
108
|
+
}
|
|
109
|
+
expect(workspaceCapabilitiesVersion([reordered], [])).toBe(
|
|
110
|
+
workspaceCapabilitiesVersion([ordered], []),
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('reads an explicitly-undefined field as an absent one', () => {
|
|
115
|
+
// A projection that spreads a conditional field (`...(x ? { x } : {})`) and one that assigns
|
|
116
|
+
// `x: undefined` describe the same catalog, so they must not hash differently.
|
|
117
|
+
expect(workspaceCapabilitiesVersion([{ ...kind(), binaryOutput: undefined }], [])).toBe(
|
|
118
|
+
workspaceCapabilitiesVersion([kind()], []),
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
|
|
73
122
|
it('changes the version when a task-type field, its fields, or the set differs', () => {
|
|
74
123
|
const base = workspaceCapabilitiesVersion([], [taskType()])
|
|
75
124
|
expect(workspaceCapabilitiesVersion([], [taskType({ label: 'Renamed' })])).not.toBe(base)
|
|
@@ -19,43 +19,46 @@ import type { AppSlots } from './slots'
|
|
|
19
19
|
/** The stable id for the per-workspace capability manifest built from the snapshot. */
|
|
20
20
|
export const WORKSPACE_CAPABILITIES_MANIFEST_ID = 'cat-factory:workspace-capabilities'
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Every key of `value`, recursively, in sorted order: the canonical form
|
|
24
|
+
* {@link workspaceCapabilitiesVersion} signs. Arrays keep their order (a capability list's order is
|
|
25
|
+
* content: it is the order the palette and the picker render in); objects lose theirs, so a
|
|
26
|
+
* re-serialization with reordered keys can't spuriously differ.
|
|
27
|
+
*/
|
|
28
|
+
function canonicalize(value: unknown): unknown {
|
|
29
|
+
if (Array.isArray(value)) return value.map(canonicalize)
|
|
30
|
+
if (value === null || typeof value !== 'object') return value
|
|
31
|
+
return Object.fromEntries(
|
|
32
|
+
Object.entries(value)
|
|
33
|
+
.filter(([, entry]) => entry !== undefined)
|
|
34
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
35
|
+
.map(([key, entry]) => [key, canonicalize(entry)]),
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
/**
|
|
23
40
|
* A deterministic, key-order-independent content signature covering BOTH capability lists, used as
|
|
24
41
|
* the manifest `version`. The workspace snapshot re-delivers the SAME deployment capabilities on
|
|
25
42
|
* every board-event refresh (a full `workspace.refresh()` re-hydrates each time), so a
|
|
26
|
-
* content-derived version lets each store skip re-projecting an UNCHANGED catalog
|
|
43
|
+
* content-derived version lets each store skip re-projecting an UNCHANGED catalog: otherwise every
|
|
27
44
|
* refresh would replace its read-model and needlessly invalidate every `agentKindMeta` /
|
|
28
45
|
* `taskTypeMeta` consumer. It still changes (and swaps wholesale) when a different workspace's
|
|
29
|
-
* capabilities genuinely differ.
|
|
30
|
-
*
|
|
46
|
+
* capabilities genuinely differ.
|
|
47
|
+
*
|
|
48
|
+
* Signs the WHOLE entry, canonicalized, rather than a hand-listed tuple of the fields that seemed
|
|
49
|
+
* to matter. The two mistakes are not symmetric: folding in a field nothing renders costs one
|
|
50
|
+
* re-projection nobody sees, while OMITTING one costs correctness, because `hydrateCapabilities`
|
|
51
|
+
* short-circuits on an unchanged version and the store then keeps serving the PREVIOUS declaration
|
|
52
|
+
* until something else swaps the manifest. A field list drifts silently into that: it was missing
|
|
53
|
+
* `tier` and `binaryOutput` before `purposes` was added to it, so a backend that re-declared any of
|
|
54
|
+
* the three reached an open tab as the catalog it had replaced. There is nothing volatile in either
|
|
55
|
+
* shape (no timestamps, no ids minted per request), so signing all of it costs nothing.
|
|
31
56
|
*/
|
|
32
57
|
export function workspaceCapabilitiesVersion(
|
|
33
58
|
kinds: readonly CustomAgentKind[],
|
|
34
59
|
taskTypes: readonly CustomTaskType[],
|
|
35
60
|
): string {
|
|
36
|
-
return JSON.stringify([
|
|
37
|
-
kinds.map((k) => [
|
|
38
|
-
k.kind,
|
|
39
|
-
k.container,
|
|
40
|
-
k.presentation.label,
|
|
41
|
-
k.presentation.icon,
|
|
42
|
-
k.presentation.color,
|
|
43
|
-
k.presentation.description,
|
|
44
|
-
k.presentation.category ?? null,
|
|
45
|
-
k.presentation.resultView ?? null,
|
|
46
|
-
]),
|
|
47
|
-
taskTypes.map((t) => [
|
|
48
|
-
t.taskType,
|
|
49
|
-
t.presentation.label,
|
|
50
|
-
t.presentation.icon,
|
|
51
|
-
t.presentation.color,
|
|
52
|
-
t.presentation.description,
|
|
53
|
-
t.defaultPipelineId ?? null,
|
|
54
|
-
t.formPanel ?? null,
|
|
55
|
-
// The descriptor list affects the create-form, so fold its shape into the signature too.
|
|
56
|
-
(t.fields ?? []).map((f) => [f.key, f.type, f.label, f.required ?? false]),
|
|
57
|
-
]),
|
|
58
|
-
])
|
|
61
|
+
return JSON.stringify([canonicalize(kinds), canonicalize(taskTypes)])
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
/**
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { pipelineEnvironmentProblems } from '@cat-factory/contracts'
|
|
3
|
+
import { usePipelinesStore } from '~/stores/pipelines'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The pipeline-builder draft's retain declaration (`stepOptions.retainEnvironment`): how an author
|
|
7
|
+
* says a Deployer's environment is MEANT to outlive the run, which is the only savable form of
|
|
8
|
+
* "deploy a preview and leave it up" (the reclaim rule refuses a Deployer that neither reclaims
|
|
9
|
+
* nor declares this). Same contract as the option helpers beside it: merge into the bag, normalize
|
|
10
|
+
* an emptied bag back to null.
|
|
11
|
+
*/
|
|
12
|
+
describe('pipelines store — per-step retain-environment declaration', () => {
|
|
13
|
+
it('stores only the opt-in, and normalizes the bag away when it is cleared', () => {
|
|
14
|
+
const pipelines = usePipelinesStore()
|
|
15
|
+
pipelines.addToDraft('deployer')
|
|
16
|
+
// OFF by default, and stored as ABSENCE rather than `false`: reclaiming is the default, so a
|
|
17
|
+
// step that never touched this persists exactly the shape it always did.
|
|
18
|
+
expect(pipelines.draftRetainEnvironment(0)).toBe(false)
|
|
19
|
+
expect(pipelines.draftStepOptions[0]).toBeNull()
|
|
20
|
+
|
|
21
|
+
pipelines.toggleDraftRetainEnvironment(0)
|
|
22
|
+
expect(pipelines.draftRetainEnvironment(0)).toBe(true)
|
|
23
|
+
expect(pipelines.draftStepOptions[0]).toEqual({ retainEnvironment: true })
|
|
24
|
+
|
|
25
|
+
pipelines.toggleDraftRetainEnvironment(0)
|
|
26
|
+
expect(pipelines.draftRetainEnvironment(0)).toBe(false)
|
|
27
|
+
expect(pipelines.draftStepOptions[0]).toBeNull()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('merges with the other options on the same step rather than clobbering them', () => {
|
|
31
|
+
const pipelines = usePipelinesStore()
|
|
32
|
+
pipelines.addToDraft('deployer')
|
|
33
|
+
pipelines.setDraftAgentVariantId(0, 'acme:fast')
|
|
34
|
+
|
|
35
|
+
pipelines.toggleDraftRetainEnvironment(0)
|
|
36
|
+
expect(pipelines.draftStepOptions[0]).toEqual({
|
|
37
|
+
agentVariantId: 'acme:fast',
|
|
38
|
+
retainEnvironment: true,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
pipelines.toggleDraftRetainEnvironment(0)
|
|
42
|
+
expect(pipelines.draftStepOptions[0]).toEqual({ agentVariantId: 'acme:fast' })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('turns a draft the save boundary refuses into one it accepts', () => {
|
|
46
|
+
// The point of the whole field, asserted against the SAME rule the backend refuses on rather
|
|
47
|
+
// than against a restatement of it: without the declaration this draft has no savable form,
|
|
48
|
+
// since dropping the Deployer instead just moves the fault onto the tester.
|
|
49
|
+
const pipelines = usePipelinesStore()
|
|
50
|
+
for (const kind of ['coder', 'deployer', 'human-test'] as const) pipelines.addToDraft(kind)
|
|
51
|
+
const problems = () =>
|
|
52
|
+
pipelineEnvironmentProblems(
|
|
53
|
+
pipelines.draft,
|
|
54
|
+
pipelines.draftEnabled,
|
|
55
|
+
pipelines.draftStepOptions,
|
|
56
|
+
).map((p) => p.reason)
|
|
57
|
+
|
|
58
|
+
expect(problems()).toEqual(['deployer_without_disposer'])
|
|
59
|
+
pipelines.toggleDraftRetainEnvironment(1)
|
|
60
|
+
expect(problems()).toEqual([])
|
|
61
|
+
})
|
|
62
|
+
})
|