@cat-factory/app 0.152.0 → 0.153.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.
@@ -0,0 +1,307 @@
1
+ import { computed } from 'vue'
2
+ import type { AgentKind, Pipeline } from '~/types/domain'
3
+ import type { ConsensusStepConfig } from '~/types/consensus'
4
+ import type { StepOptions } from '@cat-factory/contracts'
5
+ import { companionForProducer } from '~/utils/catalog'
6
+ import { defaultConsensusConfig, type PipelinesContext } from './context'
7
+
8
+ /**
9
+ * The pipeline-builder draft manipulation: inserting/removing/reordering steps and toggling each
10
+ * step's per-step config, plus `clearDraft` / `loadForEdit`. Extracted from the store setup into a
11
+ * factory closing over the shared {@link PipelinesContext} so behaviour is byte-identical to the
12
+ * former in-closure functions — the split is purely to keep the setup within the size budget.
13
+ */
14
+ export function createPipelineDraftActions(ctx: PipelinesContext) {
15
+ const {
16
+ draft,
17
+ draftGates,
18
+ draftEnabled,
19
+ draftThresholds,
20
+ draftConsensus,
21
+ draftGating,
22
+ draftFollowUps,
23
+ draftTesterQuality,
24
+ draftStepOptions,
25
+ draftLabels,
26
+ draftPurpose,
27
+ draftName,
28
+ draftDescription,
29
+ editingId,
30
+ } = ctx
31
+
32
+ /** Insert a step (with its default per-step config) at `index`, keeping arrays aligned. */
33
+ function insertAt(index: number, kind: AgentKind) {
34
+ draft.value.splice(index, 0, kind)
35
+ draftGates.value.splice(index, 0, false)
36
+ draftEnabled.value.splice(index, 0, true)
37
+ draftThresholds.value.splice(index, 0, null)
38
+ draftConsensus.value.splice(index, 0, null)
39
+ draftGating.value.splice(index, 0, null)
40
+ draftFollowUps.value.splice(index, 0, null)
41
+ draftTesterQuality.value.splice(index, 0, null)
42
+ draftStepOptions.value.splice(index, 0, null)
43
+ }
44
+
45
+ function addToDraft(kind: AgentKind) {
46
+ insertAt(draft.value.length, kind)
47
+ }
48
+
49
+ function removeFromDraft(index: number) {
50
+ draft.value.splice(index, 1)
51
+ draftGates.value.splice(index, 1)
52
+ draftEnabled.value.splice(index, 1)
53
+ draftThresholds.value.splice(index, 1)
54
+ draftConsensus.value.splice(index, 1)
55
+ draftGating.value.splice(index, 1)
56
+ draftFollowUps.value.splice(index, 1)
57
+ draftTesterQuality.value.splice(index, 1)
58
+ draftStepOptions.value.splice(index, 1)
59
+ }
60
+
61
+ function moveInDraft(from: number, to: number) {
62
+ if (to < 0 || to >= draft.value.length) return
63
+ const [item] = draft.value.splice(from, 1)
64
+ if (item) draft.value.splice(to, 0, item)
65
+ const [gate] = draftGates.value.splice(from, 1)
66
+ draftGates.value.splice(to, 0, gate ?? false)
67
+ const [on] = draftEnabled.value.splice(from, 1)
68
+ draftEnabled.value.splice(to, 0, on ?? true)
69
+ const [th] = draftThresholds.value.splice(from, 1)
70
+ draftThresholds.value.splice(to, 0, th ?? null)
71
+ const [cons] = draftConsensus.value.splice(from, 1)
72
+ draftConsensus.value.splice(to, 0, cons ?? null)
73
+ const [gat] = draftGating.value.splice(from, 1)
74
+ draftGating.value.splice(to, 0, gat ?? null)
75
+ const [fu] = draftFollowUps.value.splice(from, 1)
76
+ draftFollowUps.value.splice(to, 0, fu ?? null)
77
+ const [tq] = draftTesterQuality.value.splice(from, 1)
78
+ draftTesterQuality.value.splice(to, 0, tq ?? null)
79
+ const [so] = draftStepOptions.value.splice(from, 1)
80
+ draftStepOptions.value.splice(to, 0, so ?? null)
81
+ }
82
+
83
+ /** Whether the producer step at `index` currently has its companion attached after it. */
84
+ function hasCompanion(index: number): boolean {
85
+ const companion = companionForProducer(draft.value[index] ?? '')
86
+ return companion !== undefined && draft.value[index + 1] === companion
87
+ }
88
+
89
+ /**
90
+ * Toggle the dependent companion on the producer step at `index`: insert it immediately
91
+ * after (turn on) or remove it (turn off). A no-op for a kind that has no companion.
92
+ */
93
+ function toggleCompanion(index: number) {
94
+ const companion = companionForProducer(draft.value[index] ?? '')
95
+ if (!companion) return
96
+ if (draft.value[index + 1] === companion) removeFromDraft(index + 1)
97
+ else insertAt(index + 1, companion)
98
+ }
99
+
100
+ /** Toggle estimate gating on/off for the (companion) step at `index`. */
101
+ function toggleDraftGating(index: number) {
102
+ draftGating.value[index] = draftGating.value[index]?.enabled
103
+ ? null
104
+ : { enabled: true, minRisk: 0.5, minImpact: 0.5, onMissingEstimate: 'run' }
105
+ }
106
+
107
+ /**
108
+ * The draft as a list of "units" for rendering: each step is one unit, EXCEPT a companion
109
+ * that sits immediately after its producer — that companion is folded into the producer's
110
+ * unit (`companionIndex`) and surfaced as a toggle on it, not a standalone row. The backend
111
+ * now REJECTS a companion that is not immediately after its producer (strict adjacency in
112
+ * `validatePipelineShape`), so a saved pipeline never has one — but a stray companion that
113
+ * still shows up in the draft (e.g. a pre-existing pipeline saved before adjacency was
114
+ * enforced) is emitted as its own standalone unit so it stays visible and removable/
115
+ * reorderable into a valid shape rather than being silently dropped — and, crucially, so
116
+ * every `draft` index belongs to exactly one unit, which is what lets {@link moveUnit}
117
+ * reorder by unit boundaries without ever dropping a step.
118
+ * `index`/`companionIndex` are positions in the raw `draft` arrays.
119
+ */
120
+ const units = computed(() => {
121
+ const out: { index: number; kind: AgentKind; companionIndex: number | null }[] = []
122
+ let folded = -1 // draft index already consumed as the previous unit's adjacent companion
123
+ for (let i = 0; i < draft.value.length; i++) {
124
+ const kind = draft.value[i]
125
+ if (kind === undefined || i === folded) continue
126
+ const companion = companionForProducer(kind)
127
+ const companionIndex = companion && draft.value[i + 1] === companion ? i + 1 : null
128
+ if (companionIndex !== null) folded = companionIndex
129
+ out.push({ index: i, kind, companionIndex })
130
+ }
131
+ return out
132
+ })
133
+
134
+ /**
135
+ * Move the unit at visible position `from` to `to`, carrying its attached companion. Rebuilds
136
+ * every parallel array by the SAME unit boundaries so they stay index-aligned.
137
+ */
138
+ function moveUnit(from: number, to: number) {
139
+ const u = units.value
140
+ if (to < 0 || to >= u.length || from === to) return
141
+ const reorder = <T>(arr: T[]): T[] => {
142
+ const chunks = u.map((unit) =>
143
+ arr.slice(unit.index, unit.index + (unit.companionIndex !== null ? 2 : 1)),
144
+ )
145
+ const [moved] = chunks.splice(from, 1)
146
+ if (moved) chunks.splice(to, 0, moved)
147
+ return chunks.flat()
148
+ }
149
+ draft.value = reorder(draft.value)
150
+ draftGates.value = reorder(draftGates.value)
151
+ draftEnabled.value = reorder(draftEnabled.value)
152
+ draftThresholds.value = reorder(draftThresholds.value)
153
+ draftConsensus.value = reorder(draftConsensus.value)
154
+ draftGating.value = reorder(draftGating.value)
155
+ draftFollowUps.value = reorder(draftFollowUps.value)
156
+ draftTesterQuality.value = reorder(draftTesterQuality.value)
157
+ draftStepOptions.value = reorder(draftStepOptions.value)
158
+ }
159
+
160
+ /** Toggle the consensus mechanism on the draft step at `index` (default config / off). */
161
+ function toggleDraftConsensus(index: number) {
162
+ draftConsensus.value[index] = draftConsensus.value[index] ? null : defaultConsensusConfig()
163
+ }
164
+
165
+ /** Replace the consensus config of the draft step at `index` (builder editor edits). */
166
+ function setDraftConsensus(index: number, config: ConsensusStepConfig | null) {
167
+ draftConsensus.value[index] = config
168
+ }
169
+
170
+ /** Toggle the approval gate on the draft step at `index`. */
171
+ function toggleDraftGate(index: number) {
172
+ draftGates.value[index] = !draftGates.value[index]
173
+ }
174
+
175
+ /** Toggle the Follow-up companion on the draft (coder) step at `index` (default on → off). */
176
+ function toggleDraftFollowUps(index: number) {
177
+ // Default (null/true) is enabled, so the first toggle disables it (false); toggle back to null.
178
+ draftFollowUps.value[index] = draftFollowUps.value[index] === false ? null : false
179
+ }
180
+
181
+ /**
182
+ * Toggle the test quality-control companion on the draft (Tester) step at `index`. The
183
+ * companion is enabled by default (a `null` entry), so the first toggle disables it
184
+ * (`{ enabled: false }`, dropping any gating) and the next restores the default.
185
+ */
186
+ function toggleDraftTesterQuality(index: number) {
187
+ draftTesterQuality.value[index] =
188
+ draftTesterQuality.value[index]?.enabled === false ? null : { enabled: false }
189
+ }
190
+
191
+ /**
192
+ * Toggle estimate gating on/off for the QC companion on the draft (Tester) step at `index`.
193
+ * A no-op while the companion is disabled (nothing to gate). Enabling gating pins the config
194
+ * to `{ enabled: true, gating }` so the thresholds are editable; disabling drops back to the
195
+ * default `null` (enabled, ungated).
196
+ */
197
+ function toggleDraftTesterQualityGating(index: number) {
198
+ const cur = draftTesterQuality.value[index]
199
+ if (cur?.enabled === false) return
200
+ draftTesterQuality.value[index] = cur?.gating?.enabled
201
+ ? null
202
+ : {
203
+ enabled: true,
204
+ gating: { enabled: true, minRisk: 0.5, minImpact: 0.5, onMissingEstimate: 'run' },
205
+ }
206
+ }
207
+
208
+ /** Enable/disable the draft step at `index` without removing it. */
209
+ function toggleDraftEnabled(index: number) {
210
+ draftEnabled.value[index] = draftEnabled.value[index] === false
211
+ }
212
+
213
+ /** Whether auto-recommendation is on for the draft (requirements-review) step at `index`. */
214
+ function draftAutoRecommendEnabled(index: number): boolean {
215
+ return draftStepOptions.value[index]?.autoRecommend !== false
216
+ }
217
+
218
+ /**
219
+ * Toggle the requirements-review auto-recommendation on the draft step at `index`. It is on by
220
+ * default, so we store ONLY the opt-out (`{ autoRecommend: false }`); toggling back drops the
221
+ * flag. Merges with any other future StepOptions fields rather than clobbering the whole bag.
222
+ */
223
+ function toggleDraftAutoRecommend(index: number) {
224
+ const next: StepOptions = { ...draftStepOptions.value[index] }
225
+ if (draftAutoRecommendEnabled(index)) next.autoRecommend = false
226
+ else delete next.autoRecommend
227
+ draftStepOptions.value[index] = Object.keys(next).length ? next : null
228
+ }
229
+
230
+ /** The skill picked for the draft `skill` step at `index` (its `stepOptions.skillId`). */
231
+ function draftSkillId(index: number): string | undefined {
232
+ return draftStepOptions.value[index]?.skillId
233
+ }
234
+
235
+ /**
236
+ * Set (or clear) the picked skill on the draft `skill` step at `index`. Merges into the
237
+ * step's `StepOptions` bag rather than clobbering it; clearing drops the field and, if the
238
+ * bag empties, the whole entry (so it normalizes away like the other options).
239
+ */
240
+ function setDraftSkillId(index: number, skillId: string | undefined) {
241
+ const next: StepOptions = { ...draftStepOptions.value[index] }
242
+ if (skillId) next.skillId = skillId
243
+ else delete next.skillId
244
+ draftStepOptions.value[index] = Object.keys(next).length ? next : null
245
+ }
246
+
247
+ function clearDraft() {
248
+ draft.value = []
249
+ draftGates.value = []
250
+ draftEnabled.value = []
251
+ draftThresholds.value = []
252
+ draftConsensus.value = []
253
+ draftGating.value = []
254
+ draftFollowUps.value = []
255
+ draftTesterQuality.value = []
256
+ draftStepOptions.value = []
257
+ draftLabels.value = []
258
+ draftPurpose.value = null
259
+ draftName.value = 'New pipeline'
260
+ draftDescription.value = ''
261
+ editingId.value = null
262
+ }
263
+
264
+ /** Load an existing (custom) pipeline into the draft so it can be edited in place. */
265
+ function loadForEdit(pipeline: Pipeline) {
266
+ draft.value = [...pipeline.agentKinds]
267
+ draftGates.value = pipeline.agentKinds.map((_, i) => pipeline.gates?.[i] ?? false)
268
+ draftEnabled.value = pipeline.agentKinds.map((_, i) => pipeline.enabled?.[i] ?? true)
269
+ draftThresholds.value = pipeline.agentKinds.map((_, i) => pipeline.thresholds?.[i] ?? null)
270
+ draftConsensus.value = pipeline.agentKinds.map((_, i) => pipeline.consensus?.[i] ?? null)
271
+ draftGating.value = pipeline.agentKinds.map((_, i) => pipeline.gating?.[i] ?? null)
272
+ draftFollowUps.value = pipeline.agentKinds.map((_, i) => pipeline.followUps?.[i] ?? null)
273
+ draftTesterQuality.value = pipeline.agentKinds.map(
274
+ (_, i) => pipeline.testerQuality?.[i] ?? null,
275
+ )
276
+ draftStepOptions.value = pipeline.agentKinds.map((_, i) => pipeline.stepOptions?.[i] ?? null)
277
+ draftLabels.value = [...(pipeline.labels ?? [])]
278
+ draftPurpose.value = pipeline.purpose ?? null
279
+ draftName.value = pipeline.name
280
+ draftDescription.value = pipeline.description ?? ''
281
+ editingId.value = pipeline.id
282
+ }
283
+
284
+ return {
285
+ addToDraft,
286
+ removeFromDraft,
287
+ moveInDraft,
288
+ hasCompanion,
289
+ toggleCompanion,
290
+ toggleDraftGating,
291
+ units,
292
+ moveUnit,
293
+ toggleDraftConsensus,
294
+ setDraftConsensus,
295
+ toggleDraftGate,
296
+ toggleDraftFollowUps,
297
+ toggleDraftTesterQuality,
298
+ toggleDraftTesterQualityGating,
299
+ toggleDraftEnabled,
300
+ draftAutoRecommendEnabled,
301
+ toggleDraftAutoRecommend,
302
+ draftSkillId,
303
+ setDraftSkillId,
304
+ clearDraft,
305
+ loadForEdit,
306
+ }
307
+ }
@@ -0,0 +1,136 @@
1
+ import type { Pipeline } from '~/types/domain'
2
+ import { useWorkspaceStore } from '~/stores/workspace'
3
+ import type { PipelinesContext } from './context'
4
+
5
+ /**
6
+ * The pipeline persistence operations — building the draft payload and the create/update/clone/
7
+ * remove/reseed/organize API calls. Extracted from the store setup into a factory closing over the
8
+ * shared {@link PipelinesContext} plus the draft-lifecycle helpers it drives (`clearDraft` /
9
+ * `loadForEdit`, owned by {@link createPipelineDraftActions}), so behaviour is byte-identical to the
10
+ * former in-closure functions — the split is purely to keep the setup within the size budget.
11
+ */
12
+ export function createPipelinePersistence(
13
+ ctx: PipelinesContext,
14
+ draft: { clearDraft: () => void; loadForEdit: (pipeline: Pipeline) => void },
15
+ ) {
16
+ const { api, upsertPipeline, dropPipeline, editingId } = ctx
17
+ const { clearDraft, loadForEdit } = draft
18
+
19
+ /** The optional arrays to send, omitting the ones that are at their defaults. */
20
+ function draftPayload() {
21
+ return {
22
+ name: ctx.draftName.value.trim() || 'Untitled pipeline',
23
+ // ALWAYS send description (like stepOptions) so an update can CLEAR it — an omitted field
24
+ // reads as "keep existing", so toggling the last of the text away must send the empty string.
25
+ // The backend trims + drops a blank one, so this is a no-op on create / an empty description.
26
+ description: ctx.draftDescription.value.trim(),
27
+ agentKinds: [...ctx.draft.value],
28
+ // Only send gates when at least one step is gated.
29
+ ...(ctx.draftGates.value.some(Boolean) ? { gates: [...ctx.draftGates.value] } : {}),
30
+ // Only send enabled when at least one step is disabled (default is all-on).
31
+ ...(ctx.draftEnabled.value.some((e) => e === false)
32
+ ? { enabled: [...ctx.draftEnabled.value] }
33
+ : {}),
34
+ // Only send thresholds when at least one step pins an explicit value.
35
+ ...(ctx.draftThresholds.value.some((t) => t != null)
36
+ ? { thresholds: [...ctx.draftThresholds.value] }
37
+ : {}),
38
+ // Only send consensus when at least one step is consensus-enabled.
39
+ ...(ctx.draftConsensus.value.some((c) => c?.enabled)
40
+ ? { consensus: [...ctx.draftConsensus.value] }
41
+ : {}),
42
+ // Only send gating when at least one step has gating enabled.
43
+ ...(ctx.draftGating.value.some((g) => g?.enabled)
44
+ ? { gating: [...ctx.draftGating.value] }
45
+ : {}),
46
+ // Only send followUps when at least one step disables it (default is on, so only the
47
+ // explicit `false` opt-outs are worth persisting).
48
+ ...(ctx.draftFollowUps.value.some((f) => f === false)
49
+ ? { followUps: [...ctx.draftFollowUps.value] }
50
+ : {}),
51
+ // Only send testerQuality when at least one Tester step deviates from the default
52
+ // (companion disabled, or an estimate gate configured) — the default (null/enabled,
53
+ // ungated) is not worth persisting.
54
+ ...(ctx.draftTesterQuality.value.some((q) => q?.enabled === false || q?.gating?.enabled)
55
+ ? { testerQuality: [...ctx.draftTesterQuality.value] }
56
+ : {}),
57
+ // ALWAYS send stepOptions, unlike the legacy per-step arrays above. Those omit-when-default,
58
+ // which means an update can never CLEAR them (an omitted field reads as "keep existing"), so
59
+ // toggling the last opt-out back to its default on a saved pipeline would silently not
60
+ // persist. Sending the aligned array always lets `update` overwrite; the backend normalizes
61
+ // an all-default array away (stores nothing), so this is a no-op on create / all-default.
62
+ stepOptions: ctx.draftStepOptions.value.map((o) => o ?? null),
63
+ // Only send labels when there are any.
64
+ ...(ctx.draftLabels.value.length ? { labels: [...ctx.draftLabels.value] } : {}),
65
+ // Only send purpose when the pipeline is classified (null ⇒ leave unclassified). Like the
66
+ // legacy per-step arrays, an omitted `purpose` on update reads as "keep existing"; clearing
67
+ // a classification back to unclassified is not a supported edit (every built-in ships one).
68
+ ...(ctx.draftPurpose.value ? { purpose: ctx.draftPurpose.value } : {}),
69
+ }
70
+ }
71
+
72
+ /** Persist the draft: update the pipeline being edited, else create a new one. */
73
+ async function saveDraft(): Promise<Pipeline | null> {
74
+ if (ctx.draft.value.length === 0) return null
75
+ const wsId = useWorkspaceStore().requireId()
76
+ const payload = draftPayload()
77
+ if (editingId.value) {
78
+ const updated = await api.updatePipeline(wsId, editingId.value, payload)
79
+ upsertPipeline(updated)
80
+ clearDraft()
81
+ return updated
82
+ }
83
+ const pipeline = await api.createPipeline(wsId, payload)
84
+ upsertPipeline(pipeline)
85
+ clearDraft()
86
+ return pipeline
87
+ }
88
+
89
+ /** Clone any pipeline (built-in or custom) into an editable copy, ready to edit. */
90
+ async function clonePipeline(id: string): Promise<Pipeline> {
91
+ const clone = await api.clonePipeline(useWorkspaceStore().requireId(), id)
92
+ upsertPipeline(clone)
93
+ loadForEdit(clone)
94
+ return clone
95
+ }
96
+
97
+ async function removePipeline(id: string) {
98
+ await api.removePipeline(useWorkspaceStore().requireId(), id)
99
+ dropPipeline(id)
100
+ if (editingId.value === id) clearDraft()
101
+ }
102
+
103
+ /**
104
+ * Reseed a built-in pipeline from the backend's current catalog definition: restores its
105
+ * canonical structure + version (adopting an update, or repairing a drifted/invalid copy)
106
+ * while preserving its labels/archive state. Replaces the pipeline in the cache.
107
+ */
108
+ async function reseed(id: string): Promise<Pipeline> {
109
+ const updated = await api.reseedPipeline(useWorkspaceStore().requireId(), id)
110
+ upsertPipeline(updated)
111
+ if (editingId.value === id) clearDraft()
112
+ return updated
113
+ }
114
+
115
+ /** Set a pipeline's organizational metadata (labels / archive). Works on built-ins too. */
116
+ async function organize(id: string, body: { labels?: string[]; archived?: boolean }) {
117
+ const updated = await api.organizePipeline(useWorkspaceStore().requireId(), id, body)
118
+ upsertPipeline(updated)
119
+ return updated
120
+ }
121
+
122
+ const archive = (id: string) => organize(id, { archived: true })
123
+ const unarchive = (id: string) => organize(id, { archived: false })
124
+ const setLabels = (id: string, labels: string[]) => organize(id, { labels })
125
+
126
+ return {
127
+ saveDraft,
128
+ clonePipeline,
129
+ removePipeline,
130
+ reseed,
131
+ organize,
132
+ archive,
133
+ unarchive,
134
+ setLabels,
135
+ }
136
+ }