@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.
- package/app/stores/auth/mothership.ts +108 -0
- package/app/stores/auth/session.ts +118 -0
- package/app/stores/auth.ts +17 -167
- package/app/stores/board/mutations.ts +6 -191
- package/app/stores/board/placement.ts +203 -0
- package/app/stores/board.ts +5 -1
- package/app/stores/execution/commands.ts +197 -0
- package/app/stores/execution.ts +12 -171
- package/app/stores/github/connection.ts +60 -0
- package/app/stores/github/context.ts +46 -0
- package/app/stores/github/repoActions.ts +151 -0
- package/app/stores/github.ts +30 -183
- package/app/stores/initiative/curation.ts +89 -0
- package/app/stores/initiative/planning.ts +121 -0
- package/app/stores/initiative.ts +14 -175
- package/app/stores/workspace/hydrate.ts +105 -0
- package/app/stores/workspace.ts +6 -94
- package/package.json +2 -2
|
@@ -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
|
+
}
|
package/app/stores/board.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/app/stores/execution.ts
CHANGED
|
@@ -1,21 +1,17 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { ref, computed } from 'vue'
|
|
3
|
-
import type {
|
|
4
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
402
|
-
resolveDecision,
|
|
403
|
-
approveStep,
|
|
404
|
-
requestStepChanges,
|
|
405
|
-
rejectStep,
|
|
406
|
-
resolveCompanionExceeded,
|
|
407
|
-
restartFromStep,
|
|
408
|
-
mergePr,
|
|
409
|
-
cancel,
|
|
410
|
-
stop,
|
|
251
|
+
...commands,
|
|
411
252
|
}
|
|
412
253
|
})
|