@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 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
- - `purposeSuggestsAgentCategory` is **relevance**: what the palette OFFERS. Opinionated (a
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 { purposeAllowsAgentCategory } from '@cat-factory/contracts'
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
- // An enabled generator step with no storage selection — mirrors the backend save/start
235
- // rejection (`assertValidBinaryOutputSteps`), surfaced as an inline hint so the user fixes it
236
- // before the round trip. Same disposition as `skillStepNeedsPick`, for the same reason: both
237
- // are a step parametrized by a selection it cannot run without.
238
- const binaryOutputStepNeedsPick = computed(() =>
239
- pipelines.draft.some(
240
- (kind, i) =>
241
- showBinaryOutputPicker(kind) &&
242
- pipelines.draftEnabled[i] !== false &&
243
- !pipelines.draftBinaryOutput(i)?.storageServiceId,
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
- // Surfaced as an inline hint: a gated step needs a task-estimator before it (mirrors the
374
- // backend validation, which also rejects the save/start). Both the companion estimate gate
375
- // (`draftGating`) and the Tester QC companion's estimate gate (`draftTesterQuality[i].gating`)
376
- // count — either without a preceding estimator is rejected on save.
377
- const gatingNeedsEstimator = computed(() => {
378
- const kinds = pipelines.draft
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-if="gatingNeedsEstimator"
568
- 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"
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('pipeline.builder.skillNeedsPick') }}
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 otherwise every
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. Serialized as fixed-order tuples of only the fields that affect
30
- * display/pairing, so a re-serialization with reordered object keys can't spuriously differ.
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
+ })