@cat-factory/app 0.153.2 → 0.153.4

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,203 @@
1
+ import type { UpdateBlockInput } from '@cat-factory/contracts'
2
+ import { useServicesStore } from '~/stores/services'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import type { BoardWriteContext } from './context'
5
+ import { UNDO_WINDOW_MS } from './context'
6
+
7
+ /**
8
+ * The board's placement (drag/drop/reparent) and per-block edit operations — the writes that
9
+ * move a block or patch its fields, including the dependency edges. Extracted from
10
+ * {@link createBoardMutations} (which keeps the creation writes) along the same seam: each
11
+ * closes over the shared {@link BoardWriteContext} so behaviour is identical to the original
12
+ * in-closure functions, and the split is purely to keep every function within the size budget.
13
+ */
14
+ export function createBoardPlacement(ctx: BoardWriteContext) {
15
+ const { getBlock, upsert, api, toast, tr } = ctx
16
+
17
+ /**
18
+ * Move a block into a new container at a new local position. Drag-reparent commits
19
+ * silently on a small overshoot, so a successful move (into a *different* container,
20
+ * not an undo of one) offers a one-click undo back to its previous home.
21
+ */
22
+ async function reparentBlock(
23
+ id: string,
24
+ newParentId: string,
25
+ position: { x: number; y: number },
26
+ opts: { undoable?: boolean } = { undoable: true },
27
+ ) {
28
+ const b = getBlock(id)
29
+ const parent = getBlock(newParentId)
30
+ if (!b || !parent || b.id === newParentId) return
31
+ // tasks may live in services or modules; modules only in services
32
+ if (b.level === 'task' && parent.level !== 'frame' && parent.level !== 'module') return
33
+ if (b.level === 'module' && parent.level !== 'frame') return
34
+ // Optimistic: drop the block into the new container immediately so it doesn't
35
+ // briefly snap back to its old home while the request is in flight. Snapshot
36
+ // the old home so a rejected reparent restores it rather than leaving the
37
+ // block in the wrong container (a structural lie that survives until re-hydrate).
38
+ const prevParentId = b.parentId
39
+ const prevPosition = b.position
40
+ const name = b.title
41
+ b.parentId = newParentId
42
+ b.position = position
43
+ try {
44
+ upsert(
45
+ await api.reparentBlock(useWorkspaceStore().requireId(), id, {
46
+ parentId: newParentId,
47
+ position,
48
+ }),
49
+ )
50
+ // Offer an undo back to the previous container (a drag overshoot is easy). The undo
51
+ // move is itself non-undoable so the toast doesn't ping-pong.
52
+ if (opts.undoable && prevParentId) {
53
+ toast.add({
54
+ title: tr('board.toast.moved', { name }),
55
+ icon: 'i-lucide-move',
56
+ color: 'neutral',
57
+ duration: UNDO_WINDOW_MS,
58
+ actions: [
59
+ {
60
+ label: tr('common.undo'),
61
+ icon: 'i-lucide-undo-2',
62
+ onClick: () =>
63
+ void reparentBlock(id, prevParentId, prevPosition, { undoable: false }),
64
+ },
65
+ ],
66
+ })
67
+ }
68
+ } catch (e) {
69
+ b.parentId = prevParentId
70
+ b.position = prevPosition
71
+ toast.add({
72
+ title: tr('board.toast.moveFailed'),
73
+ description: e instanceof Error ? e.message : String(e),
74
+ icon: 'i-lucide-triangle-alert',
75
+ color: 'error',
76
+ })
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Local-only optimistic position update during an active drag — no persistence.
82
+ * A drag fires this on every pointer move so the block tracks the cursor without
83
+ * a per-move API round-trip; the final position is committed once via
84
+ * {@link moveBlock} (or {@link reparentBlock}) on release. Persisting every move
85
+ * raced: out-of-order responses to the burst of in-flight writes could land a
86
+ * stale position last, snapping the block back after the user let go.
87
+ */
88
+ function previewMove(id: string, position: { x: number; y: number }) {
89
+ const b = getBlock(id)
90
+ if (b) b.position = position
91
+ }
92
+
93
+ async function moveBlock(id: string, position: { x: number; y: number }) {
94
+ const b = getBlock(id)
95
+ if (!b) return
96
+ const prevPosition = b.position
97
+ b.position = position // optimistic: keep the drag feeling instant
98
+ try {
99
+ // A mounted service frame's position is a PER-WORKSPACE layout override on the mount, not
100
+ // on the (shared) block — so route a frame drag there. Other moves write the block.
101
+ const services = useServicesStore()
102
+ const mount = services.serviceByFrameBlock[id]
103
+ ? services.byServiceId[services.serviceByFrameBlock[id]!.id]
104
+ : undefined
105
+ if (mount) {
106
+ await services.updateLayout(mount.serviceId, position)
107
+ return
108
+ }
109
+ upsert(await api.moveBlock(useWorkspaceStore().requireId(), id, { position }))
110
+ } catch (e) {
111
+ // Restore the pre-drag position — a rejected move must not leave the block at a
112
+ // spot the server never stored (a lie that survives until the next re-hydrate).
113
+ b.position = prevPosition
114
+ toast.add({
115
+ title: tr('board.toast.moveFailed'),
116
+ description: e instanceof Error ? e.message : String(e),
117
+ icon: 'i-lucide-triangle-alert',
118
+ color: 'error',
119
+ })
120
+ }
121
+ }
122
+
123
+ /** Patch the user-editable fields of a block (title, features, threshold…). */
124
+ async function updateBlock(id: string, patch: UpdateBlockInput) {
125
+ const b = getBlock(id)
126
+ if (!b) return
127
+ // Snapshot ONLY the fields this patch touches so a rejected write restores them exactly
128
+ // (a patch may set several at once) rather than leaving a stale optimistic value stuck on
129
+ // screen with no feedback — the same rollback contract the other mutations here follow.
130
+ const prev: Record<string, unknown> = {}
131
+ const patchRecord = patch as Record<string, unknown>
132
+ const record = b as unknown as Record<string, unknown>
133
+ for (const key of Object.keys(patch)) prev[key] = record[key]
134
+ Object.assign(b, patch) // optimistic
135
+ try {
136
+ upsert(await api.updateBlock(useWorkspaceStore().requireId(), id, patch))
137
+ } catch (e) {
138
+ // Re-resolve the block: a live event may have replaced its object reference (`upsert`
139
+ // swaps in a fresh one) while the write was in flight, so `b` can be stale. Only revert
140
+ // fields that still hold OUR optimistic value, so a newer server value that landed
141
+ // mid-flight isn't clobbered by the rollback.
142
+ const cur = getBlock(id) as unknown as Record<string, unknown> | undefined
143
+ if (cur) {
144
+ for (const key of Object.keys(patch)) {
145
+ if (cur[key] === patchRecord[key]) cur[key] = prev[key]
146
+ }
147
+ }
148
+ toast.add({
149
+ title: tr('board.toast.updateFailed'),
150
+ description: e instanceof Error ? e.message : String(e),
151
+ icon: 'i-lucide-triangle-alert',
152
+ color: 'error',
153
+ })
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Toggle a dependency edge target -> source (target dependsOn source). The backend
159
+ * rejects an edge that would close a cycle (422) — surface that as a toast rather than
160
+ * letting it throw unhandled out of a board gesture.
161
+ */
162
+ async function toggleDependency(targetId: string, sourceId: string) {
163
+ if (targetId === sourceId || !getBlock(targetId)) return
164
+ try {
165
+ upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
166
+ } catch (e) {
167
+ toast.add({
168
+ title: tr('board.toast.linkFailed'),
169
+ description: e instanceof Error ? e.message : String(e),
170
+ icon: 'i-lucide-triangle-alert',
171
+ color: 'error',
172
+ })
173
+ }
174
+ }
175
+
176
+ /** Remove a dependency edge target -> source if it exists. */
177
+ async function removeDependency(targetId: string, sourceId: string) {
178
+ const t = getBlock(targetId)
179
+ if (!t || !t.dependsOn.includes(sourceId)) return
180
+ // the backend exposes a single toggle; the edge exists, so toggling removes it
181
+ try {
182
+ upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
183
+ } catch (e) {
184
+ // Mirror `toggleDependency`: a failure must surface (and leave the edge visible) rather
185
+ // than rejecting unhandled with no feedback.
186
+ toast.add({
187
+ title: tr('board.toast.unlinkFailed'),
188
+ description: e instanceof Error ? e.message : String(e),
189
+ icon: 'i-lucide-triangle-alert',
190
+ color: 'error',
191
+ })
192
+ }
193
+ }
194
+
195
+ return {
196
+ reparentBlock,
197
+ previewMove,
198
+ moveBlock,
199
+ updateBlock,
200
+ toggleDependency,
201
+ removeDependency,
202
+ }
203
+ }
@@ -4,6 +4,7 @@ import type { Block } from '~/types/domain'
4
4
  import { useBlockQueries } from '~/composables/useBlockQueries'
5
5
  import type { PendingRemoval } from '~/stores/board/context'
6
6
  import { createBoardMutations } from '~/stores/board/mutations'
7
+ import { createBoardPlacement } from '~/stores/board/placement'
7
8
  import { createBoardRemoval } from '~/stores/board/removal'
8
9
 
9
10
  /**
@@ -11,7 +12,8 @@ import { createBoardRemoval } from '~/stores/board/removal'
11
12
  * are owned by the backend — this store is a hydrated cache. Read getters are
12
13
  * pure client logic (see {@link useBlockQueries}); every mutation calls the API
13
14
  * and applies the authoritative block the server returns. The write operations
14
- * live in cohesive factories ({@link createBoardMutations} / {@link createBoardRemoval},
15
+ * live in cohesive factories ({@link createBoardMutations} / {@link createBoardPlacement} /
16
+ * {@link createBoardRemoval},
15
17
  * under `stores/board/`) that close over the shared state assembled here — a size-only
16
18
  * split, not a new seam.
17
19
  */
@@ -141,6 +143,7 @@ export const useBoardStore = defineStore('board', () => {
141
143
  // internal to the removal factory (only its delete toast wires it), so it is NOT re-exported.
142
144
  const context = { blocks, getBlock, upsert, pendingRemovals, pendingDoomed, api, toast, tr }
143
145
  const mutations = createBoardMutations(context)
146
+ const placement = createBoardPlacement(context)
144
147
  const { detach, reattach, removeBlock } = createBoardRemoval(context)
145
148
 
146
149
  return {
@@ -152,6 +155,7 @@ export const useBoardStore = defineStore('board', () => {
152
155
  upsert,
153
156
  ...queries,
154
157
  ...mutations,
158
+ ...placement,
155
159
  detach,
156
160
  reattach,
157
161
  removeBlock,
@@ -0,0 +1,197 @@
1
+ import type { Ref } from 'vue'
2
+ import type { ExecutionInstance, Pipeline } from '~/types/domain'
3
+ import type { RequestStepChangesInput } from '@cat-factory/contracts'
4
+ import type { IterationCapChoice } from '~/types/execution'
5
+ import { useWorkspaceStore } from '~/stores/workspace'
6
+
7
+ /**
8
+ * Shared reactive state + injected dependencies the execution-store command factory closes
9
+ * over. Created once in the `execution` store setup and threaded into
10
+ * {@link createExecutionCommands} so the split operations stay behaviourally identical to the
11
+ * original single-closure store — a size-only extraction mirroring `stores/board/` and
12
+ * `stores/pipelines/`, not a new seam.
13
+ */
14
+ export interface ExecutionCommandContext {
15
+ api: ReturnType<typeof useApi>
16
+ /** Centralised actionable toasts for run-control failures (see the store's note). */
17
+ runErrors: ReturnType<typeof usePipelineErrorToast>
18
+ instances: Ref<ExecutionInstance[]>
19
+ }
20
+
21
+ /**
22
+ * The run-control commands (start / decide / approve / merge / restart / cancel / stop) the
23
+ * execution store exposes. Each drives the backend and then refreshes the workspace snapshot,
24
+ * since advancing a run also rolls status/progress up onto its block server-side.
25
+ *
26
+ * Every command that can (re-)dispatch a step rides the initiator's personal password through
27
+ * `withCredential`, so an individual-usage (Claude/Codex) run re-mints its short-TTL activation
28
+ * while the user is present instead of breaking mid-pipeline.
29
+ */
30
+ export function createExecutionCommands(ctx: ExecutionCommandContext) {
31
+ const { api, runErrors, instances } = ctx
32
+
33
+ /**
34
+ * Start `pipeline` against a block; the server marks the block in-progress. A block
35
+ * pinned to an individual-usage model (Claude) needs the initiator's personal
36
+ * password — supplied transparently from the local cache, and prompted via the
37
+ * credential modal (then retried) when the server replies 428.
38
+ */
39
+ async function start(blockId: string, pipeline: Pipeline): Promise<boolean> {
40
+ const ws = useWorkspaceStore()
41
+ const personal = usePersonalSubscriptionsStore()
42
+ // Returns false when the user cancels the personal-password prompt OR the start was
43
+ // refused (a 409 conflict, surfaced as an actionable toast here), so an optimistic
44
+ // caller can revert its "Starting…" state without its own error handling.
45
+ try {
46
+ return await personal.withCredential(async (password) => {
47
+ await api.startExecution(ws.requireId(), blockId, { pipelineId: pipeline.id }, password)
48
+ await ws.refresh()
49
+ })
50
+ } catch (e) {
51
+ runErrors.present(e, 'errors.action.startFailed')
52
+ return false
53
+ }
54
+ }
55
+
56
+ // Interacting with a running individual-usage run (resolve/approve/request-changes) advances
57
+ // + re-dispatches the run, so the server re-mints its short-TTL activation from the personal
58
+ // password first. It rides the cached password transparently, and — like start/retry — is
59
+ // gated through `withCredential`: a within-buffer/lapsed cache re-prompts EARLY here (while
60
+ // the user is present) rather than letting the run break mid-pipeline. For a non-individual
61
+ // run the server ignores it and nothing prompts.
62
+ async function resolveDecision(instanceId: string, decisionId: string, choice: string) {
63
+ const ws = useWorkspaceStore()
64
+ const personal = usePersonalSubscriptionsStore()
65
+ return await personal.withCredential(async (password) => {
66
+ await api.resolveDecision(ws.requireId(), instanceId, decisionId, { choice }, password)
67
+ await ws.refresh()
68
+ })
69
+ }
70
+
71
+ /** Approve a step's gated proposal (optionally edited); the run advances. */
72
+ async function approveStep(instanceId: string, approvalId: string, proposal?: string) {
73
+ const ws = useWorkspaceStore()
74
+ const personal = usePersonalSubscriptionsStore()
75
+ return await personal.withCredential(async (password) => {
76
+ await api.approveStep(ws.requireId(), instanceId, approvalId, { proposal }, password)
77
+ await ws.refresh()
78
+ })
79
+ }
80
+
81
+ /** Request changes on a gated proposal; the step re-runs with the review. */
82
+ async function requestStepChanges(
83
+ instanceId: string,
84
+ approvalId: string,
85
+ review: RequestStepChangesInput,
86
+ ) {
87
+ const ws = useWorkspaceStore()
88
+ const personal = usePersonalSubscriptionsStore()
89
+ return await personal.withCredential(async (password) => {
90
+ await api.requestStepChanges(ws.requireId(), instanceId, approvalId, review, password)
91
+ await ws.refresh()
92
+ })
93
+ }
94
+
95
+ /** Reject a gated proposal; the run stops entirely (a retryable failure). */
96
+ async function rejectStep(instanceId: string, approvalId: string, reason?: string) {
97
+ const ws = useWorkspaceStore()
98
+ await api.rejectStep(ws.requireId(), instanceId, approvalId, { reason })
99
+ await ws.refresh()
100
+ }
101
+
102
+ /**
103
+ * Resolve a companion step parked at its rework cap: extra-round (one more pass) /
104
+ * proceed (advance with the current output) / stop-reset (cancel + reset the task).
105
+ * Rides the cached personal password (gated through `withCredential`, so a within-buffer
106
+ * cache re-prompts early) for the server to re-mint the run's activation before
107
+ * re-dispatching on extra-round/proceed.
108
+ */
109
+ async function resolveCompanionExceeded(
110
+ instanceId: string,
111
+ approvalId: string,
112
+ choice: IterationCapChoice,
113
+ ) {
114
+ const ws = useWorkspaceStore()
115
+ const personal = usePersonalSubscriptionsStore()
116
+ return await personal.withCredential(async (password) => {
117
+ await api.resolveCompanionExceeded(
118
+ ws.requireId(),
119
+ instanceId,
120
+ approvalId,
121
+ { choice },
122
+ password,
123
+ )
124
+ await ws.refresh()
125
+ })
126
+ }
127
+
128
+ /** Merge an open PR (a task in `pr_ready`) — the server completes the task. */
129
+ async function mergePr(blockId: string) {
130
+ const ws = useWorkspaceStore()
131
+ try {
132
+ await api.mergeBlock(ws.requireId(), blockId)
133
+ await ws.refresh()
134
+ } catch (e) {
135
+ runErrors.present(e, 'errors.action.mergeFailed')
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Restart a run from a chosen step: the server re-runs from `stepIndex` onward
141
+ * (resetting that step + later steps' iteration counters) while preserving the
142
+ * earlier steps' outputs as handoff context, and re-drives a fresh run. Like
143
+ * start/retry it may dispatch an individual-usage (Claude) step, so it rides the
144
+ * initiator's personal password — prompted (then retried) on a 428. Returns false
145
+ * when the user cancels that prompt (nothing was restarted).
146
+ */
147
+ async function restartFromStep(instanceId: string, stepIndex: number): Promise<boolean> {
148
+ const ws = useWorkspaceStore()
149
+ const personal = usePersonalSubscriptionsStore()
150
+ try {
151
+ return await personal.withCredential(async (password) => {
152
+ await api.restartFromStep(ws.requireId(), instanceId, stepIndex, password)
153
+ await ws.refresh()
154
+ })
155
+ } catch (e) {
156
+ runErrors.present(e, 'errors.action.restartFailed')
157
+ return false
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Cancel the execution running against a block and reset it to planned. `workspaceId`
163
+ * defaults to the current workspace but can be pinned by callers that cancel a run for a
164
+ * board the user may have since navigated away from (e.g. a deferred delete's commit).
165
+ */
166
+ async function cancel(blockId: string, workspaceId?: string) {
167
+ const ws = useWorkspaceStore()
168
+ await api.cancelExecution(workspaceId ?? ws.requireId(), blockId)
169
+ instances.value = instances.value.filter((e) => e.blockId !== blockId)
170
+ await ws.refresh()
171
+ }
172
+
173
+ /**
174
+ * Stop a running execution WITHOUT deleting it: halts the container + durable driver
175
+ * and records the run as `cancelled` (a retryable failure), leaving the block
176
+ * `blocked`. Unlike {@link cancel} the run is kept — its steps/output stay readable on
177
+ * the board and it can be retried from where it stopped. `runId` is the execution id.
178
+ */
179
+ async function stop(runId: string) {
180
+ const ws = useWorkspaceStore()
181
+ await api.stopAgentRun(ws.requireId(), runId)
182
+ await ws.refresh()
183
+ }
184
+
185
+ return {
186
+ start,
187
+ resolveDecision,
188
+ approveStep,
189
+ requestStepChanges,
190
+ rejectStep,
191
+ resolveCompanionExceeded,
192
+ restartFromStep,
193
+ mergePr,
194
+ cancel,
195
+ stop,
196
+ }
197
+ }
@@ -1,21 +1,17 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref, computed } from 'vue'
3
- import type {
4
- Decision,
5
- ExecutionInstance,
6
- Pipeline,
7
- PipelineStep,
8
- StepApproval,
9
- } from '~/types/domain'
10
- import type { RequestStepChangesInput } from '@cat-factory/contracts'
11
- import type { IterationCapChoice } from '~/types/execution'
12
- import { useWorkspaceStore } from '~/stores/workspace'
3
+ import type { Decision, ExecutionInstance, PipelineStep, StepApproval } from '~/types/domain'
4
+ import { createExecutionCommands } from '~/stores/execution/commands'
13
5
 
14
6
  /**
15
7
  * Running pipeline instances. The simulation engine lives on the backend: this
16
8
  * store mirrors the server's executions and drives them via the API. Commands
17
9
  * call the worker and then refresh the workspace snapshot, since advancing an
18
10
  * execution also rolls status/progress up onto its block server-side.
11
+ *
12
+ * The run-control commands live in a cohesive factory ({@link createExecutionCommands}, under
13
+ * `stores/execution/`) that closes over the state assembled here — a size-only split mirroring
14
+ * `stores/board/`, not a new seam.
19
15
  */
20
16
  export const useExecutionStore = defineStore('execution', () => {
21
17
  const api = useApi()
@@ -225,101 +221,6 @@ export const useExecutionStore = defineStore('execution', () => {
225
221
  const decisionsByBlock = computed(() => groupByBlock(openDecisions.value))
226
222
  const approvalsByBlock = computed(() => groupByBlock(openApprovals.value))
227
223
 
228
- /**
229
- * Start `pipeline` against a block; the server marks the block in-progress. A block
230
- * pinned to an individual-usage model (Claude) needs the initiator's personal
231
- * password — supplied transparently from the local cache, and prompted via the
232
- * credential modal (then retried) when the server replies 428.
233
- */
234
- async function start(blockId: string, pipeline: Pipeline): Promise<boolean> {
235
- const ws = useWorkspaceStore()
236
- const personal = usePersonalSubscriptionsStore()
237
- // Returns false when the user cancels the personal-password prompt OR the start was
238
- // refused (a 409 conflict, surfaced as an actionable toast here), so an optimistic
239
- // caller can revert its "Starting…" state without its own error handling.
240
- try {
241
- return await personal.withCredential(async (password) => {
242
- await api.startExecution(ws.requireId(), blockId, { pipelineId: pipeline.id }, password)
243
- await ws.refresh()
244
- })
245
- } catch (e) {
246
- runErrors.present(e, 'errors.action.startFailed')
247
- return false
248
- }
249
- }
250
-
251
- // Interacting with a running individual-usage run (resolve/approve/request-changes) advances
252
- // + re-dispatches the run, so the server re-mints its short-TTL activation from the personal
253
- // password first. It rides the cached password transparently, and — like start/retry — is
254
- // gated through `withCredential`: a within-buffer/lapsed cache re-prompts EARLY here (while
255
- // the user is present) rather than letting the run break mid-pipeline. For a non-individual
256
- // run the server ignores it and nothing prompts.
257
- async function resolveDecision(instanceId: string, decisionId: string, choice: string) {
258
- const ws = useWorkspaceStore()
259
- const personal = usePersonalSubscriptionsStore()
260
- return await personal.withCredential(async (password) => {
261
- await api.resolveDecision(ws.requireId(), instanceId, decisionId, { choice }, password)
262
- await ws.refresh()
263
- })
264
- }
265
-
266
- /** Approve a step's gated proposal (optionally edited); the run advances. */
267
- async function approveStep(instanceId: string, approvalId: string, proposal?: string) {
268
- const ws = useWorkspaceStore()
269
- const personal = usePersonalSubscriptionsStore()
270
- return await personal.withCredential(async (password) => {
271
- await api.approveStep(ws.requireId(), instanceId, approvalId, { proposal }, password)
272
- await ws.refresh()
273
- })
274
- }
275
-
276
- /** Request changes on a gated proposal; the step re-runs with the review. */
277
- async function requestStepChanges(
278
- instanceId: string,
279
- approvalId: string,
280
- review: RequestStepChangesInput,
281
- ) {
282
- const ws = useWorkspaceStore()
283
- const personal = usePersonalSubscriptionsStore()
284
- return await personal.withCredential(async (password) => {
285
- await api.requestStepChanges(ws.requireId(), instanceId, approvalId, review, password)
286
- await ws.refresh()
287
- })
288
- }
289
-
290
- /** Reject a gated proposal; the run stops entirely (a retryable failure). */
291
- async function rejectStep(instanceId: string, approvalId: string, reason?: string) {
292
- const ws = useWorkspaceStore()
293
- await api.rejectStep(ws.requireId(), instanceId, approvalId, { reason })
294
- await ws.refresh()
295
- }
296
-
297
- /**
298
- * Resolve a companion step parked at its rework cap: extra-round (one more pass) /
299
- * proceed (advance with the current output) / stop-reset (cancel + reset the task).
300
- * Rides the cached personal password (gated through `withCredential`, so a within-buffer
301
- * cache re-prompts early) for the server to re-mint the run's activation before
302
- * re-dispatching on extra-round/proceed.
303
- */
304
- async function resolveCompanionExceeded(
305
- instanceId: string,
306
- approvalId: string,
307
- choice: IterationCapChoice,
308
- ) {
309
- const ws = useWorkspaceStore()
310
- const personal = usePersonalSubscriptionsStore()
311
- return await personal.withCredential(async (password) => {
312
- await api.resolveCompanionExceeded(
313
- ws.requireId(),
314
- instanceId,
315
- approvalId,
316
- { choice },
317
- password,
318
- )
319
- await ws.refresh()
320
- })
321
- }
322
-
323
224
  /** How many approval gates anywhere are awaiting a human. */
324
225
  const pendingApprovalCount = computed(() =>
325
226
  instances.value.reduce(
@@ -328,62 +229,11 @@ export const useExecutionStore = defineStore('execution', () => {
328
229
  ),
329
230
  )
330
231
 
331
- /** Merge an open PR (a task in `pr_ready`) the server completes the task. */
332
- async function mergePr(blockId: string) {
333
- const ws = useWorkspaceStore()
334
- try {
335
- await api.mergeBlock(ws.requireId(), blockId)
336
- await ws.refresh()
337
- } catch (e) {
338
- runErrors.present(e, 'errors.action.mergeFailed')
339
- }
340
- }
341
-
342
- /**
343
- * Restart a run from a chosen step: the server re-runs from `stepIndex` onward
344
- * (resetting that step + later steps' iteration counters) while preserving the
345
- * earlier steps' outputs as handoff context, and re-drives a fresh run. Like
346
- * start/retry it may dispatch an individual-usage (Claude) step, so it rides the
347
- * initiator's personal password — prompted (then retried) on a 428. Returns false
348
- * when the user cancels that prompt (nothing was restarted).
349
- */
350
- async function restartFromStep(instanceId: string, stepIndex: number): Promise<boolean> {
351
- const ws = useWorkspaceStore()
352
- const personal = usePersonalSubscriptionsStore()
353
- try {
354
- return await personal.withCredential(async (password) => {
355
- await api.restartFromStep(ws.requireId(), instanceId, stepIndex, password)
356
- await ws.refresh()
357
- })
358
- } catch (e) {
359
- runErrors.present(e, 'errors.action.restartFailed')
360
- return false
361
- }
362
- }
363
-
364
- /**
365
- * Cancel the execution running against a block and reset it to planned. `workspaceId`
366
- * defaults to the current workspace but can be pinned by callers that cancel a run for a
367
- * board the user may have since navigated away from (e.g. a deferred delete's commit).
368
- */
369
- async function cancel(blockId: string, workspaceId?: string) {
370
- const ws = useWorkspaceStore()
371
- await api.cancelExecution(workspaceId ?? ws.requireId(), blockId)
372
- instances.value = instances.value.filter((e) => e.blockId !== blockId)
373
- await ws.refresh()
374
- }
375
-
376
- /**
377
- * Stop a running execution WITHOUT deleting it: halts the container + durable driver
378
- * and records the run as `cancelled` (a retryable failure), leaving the block
379
- * `blocked`. Unlike {@link cancel} the run is kept — its steps/output stay readable on
380
- * the board and it can be retried from where it stopped. `runId` is the execution id.
381
- */
382
- async function stop(runId: string) {
383
- const ws = useWorkspaceStore()
384
- await api.stopAgentRun(ws.requireId(), runId)
385
- await ws.refresh()
386
- }
232
+ // The run-control commands (start / decide / approve / merge / restart / cancel / stop),
233
+ // extracted into a cohesive factory sharing the state above (a size-only split mirroring
234
+ // `stores/board/` and `stores/pipelines/` — behaviour is identical to the former in-closure
235
+ // functions).
236
+ const commands = createExecutionCommands({ api, runErrors, instances })
387
237
 
388
238
  return {
389
239
  instances,
@@ -398,15 +248,6 @@ export const useExecutionStore = defineStore('execution', () => {
398
248
  decisionsByBlock,
399
249
  approvalsByBlock,
400
250
  pendingApprovalCount,
401
- start,
402
- resolveDecision,
403
- approveStep,
404
- requestStepChanges,
405
- rejectStep,
406
- resolveCompanionExceeded,
407
- restartFromStep,
408
- mergePr,
409
- cancel,
410
- stop,
251
+ ...commands,
411
252
  }
412
253
  })