@cat-factory/app 0.20.0 → 0.22.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,83 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { Block } from '~/types/domain'
4
+ import { STATUS_META } from '~/utils/catalog'
5
+
6
+ // The epic inspector body: the full tree of member tasks (which may live under different
7
+ // services/modules), grouped service → module → task. Each task row selects it. Membership
8
+ // is the task's `epicId`; the epic is non-structural, so this reads across the whole board.
9
+ const props = defineProps<{ block: Block }>()
10
+
11
+ const board = useBoardStore()
12
+ const ui = useUiStore()
13
+
14
+ const members = computed(() => board.epicMembers(props.block.id))
15
+ const done = computed(() => members.value.filter((m) => m.status === 'done').length)
16
+
17
+ /** Member tasks grouped by their owning service, then by module (or "direct"). */
18
+ const groups = computed(() => {
19
+ const byService = new Map<
20
+ string,
21
+ { service: Block | undefined; modules: Map<string, { module: Block | undefined; tasks: Block[] }> }
22
+ >()
23
+ for (const task of members.value) {
24
+ const service = board.serviceOf(task)
25
+ const serviceKey = service?.id ?? '—'
26
+ if (!byService.has(serviceKey)) byService.set(serviceKey, { service, modules: new Map() })
27
+ const group = byService.get(serviceKey)!
28
+ // The task's structural container: a module when its parent is a module, else "direct".
29
+ const parent = task.parentId ? board.getBlock(task.parentId) : undefined
30
+ const moduleKey = parent && parent.level === 'module' ? parent.id : '—'
31
+ if (!group.modules.has(moduleKey)) {
32
+ group.modules.set(moduleKey, { module: parent?.level === 'module' ? parent : undefined, tasks: [] })
33
+ }
34
+ group.modules.get(moduleKey)!.tasks.push(task)
35
+ }
36
+ return [...byService.values()]
37
+ })
38
+ </script>
39
+
40
+ <template>
41
+ <div>
42
+ <div class="mb-1 flex items-center justify-between">
43
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
44
+ Epic tasks
45
+ </span>
46
+ <span class="text-[11px] text-slate-500">{{ done }}/{{ members.length }} done</span>
47
+ </div>
48
+
49
+ <div v-if="members.length === 0" class="text-[11px] text-slate-500">
50
+ No tasks belong to this epic yet. Import an epic's children, or set a task's epic.
51
+ </div>
52
+
53
+ <div v-else class="space-y-2">
54
+ <div v-for="(group, gi) in groups" :key="gi" class="rounded-md border border-slate-700/60 p-2">
55
+ <div class="mb-1 flex items-center gap-1 text-[11px] font-medium text-slate-300">
56
+ <UIcon name="i-lucide-box" class="h-3 w-3 text-slate-500" />
57
+ {{ group.service?.title ?? 'Unassigned' }}
58
+ </div>
59
+ <div v-for="(mod, mi) in [...group.modules.values()]" :key="mi" class="pl-1">
60
+ <div v-if="mod.module" class="text-[10px] uppercase tracking-wide text-slate-500">
61
+ {{ mod.module.title }}
62
+ </div>
63
+ <button
64
+ v-for="task in mod.tasks"
65
+ :key="task.id"
66
+ type="button"
67
+ class="flex w-full items-center gap-1.5 rounded px-1 py-0.5 text-left text-xs text-slate-200 hover:bg-slate-800"
68
+ @click="ui.select(task.id)"
69
+ >
70
+ <span
71
+ class="h-2 w-2 shrink-0 rounded-full"
72
+ :style="{ backgroundColor: STATUS_META[task.status].color }"
73
+ />
74
+ <span class="truncate">{{ task.title }}</span>
75
+ <span class="ml-auto shrink-0 text-[10px] text-slate-500">
76
+ {{ STATUS_META[task.status].label }}
77
+ </span>
78
+ </button>
79
+ </div>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ </template>
@@ -45,6 +45,13 @@ function setResponsible(userId: string) {
45
45
  board.updateBlock(props.block.id, { responsibleProductUserId: userId })
46
46
  }
47
47
 
48
+ // ---- auto-start dependents -------------------------------------------------
49
+ // Preceding-task toggle: when this task merges, the engine auto-starts the tasks that
50
+ // depend on it (skipping any on an individual-usage model, which can't unlock unattended).
51
+ function setAutoStartDependents(value: boolean) {
52
+ board.updateBlock(props.block.id, { autoStartDependents: value })
53
+ }
54
+
48
55
  // ---- merge policy preset ---------------------------------------------------
49
56
  // Which merge threshold preset governs this task's auto-merge decision + CI-fixer
50
57
  // budget. None selected → the workspace default preset. (The old confidence-based
@@ -365,5 +372,23 @@ const resolveOnMergeLabel = computed(() =>
365
372
  Unassigned — set a product owner to notify them when requirement review flags this task.
366
373
  </div>
367
374
  </div>
375
+
376
+ <!-- auto-start dependents: when this task merges, start the tasks that depend on it -->
377
+ <div>
378
+ <div class="flex items-center justify-between gap-2">
379
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
380
+ Auto-start dependents
381
+ </span>
382
+ <USwitch
383
+ size="sm"
384
+ :model-value="block.autoStartDependents ?? false"
385
+ @update:model-value="setAutoStartDependents"
386
+ />
387
+ </div>
388
+ <div class="mt-1 text-[11px] text-slate-500">
389
+ When this task merges, automatically start the tasks that depend on it (once their
390
+ other dependencies are also done).
391
+ </div>
392
+ </div>
368
393
  </div>
369
394
  </template>
@@ -25,6 +25,7 @@ const ROUTABLE: { type: NotificationType; label: string }[] = [
25
25
  { type: 'requirement_review', label: 'Requirement review' },
26
26
  { type: 'clarity_review', label: 'Clarity review' },
27
27
  { type: 'release_regression', label: 'Release regression' },
28
+ { type: 'human_test_ready', label: 'Ready for human testing' },
28
29
  ]
29
30
 
30
31
  /** Notification-role options for a mapped member (drives who gets @-mentioned). */
@@ -41,6 +42,7 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
41
42
  release_regression: { enabled: false, channel: '' },
42
43
  // In-app only (not in ROUTABLE), but the map is exhaustive over the type.
43
44
  decision_required: { enabled: false, channel: '' },
45
+ human_test_ready: { enabled: false, channel: '' },
44
46
  })
45
47
  const mentionsEnabled = ref(false)
46
48
  const mapping = ref<SlackMemberMappingEntry[]>([])
@@ -162,6 +162,35 @@ async function doImport() {
162
162
  importing.value = false
163
163
  }
164
164
  }
165
+
166
+ // Spawn the referenced issue as an EPIC: an epic node + a task per child issue (into the
167
+ // chosen container), with dependency edges seeded from the issues' blocked-by/depends-on
168
+ // links. Needs a container for the child tasks.
169
+ async function doSpawnEpic() {
170
+ const value = ref_.value.trim()
171
+ if (!value || !source.value || !containerId.value) return
172
+ importing.value = true
173
+ try {
174
+ const { epic, tasks: spawned } = await tasks.spawnEpic(source.value, value, containerId.value)
175
+ ref_.value = ''
176
+ ui.closeTaskImport()
177
+ ui.select(epic.id)
178
+ toast.add({
179
+ title: `Spawned epic "${epic.title}"`,
180
+ description: `${spawned.length} child task(s) created`,
181
+ icon: 'i-lucide-layers',
182
+ })
183
+ } catch (e) {
184
+ toast.add({
185
+ title: 'Could not spawn epic',
186
+ description: e instanceof Error ? e.message : String(e),
187
+ icon: 'i-lucide-triangle-alert',
188
+ color: 'error',
189
+ })
190
+ } finally {
191
+ importing.value = false
192
+ }
193
+ }
165
194
  </script>
166
195
 
167
196
  <template>
@@ -209,8 +238,38 @@ async function doImport() {
209
238
  >
210
239
  Import
211
240
  </UButton>
241
+ <UButton
242
+ color="primary"
243
+ variant="soft"
244
+ icon="i-lucide-layers"
245
+ :loading="importing"
246
+ :disabled="!ref_.trim() || !containerId"
247
+ :title="
248
+ containerId
249
+ ? 'Spawn this epic + its children as a linked task group'
250
+ : 'Pick a container for the child tasks first'
251
+ "
252
+ @click="doSpawnEpic"
253
+ >
254
+ As epic
255
+ </UButton>
212
256
  </div>
213
257
 
258
+ <!-- Container for epic children when spawning from a pasted ref (the shared
259
+ "Create tasks in" selector below covers the search-results case). -->
260
+ <UFormField
261
+ v-if="containerItems.length && !freshHits.length && !sourceTasks.length"
262
+ label="Epic children container"
263
+ class="w-72"
264
+ >
265
+ <USelect
266
+ v-model="containerId"
267
+ :items="containerItems"
268
+ placeholder="Pick a frame or module"
269
+ class="w-full"
270
+ />
271
+ </UFormField>
272
+
214
273
  <!-- Browse: search the tracker by title so an issue can be turned into a
215
274
  task without knowing its key. -->
216
275
  <UFormField v-if="searchable" label="Search issues">
@@ -51,6 +51,16 @@ export function boardApi({ http, ws }: ApiContext) {
51
51
  body: { name: string; position?: Position },
52
52
  ) => http<Block>(`${ws(workspaceId)}/blocks/${blockId}/modules`, { method: 'POST', body }),
53
53
 
54
+ // Create an epic grouping node (optionally placed under a service/module).
55
+ addEpic: (
56
+ workspaceId: string,
57
+ body: { title: string; description?: string; position: Position; parentId?: string },
58
+ ) => http<Block>(`${ws(workspaceId)}/epics`, { method: 'POST', body }),
59
+
60
+ // Assign a task to an epic, or detach it (epicId: null).
61
+ assignToEpic: (workspaceId: string, blockId: string, body: { epicId: string | null }) =>
62
+ http<Block>(`${ws(workspaceId)}/blocks/${blockId}/epic`, { method: 'POST', body }),
63
+
54
64
  updateBlock: (workspaceId: string, blockId: string, body: Partial<Block>) =>
55
65
  http<Block>(`${ws(workspaceId)}/blocks/${blockId}`, { method: 'PATCH', body }),
56
66
 
@@ -0,0 +1,37 @@
1
+ import type { ExecutionInstance } from '~/types/domain'
2
+ import type { ApiContext } from './context'
3
+
4
+ /**
5
+ * The human-testing gate's run-driving actions. Each acts on the block's parked `human-test`
6
+ * step and returns the updated execution instance (the gate state rides on its current step,
7
+ * and also arrives live via the execution stream).
8
+ */
9
+ export function humanTestApi({ http, ws }: ApiContext) {
10
+ const base = (workspaceId: string, blockId: string) =>
11
+ `${ws(workspaceId)}/blocks/${encodeURIComponent(blockId)}/human-test`
12
+
13
+ return {
14
+ // Confirm the change works: tear the env down and advance the pipeline.
15
+ confirmHumanTest: (workspaceId: string, blockId: string) =>
16
+ http<ExecutionInstance>(`${base(workspaceId, blockId)}/confirm`, { method: 'POST' }),
17
+
18
+ // Submit findings and request a fix (dispatches the Tester's fixer, then rebuilds the env).
19
+ requestHumanTestFix: (workspaceId: string, blockId: string, findings: string) =>
20
+ http<ExecutionInstance>(`${base(workspaceId, blockId)}/request-fix`, {
21
+ method: 'POST',
22
+ body: { findings },
23
+ }),
24
+
25
+ // Pull latest main into the PR branch + redeploy (conflict → conflict-resolver).
26
+ pullMainHumanTest: (workspaceId: string, blockId: string) =>
27
+ http<ExecutionInstance>(`${base(workspaceId, blockId)}/pull-main`, { method: 'POST' }),
28
+
29
+ // Rebuild the ephemeral environment on demand.
30
+ recreateHumanTestEnv: (workspaceId: string, blockId: string) =>
31
+ http<ExecutionInstance>(`${base(workspaceId, blockId)}/recreate-env`, { method: 'POST' }),
32
+
33
+ // Destroy the ephemeral environment on demand (the run stays parked).
34
+ destroyHumanTestEnv: (workspaceId: string, blockId: string) =>
35
+ http<ExecutionInstance>(`${base(workspaceId, blockId)}/destroy-env`, { method: 'POST' }),
36
+ }
37
+ }
@@ -82,6 +82,18 @@ export function tasksApi({ http, ws }: ApiContext) {
82
82
  body,
83
83
  }),
84
84
 
85
+ // Spawn an epic + its children as an epic node + child tasks, with dependency edges
86
+ // seeded from the issues' blocked-by/depends-on links.
87
+ spawnEpic: (
88
+ workspaceId: string,
89
+ source: TaskSourceKind,
90
+ body: { ref: string; containerId: string; position?: { x: number; y: number } },
91
+ ) =>
92
+ http<{ epic: Block; tasks: Block[] }>(
93
+ `${ws(workspaceId)}/task-sources/${source}/epics/spawn`,
94
+ { method: 'POST', body },
95
+ ),
96
+
85
97
  // ---- issue-tracker selection (workspace-level) ------------------------
86
98
  getTrackerSettings: (workspaceId: string) =>
87
99
  http<TrackerSettings>(`${ws(workspaceId)}/tracker-settings`),
@@ -8,6 +8,7 @@ import { documentsApi } from './api/documents'
8
8
  import { executionApi } from './api/execution'
9
9
  import { fragmentsApi } from './api/fragments'
10
10
  import { githubApi } from './api/github'
11
+ import { humanTestApi } from './api/humanTest'
11
12
  import { modelsApi } from './api/models'
12
13
  import { notificationsApi } from './api/notifications'
13
14
  import { presetsApi } from './api/presets'
@@ -80,6 +81,7 @@ export function useApi() {
80
81
  ...documentsApi(ctx),
81
82
  ...tasksApi(ctx),
82
83
  ...reviewsApi(ctx),
84
+ ...humanTestApi(ctx),
83
85
  ...specApi(ctx),
84
86
  ...notificationsApi(ctx),
85
87
  ...presetsApi(ctx),
@@ -56,6 +56,19 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
56
56
  /** All tasks across every service (used for the dependency picker). */
57
57
  const allTasks = computed(() => blocks.value.filter((b) => b.level === 'task'))
58
58
 
59
+ /** Epic grouping nodes (non-structural; group tasks via their `epicId`). */
60
+ const epics = computed(() => blocks.value.filter((b) => b.level === 'epic'))
61
+
62
+ /** The tasks that belong to an epic (anywhere on the board) via their `epicId`. */
63
+ function epicMembers(epicId: string): Block[] {
64
+ return blocks.value.filter((b) => b.epicId === epicId)
65
+ }
66
+
67
+ /** The epic a task belongs to, if any. */
68
+ function epicOf(task: Block): Block | undefined {
69
+ return task.epicId ? getBlock(task.epicId) : undefined
70
+ }
71
+
59
72
  /** A task's dependencies that are not yet merged (i.e. block it from running). */
60
73
  function unmetDeps(taskId: string) {
61
74
  const t = getBlock(taskId)
@@ -139,6 +152,9 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
139
152
  getBlock,
140
153
  frames,
141
154
  allTasks,
155
+ epics,
156
+ epicMembers,
157
+ epicOf,
142
158
  childrenOf,
143
159
  tasksOf,
144
160
  modulesOf,
@@ -0,0 +1,61 @@
1
+ import { ref } from 'vue'
2
+
3
+ /**
4
+ * Drag-to-connect for board dependencies. A small connector handle on a task card calls
5
+ * {@link start} on pointerdown; we then track the pointer (a live preview line drawn by
6
+ * `DependencyConnectOverlay`) and, on release over another task card, create the edge
7
+ * "dropped-on dependsOn dragged-from" — i.e. you drag from the prerequisite onto the task
8
+ * that should wait for it. Module-level state so the handle and the overlay share one drag.
9
+ *
10
+ * Coordinates are client (screen) space, so the overlay is a fixed full-viewport SVG and
11
+ * the gesture follows pan/zoom for free (the cards move under the cursor, the line tracks
12
+ * the cursor). Target resolution uses `elementFromPoint` → nearest `[data-block-id]`.
13
+ */
14
+ export interface ConnectState {
15
+ sourceId: string
16
+ x1: number
17
+ y1: number
18
+ x2: number
19
+ y2: number
20
+ }
21
+
22
+ const connecting = ref<ConnectState | null>(null)
23
+
24
+ export function useDependencyConnect() {
25
+ const board = useBoardStore()
26
+
27
+ function onMove(ev: PointerEvent) {
28
+ if (!connecting.value) return
29
+ connecting.value.x2 = ev.clientX
30
+ connecting.value.y2 = ev.clientY
31
+ }
32
+
33
+ async function onUp(ev: PointerEvent) {
34
+ const state = connecting.value
35
+ window.removeEventListener('pointermove', onMove)
36
+ window.removeEventListener('pointerup', onUp)
37
+ connecting.value = null
38
+ if (!state) return
39
+ // Resolve the task under the cursor on release.
40
+ const el = document.elementFromPoint(ev.clientX, ev.clientY)
41
+ const card = el?.closest('[data-block-id]') as HTMLElement | null
42
+ const targetId = card?.getAttribute('data-block-id') ?? null
43
+ if (!targetId || targetId === state.sourceId) return
44
+ const target = board.getBlock(targetId)
45
+ const source = board.getBlock(state.sourceId)
46
+ if (!target || target.level !== 'task' || !source || source.level !== 'task') return
47
+ // Dropped-on task depends on the dragged-from (prerequisite) task.
48
+ await board.toggleDependency(targetId, state.sourceId)
49
+ }
50
+
51
+ /** Begin a connect drag from `sourceId`'s handle. */
52
+ function start(sourceId: string, ev: PointerEvent) {
53
+ ev.preventDefault()
54
+ ev.stopPropagation()
55
+ connecting.value = { sourceId, x1: ev.clientX, y1: ev.clientY, x2: ev.clientX, y2: ev.clientY }
56
+ window.addEventListener('pointermove', onMove)
57
+ window.addEventListener('pointerup', onUp)
58
+ }
59
+
60
+ return { connecting, start }
61
+ }
@@ -15,8 +15,8 @@ import { useBlockQueries } from '~/composables/useBlockQueries'
15
15
  interface RemovalSnapshot {
16
16
  /** The removed block + all its descendants, in their original order. */
17
17
  removed: Block[]
18
- /** Survivors whose `dependsOn` lost an edge to a removed block (originals to restore). */
19
- edges: { id: string; dependsOn: string[] }[]
18
+ /** Survivors whose `dependsOn`/`epicId` lost an edge to a removed block (originals to restore). */
19
+ edges: { id: string; dependsOn: string[]; epicId: string | null }[]
20
20
  }
21
21
 
22
22
  export const useBoardStore = defineStore('board', () => {
@@ -97,6 +97,44 @@ export const useBoardStore = defineStore('board', () => {
97
97
  return block
98
98
  }
99
99
 
100
+ /**
101
+ * Add an epic grouping node. Epics are non-structural: they group tasks via the tasks'
102
+ * `epicId`, so this just drops a new `epic`-level block on the board.
103
+ */
104
+ async function addEpic(
105
+ title: string,
106
+ position: { x: number; y: number },
107
+ options?: { description?: string; parentId?: string },
108
+ ): Promise<Block> {
109
+ const block = await api.addEpic(useWorkspaceStore().requireId(), {
110
+ title,
111
+ position,
112
+ ...(options?.description ? { description: options.description } : {}),
113
+ ...(options?.parentId ? { parentId: options.parentId } : {}),
114
+ })
115
+ upsert(block)
116
+ return block
117
+ }
118
+
119
+ /** Assign a task to an epic, or detach it (epicId: null). */
120
+ async function assignToEpic(taskId: string, epicId: string | null) {
121
+ const t = getBlock(taskId)
122
+ if (!t) return
123
+ const prev = t.epicId ?? null
124
+ t.epicId = epicId // optimistic
125
+ try {
126
+ upsert(await api.assignToEpic(useWorkspaceStore().requireId(), taskId, { epicId }))
127
+ } catch (e) {
128
+ t.epicId = prev
129
+ toast.add({
130
+ title: 'Could not change epic',
131
+ description: e instanceof Error ? e.message : String(e),
132
+ icon: 'i-lucide-triangle-alert',
133
+ color: 'error',
134
+ })
135
+ }
136
+ }
137
+
100
138
  /** Add a module (sub-frame) inside a service. */
101
139
  async function addModule(
102
140
  serviceId: string,
@@ -171,15 +209,22 @@ export const useBoardStore = defineStore('board', () => {
171
209
  }
172
210
  }
173
211
  const removed = blocks.value.filter((b) => doomed.has(b.id))
174
- // Survivors that pointed at a doomed block lose that edge snapshot the originals.
212
+ // Survivors that pointed at a doomed block (dependency edge or epic membership) lose
213
+ // that link — snapshot the originals so a failed delete restores them faithfully.
175
214
  const edges = blocks.value
176
- .filter((b) => !doomed.has(b.id) && b.dependsOn.some((d) => doomed.has(d)))
177
- .map((b) => ({ id: b.id, dependsOn: [...b.dependsOn] }))
215
+ .filter(
216
+ (b) =>
217
+ !doomed.has(b.id) &&
218
+ (b.dependsOn.some((d) => doomed.has(d)) || (b.epicId != null && doomed.has(b.epicId))),
219
+ )
220
+ .map((b) => ({ id: b.id, dependsOn: [...b.dependsOn], epicId: b.epicId ?? null }))
178
221
  blocks.value = blocks.value.filter((b) => !doomed.has(b.id))
179
222
  for (const b of blocks.value) {
180
223
  if (b.dependsOn.some((d) => doomed.has(d))) {
181
224
  b.dependsOn = b.dependsOn.filter((d) => !doomed.has(d))
182
225
  }
226
+ // A member of a deleted epic loses its membership (the task itself survives).
227
+ if (b.epicId != null && doomed.has(b.epicId)) b.epicId = null
183
228
  }
184
229
  return { removed, edges }
185
230
  }
@@ -189,7 +234,10 @@ export const useBoardStore = defineStore('board', () => {
189
234
  for (const b of snap.removed) if (!getBlock(b.id)) blocks.value.push(b)
190
235
  for (const e of snap.edges) {
191
236
  const b = getBlock(e.id)
192
- if (b) b.dependsOn = e.dependsOn
237
+ if (b) {
238
+ b.dependsOn = e.dependsOn
239
+ b.epicId = e.epicId
240
+ }
193
241
  }
194
242
  }
195
243
 
@@ -252,10 +300,23 @@ export const useBoardStore = defineStore('board', () => {
252
300
  upsert(await api.updateBlock(useWorkspaceStore().requireId(), id, patch))
253
301
  }
254
302
 
255
- /** Toggle a dependency edge target -> source (target dependsOn source). */
303
+ /**
304
+ * Toggle a dependency edge target -> source (target dependsOn source). The backend
305
+ * rejects an edge that would close a cycle (422) — surface that as a toast rather than
306
+ * letting it throw unhandled out of a board gesture.
307
+ */
256
308
  async function toggleDependency(targetId: string, sourceId: string) {
257
309
  if (targetId === sourceId || !getBlock(targetId)) return
258
- upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
310
+ try {
311
+ upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
312
+ } catch (e) {
313
+ toast.add({
314
+ title: 'Could not link tasks',
315
+ description: e instanceof Error ? e.message : String(e),
316
+ icon: 'i-lucide-triangle-alert',
317
+ color: 'error',
318
+ })
319
+ }
259
320
  }
260
321
 
261
322
  /** Remove a dependency edge target -> source if it exists. */
@@ -275,6 +336,8 @@ export const useBoardStore = defineStore('board', () => {
275
336
  addServiceFromRepo,
276
337
  addTask,
277
338
  addModule,
339
+ addEpic,
340
+ assignToEpic,
278
341
  reparentBlock,
279
342
  detach,
280
343
  reattach,
@@ -0,0 +1,70 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import { useExecutionStore } from '~/stores/execution'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+
6
+ /**
7
+ * Human-testing gate actions. The gate's live state rides on its execution step
8
+ * (`step.humanTest`) and arrives via the execution stream, so this store holds NO gate
9
+ * state — it only drives the actions (confirm / request a fix / pull main / recreate /
10
+ * destroy) and patches the execution store from each response. A per-block `busy` flag lets
11
+ * the window disable its controls while an action is in flight. Per-workspace; nothing
12
+ * persisted client-side.
13
+ */
14
+ export const useHumanTestStore = defineStore('humanTest', () => {
15
+ const api = useApi()
16
+ const ws = useWorkspaceStore()
17
+ const execution = useExecutionStore()
18
+
19
+ /** Block ids with an action currently in flight (the window disables its buttons). */
20
+ const busy = ref<Set<string>>(new Set())
21
+
22
+ function isBusy(blockId: string): boolean {
23
+ return busy.value.has(blockId)
24
+ }
25
+
26
+ async function run(blockId: string, action: () => Promise<unknown>): Promise<void> {
27
+ const next = new Set(busy.value)
28
+ next.add(blockId)
29
+ busy.value = next
30
+ try {
31
+ const instance = await action()
32
+ // The action returns the updated run; patch the store so the window reflects it
33
+ // immediately (the stream also pushes it, but this avoids a flash of stale state).
34
+ if (instance && typeof instance === 'object' && 'steps' in instance) {
35
+ execution.upsert(instance as Parameters<typeof execution.upsert>[0])
36
+ }
37
+ } finally {
38
+ const after = new Set(busy.value)
39
+ after.delete(blockId)
40
+ busy.value = after
41
+ }
42
+ }
43
+
44
+ /** Confirm the change works: tear the env down and advance the pipeline. */
45
+ function confirm(blockId: string): Promise<void> {
46
+ return run(blockId, () => api.confirmHumanTest(ws.requireId(), blockId))
47
+ }
48
+
49
+ /** Submit findings and request a fix. */
50
+ function requestFix(blockId: string, findings: string): Promise<void> {
51
+ return run(blockId, () => api.requestHumanTestFix(ws.requireId(), blockId, findings))
52
+ }
53
+
54
+ /** Pull latest main into the branch + redeploy. */
55
+ function pullMain(blockId: string): Promise<void> {
56
+ return run(blockId, () => api.pullMainHumanTest(ws.requireId(), blockId))
57
+ }
58
+
59
+ /** Rebuild the ephemeral environment. */
60
+ function recreateEnv(blockId: string): Promise<void> {
61
+ return run(blockId, () => api.recreateHumanTestEnv(ws.requireId(), blockId))
62
+ }
63
+
64
+ /** Destroy the ephemeral environment (the run stays parked). */
65
+ function destroyEnv(blockId: string): Promise<void> {
66
+ return run(blockId, () => api.destroyHumanTestEnv(ws.requireId(), blockId))
67
+ }
68
+
69
+ return { isBusy, confirm, requestFix, pullMain, recreateEnv, destroyEnv }
70
+ })
@@ -9,6 +9,7 @@ import type {
9
9
  TaskSourceState,
10
10
  } from '~/types/domain'
11
11
  import { useWorkspaceStore } from '~/stores/workspace'
12
+ import { useBoardStore } from '~/stores/board'
12
13
 
13
14
  /**
14
15
  * Task-source integration state: the trackers the backend offers (and their
@@ -210,6 +211,29 @@ export const useTasksStore = defineStore('tasks', () => {
210
211
  return result
211
212
  }
212
213
 
214
+ /**
215
+ * Spawn an epic and its children onto the board: an epic node + a task per child issue
216
+ * (joined to the epic), with dependency edges seeded from the issues' links. Upserts the
217
+ * created blocks so the board reflects them immediately (the stream also re-broadcasts).
218
+ */
219
+ async function spawnEpic(
220
+ source: TaskSourceKind,
221
+ ref: string,
222
+ containerId: string,
223
+ position?: { x: number; y: number },
224
+ ) {
225
+ const board = useBoardStore()
226
+ const result = await api.spawnEpic(workspace.requireId(), source, {
227
+ ref,
228
+ containerId,
229
+ ...(position ? { position } : {}),
230
+ })
231
+ board.upsert(result.epic)
232
+ for (const t of result.tasks) board.upsert(t)
233
+ await loadTasks().catch(() => {})
234
+ return result
235
+ }
236
+
213
237
  return {
214
238
  available,
215
239
  probeError,
@@ -237,5 +261,6 @@ export const useTasksStore = defineStore('tasks', () => {
237
261
  search,
238
262
  linkToBlock,
239
263
  createTaskFromIssue,
264
+ spawnEpic,
240
265
  }
241
266
  })