@cat-factory/app 0.147.6 → 0.147.7

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,46 @@
1
+ import type { Ref } from 'vue'
2
+ import type { Block } from '~/types/domain'
3
+
4
+ /** A detached subtree captured before an optimistic delete, restored on failure. */
5
+ export interface RemovalSnapshot {
6
+ /** The removed block + all its descendants, in their original order. */
7
+ removed: Block[]
8
+ /**
9
+ * Survivors whose `dependsOn`/`epicId`/`initiativeId` lost an edge to a removed block
10
+ * (originals to restore on rollback).
11
+ */
12
+ edges: { id: string; dependsOn: string[]; epicId: string | null; initiativeId: string | null }[]
13
+ }
14
+
15
+ /** A still-pending optimistic delete: its snapshot, the deferred-commit timer, and its workspace. */
16
+ export interface PendingRemoval {
17
+ snap: RemovalSnapshot
18
+ timer: ReturnType<typeof setTimeout>
19
+ wsId: string
20
+ }
21
+
22
+ /**
23
+ * How long a deleted block stays undoable. The backend delete is DEFERRED for this
24
+ * window (a real "undo", not a client illusion) — the block is hidden immediately but
25
+ * only actually deleted once the window elapses, so undo just cancels the pending call.
26
+ */
27
+ export const UNDO_WINDOW_MS = 6000
28
+
29
+ /**
30
+ * Shared state + injected dependencies the board-store write factories close over. Created once in
31
+ * the `board` store setup and threaded into {@link createBoardMutations} / {@link createBoardRemoval}
32
+ * so the split operations stay behaviourally identical to the original single-closure store — the
33
+ * factories are cohesive extractions purely to keep each function within the size budget, not new
34
+ * seams. `api`/`toast`/`tr` are the store's own resolved handles (a store runs outside a component
35
+ * `setup`, so `tr` bridges to the Nuxt app's global i18n instance).
36
+ */
37
+ export interface BoardWriteContext {
38
+ blocks: Ref<Block[]>
39
+ getBlock: (id: string) => Block | undefined
40
+ upsert: (block: Block) => void
41
+ pendingRemovals: Map<string, PendingRemoval>
42
+ pendingDoomed: Set<string>
43
+ api: ReturnType<typeof useApi>
44
+ toast: ReturnType<typeof useToast>
45
+ tr: (key: string, params?: Record<string, unknown>) => string
46
+ }
@@ -0,0 +1,362 @@
1
+ import type { UpdateBlockInput } from '@cat-factory/contracts'
2
+ import type {
3
+ BlockType,
4
+ CreateTaskType,
5
+ FrameRepoType,
6
+ TaskTypeFields,
7
+ Block,
8
+ } from '~/types/domain'
9
+ import { useServicesStore } from '~/stores/services'
10
+ import { useWorkspaceStore } from '~/stores/workspace'
11
+ import type { BoardWriteContext } from './context'
12
+ import { UNDO_WINDOW_MS } from './context'
13
+
14
+ /**
15
+ * The board's create / move / update / dependency write operations, extracted from the store
16
+ * setup. Each closes over the shared {@link BoardWriteContext} (the authoritative block returned
17
+ * by the API is applied via `upsert`) so behaviour is identical to the original in-closure
18
+ * functions — the split is purely to keep every function within the size budget.
19
+ */
20
+ export function createBoardMutations(ctx: BoardWriteContext) {
21
+ const { getBlock, upsert, api, toast, tr } = ctx
22
+
23
+ async function addBlock(type: BlockType, position: { x: number; y: number }): Promise<Block> {
24
+ const block = await api.addFrame(useWorkspaceStore().requireId(), { type, position })
25
+ upsert(block)
26
+ return block
27
+ }
28
+
29
+ /**
30
+ * Import an existing GitHub repo (the App is installed + it's projected) as a
31
+ * service frame, with no bootstrap run. The backend links the repo to the new
32
+ * frame and returns it `ready`; we upsert it onto the board. When the repo already
33
+ * backs an org service, the backend MOUNTS that shared service here instead of
34
+ * minting a rival — so refresh the snapshot to pull in the shared frame's subtree
35
+ * + its mount layout (a fresh import has no subtree, but the reconcile is harmless).
36
+ */
37
+ async function addServiceFromRepo(
38
+ repoGithubId: number,
39
+ opts?: {
40
+ directory?: string
41
+ isMonorepo?: boolean
42
+ type?: FrameRepoType
43
+ position?: { x: number; y: number }
44
+ },
45
+ ): Promise<Block> {
46
+ const block = await api.addServiceFromRepo(useWorkspaceStore().requireId(), {
47
+ repoGithubId,
48
+ ...(opts?.directory ? { directory: opts.directory } : {}),
49
+ ...(opts?.isMonorepo !== undefined ? { isMonorepo: opts.isMonorepo } : {}),
50
+ ...(opts?.type ? { type: opts.type } : {}),
51
+ ...(opts?.position ? { position: opts.position } : {}),
52
+ })
53
+ upsert(block)
54
+ await useWorkspaceStore().refresh()
55
+ return block
56
+ }
57
+
58
+ /**
59
+ * Add a task inside a container (a service or a module). The user supplies the
60
+ * title (and optional description) — the task is created in `planned` state and
61
+ * is not launched until the user explicitly starts a pipeline on it.
62
+ */
63
+ async function addTask(
64
+ containerId: string,
65
+ title: string,
66
+ description?: string,
67
+ options?: {
68
+ taskType?: CreateTaskType
69
+ taskTypeFields?: TaskTypeFields
70
+ riskPolicyId?: string
71
+ modelPresetId?: string
72
+ pipelineId?: string
73
+ agentConfig?: Record<string, string>
74
+ fragmentIds?: string[]
75
+ technical?: boolean
76
+ },
77
+ ): Promise<Block | undefined> {
78
+ if (!getBlock(containerId)) return
79
+ const block = await api.addTask(useWorkspaceStore().requireId(), containerId, {
80
+ title,
81
+ description,
82
+ ...(options?.taskType ? { taskType: options.taskType } : {}),
83
+ ...(options?.taskTypeFields ? { taskTypeFields: options.taskTypeFields } : {}),
84
+ ...(options?.riskPolicyId ? { riskPolicyId: options.riskPolicyId } : {}),
85
+ ...(options?.modelPresetId ? { modelPresetId: options.modelPresetId } : {}),
86
+ ...(options?.pipelineId ? { pipelineId: options.pipelineId } : {}),
87
+ ...(options?.agentConfig ? { agentConfig: options.agentConfig } : {}),
88
+ // Forward the selection when the caller provides one (the create form always does, even
89
+ // when empty — an explicit clear the backend must honour rather than re-seed); omit only
90
+ // when a caller doesn't manage fragments at all (then the backend seeds from the service).
91
+ ...(options?.fragmentIds !== undefined ? { fragmentIds: options.fragmentIds } : {}),
92
+ ...(options?.technical ? { technical: true } : {}),
93
+ })
94
+ upsert(block)
95
+ return block
96
+ }
97
+
98
+ /**
99
+ * Add an epic grouping node. Epics are non-structural: they group tasks via the tasks'
100
+ * `epicId`, so this just drops a new `epic`-level block on the board.
101
+ */
102
+ async function addEpic(
103
+ title: string,
104
+ position: { x: number; y: number },
105
+ options?: { description?: string; parentId?: string },
106
+ ): Promise<Block> {
107
+ const block = await api.addEpic(useWorkspaceStore().requireId(), {
108
+ title,
109
+ position,
110
+ ...(options?.description ? { description: options.description } : {}),
111
+ ...(options?.parentId ? { parentId: options.parentId } : {}),
112
+ })
113
+ upsert(block)
114
+ return block
115
+ }
116
+
117
+ /** Assign a task to an epic, or detach it (epicId: null). */
118
+ async function assignToEpic(taskId: string, epicId: string | null) {
119
+ const t = getBlock(taskId)
120
+ if (!t) return
121
+ const prev = t.epicId ?? null
122
+ t.epicId = epicId // optimistic
123
+ try {
124
+ upsert(await api.assignToEpic(useWorkspaceStore().requireId(), taskId, { epicId }))
125
+ } catch (e) {
126
+ t.epicId = prev
127
+ toast.add({
128
+ title: tr('board.toast.epicFailed'),
129
+ description: e instanceof Error ? e.message : String(e),
130
+ icon: 'i-lucide-triangle-alert',
131
+ color: 'error',
132
+ })
133
+ }
134
+ }
135
+
136
+ /** Add a module (sub-frame) inside a service. */
137
+ async function addModule(
138
+ serviceId: string,
139
+ name: string,
140
+ position?: { x: number; y: number },
141
+ ): Promise<Block | undefined> {
142
+ if (!getBlock(serviceId)) return
143
+ const block = await api.addModule(useWorkspaceStore().requireId(), serviceId, {
144
+ name,
145
+ position,
146
+ })
147
+ upsert(block)
148
+ return block
149
+ }
150
+
151
+ /**
152
+ * Move a block into a new container at a new local position. Drag-reparent commits
153
+ * silently on a small overshoot, so a successful move (into a *different* container,
154
+ * not an undo of one) offers a one-click undo back to its previous home.
155
+ */
156
+ async function reparentBlock(
157
+ id: string,
158
+ newParentId: string,
159
+ position: { x: number; y: number },
160
+ opts: { undoable?: boolean } = { undoable: true },
161
+ ) {
162
+ const b = getBlock(id)
163
+ const parent = getBlock(newParentId)
164
+ if (!b || !parent || b.id === newParentId) return
165
+ // tasks may live in services or modules; modules only in services
166
+ if (b.level === 'task' && parent.level !== 'frame' && parent.level !== 'module') return
167
+ if (b.level === 'module' && parent.level !== 'frame') return
168
+ // Optimistic: drop the block into the new container immediately so it doesn't
169
+ // briefly snap back to its old home while the request is in flight. Snapshot
170
+ // the old home so a rejected reparent restores it rather than leaving the
171
+ // block in the wrong container (a structural lie that survives until re-hydrate).
172
+ const prevParentId = b.parentId
173
+ const prevPosition = b.position
174
+ const name = b.title
175
+ b.parentId = newParentId
176
+ b.position = position
177
+ try {
178
+ upsert(
179
+ await api.reparentBlock(useWorkspaceStore().requireId(), id, {
180
+ parentId: newParentId,
181
+ position,
182
+ }),
183
+ )
184
+ // Offer an undo back to the previous container (a drag overshoot is easy). The undo
185
+ // move is itself non-undoable so the toast doesn't ping-pong.
186
+ if (opts.undoable && prevParentId) {
187
+ toast.add({
188
+ title: tr('board.toast.moved', { name }),
189
+ icon: 'i-lucide-move',
190
+ color: 'neutral',
191
+ duration: UNDO_WINDOW_MS,
192
+ actions: [
193
+ {
194
+ label: tr('common.undo'),
195
+ icon: 'i-lucide-undo-2',
196
+ onClick: () =>
197
+ void reparentBlock(id, prevParentId, prevPosition, { undoable: false }),
198
+ },
199
+ ],
200
+ })
201
+ }
202
+ } catch (e) {
203
+ b.parentId = prevParentId
204
+ b.position = prevPosition
205
+ toast.add({
206
+ title: tr('board.toast.moveFailed'),
207
+ description: e instanceof Error ? e.message : String(e),
208
+ icon: 'i-lucide-triangle-alert',
209
+ color: 'error',
210
+ })
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Archive a service (hide it + its subtree, restorable with no expiry) — the non-destructive
216
+ * alternative to deleting a service that still has unfinished tasks. The acting tab isn't
217
+ * echoed its own coarse board event, so re-hydrate explicitly to drop the frame from the board
218
+ * and surface it under the archived list.
219
+ */
220
+ async function archiveService(id: string) {
221
+ await api.archiveBlock(useWorkspaceStore().requireId(), id)
222
+ await useWorkspaceStore().refresh()
223
+ }
224
+
225
+ /** Restore an archived service back onto the board. Re-hydrates to pull its subtree back in. */
226
+ async function restoreService(id: string) {
227
+ await api.restoreBlock(useWorkspaceStore().requireId(), id)
228
+ await useWorkspaceStore().refresh()
229
+ }
230
+
231
+ /**
232
+ * Local-only optimistic position update during an active drag — no persistence.
233
+ * A drag fires this on every pointer move so the block tracks the cursor without
234
+ * a per-move API round-trip; the final position is committed once via
235
+ * {@link moveBlock} (or {@link reparentBlock}) on release. Persisting every move
236
+ * raced: out-of-order responses to the burst of in-flight writes could land a
237
+ * stale position last, snapping the block back after the user let go.
238
+ */
239
+ function previewMove(id: string, position: { x: number; y: number }) {
240
+ const b = getBlock(id)
241
+ if (b) b.position = position
242
+ }
243
+
244
+ async function moveBlock(id: string, position: { x: number; y: number }) {
245
+ const b = getBlock(id)
246
+ if (!b) return
247
+ const prevPosition = b.position
248
+ b.position = position // optimistic: keep the drag feeling instant
249
+ try {
250
+ // A mounted service frame's position is a PER-WORKSPACE layout override on the mount, not
251
+ // on the (shared) block — so route a frame drag there. Other moves write the block.
252
+ const services = useServicesStore()
253
+ const mount = services.serviceByFrameBlock[id]
254
+ ? services.byServiceId[services.serviceByFrameBlock[id]!.id]
255
+ : undefined
256
+ if (mount) {
257
+ await services.updateLayout(mount.serviceId, position)
258
+ return
259
+ }
260
+ upsert(await api.moveBlock(useWorkspaceStore().requireId(), id, { position }))
261
+ } catch (e) {
262
+ // Restore the pre-drag position — a rejected move must not leave the block at a
263
+ // spot the server never stored (a lie that survives until the next re-hydrate).
264
+ b.position = prevPosition
265
+ toast.add({
266
+ title: tr('board.toast.moveFailed'),
267
+ description: e instanceof Error ? e.message : String(e),
268
+ icon: 'i-lucide-triangle-alert',
269
+ color: 'error',
270
+ })
271
+ }
272
+ }
273
+
274
+ /** Patch the user-editable fields of a block (title, features, threshold…). */
275
+ async function updateBlock(id: string, patch: UpdateBlockInput) {
276
+ const b = getBlock(id)
277
+ if (!b) return
278
+ // Snapshot ONLY the fields this patch touches so a rejected write restores them exactly
279
+ // (a patch may set several at once) rather than leaving a stale optimistic value stuck on
280
+ // screen with no feedback — the same rollback contract the other mutations here follow.
281
+ const prev: Record<string, unknown> = {}
282
+ const patchRecord = patch as Record<string, unknown>
283
+ const record = b as unknown as Record<string, unknown>
284
+ for (const key of Object.keys(patch)) prev[key] = record[key]
285
+ Object.assign(b, patch) // optimistic
286
+ try {
287
+ upsert(await api.updateBlock(useWorkspaceStore().requireId(), id, patch))
288
+ } catch (e) {
289
+ // Re-resolve the block: a live event may have replaced its object reference (`upsert`
290
+ // swaps in a fresh one) while the write was in flight, so `b` can be stale. Only revert
291
+ // fields that still hold OUR optimistic value, so a newer server value that landed
292
+ // mid-flight isn't clobbered by the rollback.
293
+ const cur = getBlock(id) as unknown as Record<string, unknown> | undefined
294
+ if (cur) {
295
+ for (const key of Object.keys(patch)) {
296
+ if (cur[key] === patchRecord[key]) cur[key] = prev[key]
297
+ }
298
+ }
299
+ toast.add({
300
+ title: tr('board.toast.updateFailed'),
301
+ description: e instanceof Error ? e.message : String(e),
302
+ icon: 'i-lucide-triangle-alert',
303
+ color: 'error',
304
+ })
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Toggle a dependency edge target -> source (target dependsOn source). The backend
310
+ * rejects an edge that would close a cycle (422) — surface that as a toast rather than
311
+ * letting it throw unhandled out of a board gesture.
312
+ */
313
+ async function toggleDependency(targetId: string, sourceId: string) {
314
+ if (targetId === sourceId || !getBlock(targetId)) return
315
+ try {
316
+ upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
317
+ } catch (e) {
318
+ toast.add({
319
+ title: tr('board.toast.linkFailed'),
320
+ description: e instanceof Error ? e.message : String(e),
321
+ icon: 'i-lucide-triangle-alert',
322
+ color: 'error',
323
+ })
324
+ }
325
+ }
326
+
327
+ /** Remove a dependency edge target -> source if it exists. */
328
+ async function removeDependency(targetId: string, sourceId: string) {
329
+ const t = getBlock(targetId)
330
+ if (!t || !t.dependsOn.includes(sourceId)) return
331
+ // the backend exposes a single toggle; the edge exists, so toggling removes it
332
+ try {
333
+ upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
334
+ } catch (e) {
335
+ // Mirror `toggleDependency`: a failure must surface (and leave the edge visible) rather
336
+ // than rejecting unhandled with no feedback.
337
+ toast.add({
338
+ title: tr('board.toast.unlinkFailed'),
339
+ description: e instanceof Error ? e.message : String(e),
340
+ icon: 'i-lucide-triangle-alert',
341
+ color: 'error',
342
+ })
343
+ }
344
+ }
345
+
346
+ return {
347
+ addBlock,
348
+ addServiceFromRepo,
349
+ addTask,
350
+ addModule,
351
+ addEpic,
352
+ assignToEpic,
353
+ reparentBlock,
354
+ archiveService,
355
+ restoreService,
356
+ previewMove,
357
+ moveBlock,
358
+ updateBlock,
359
+ toggleDependency,
360
+ removeDependency,
361
+ }
362
+ }
@@ -0,0 +1,172 @@
1
+ import { useServicesStore } from '~/stores/services'
2
+ import { useWorkspaceStore } from '~/stores/workspace'
3
+ import type { BoardWriteContext, RemovalSnapshot } from './context'
4
+ import { UNDO_WINDOW_MS } from './context'
5
+
6
+ /**
7
+ * The optimistic delete / deferred-commit / undo lifecycle for board blocks, extracted from the
8
+ * `board` store setup. Operates on the shared {@link BoardWriteContext} state so behaviour is
9
+ * identical to the original in-closure functions. `detach`/`reattach`/`removeBlock` are exposed on
10
+ * the store (other stores delete a block through their own endpoint then `detach` here); `undoRemove`
11
+ * stays internal — it is only wired into the delete toast's undo action.
12
+ */
13
+ export function createBoardRemoval(ctx: BoardWriteContext) {
14
+ const { blocks, getBlock, pendingRemovals, pendingDoomed, api, toast, tr } = ctx
15
+
16
+ /**
17
+ * Optimistically drop a block and its descendants from the cache, returning a
18
+ * snapshot so the removal can be undone if the backend call fails. The server
19
+ * cascades to descendants, so we mirror that here. Exposed for other stores
20
+ * (e.g. recurring pipelines) that delete a block through their own endpoint.
21
+ */
22
+ function detach(id: string): RemovalSnapshot | null {
23
+ if (!getBlock(id)) return null
24
+ const doomed = new Set<string>([id])
25
+ let grew = true
26
+ while (grew) {
27
+ grew = false
28
+ for (const b of blocks.value) {
29
+ if (b.parentId && doomed.has(b.parentId) && !doomed.has(b.id)) {
30
+ doomed.add(b.id)
31
+ grew = true
32
+ }
33
+ }
34
+ }
35
+ const removed = blocks.value.filter((b) => doomed.has(b.id))
36
+ // Survivors that pointed at a doomed block (dependency edge, epic membership, or initiative
37
+ // membership) lose that link — snapshot the originals so a failed delete restores them
38
+ // faithfully. Mirrors the backend `pruneDanglingEdges` detach.
39
+ const edges = blocks.value
40
+ .filter(
41
+ (b) =>
42
+ !doomed.has(b.id) &&
43
+ (b.dependsOn.some((d) => doomed.has(d)) ||
44
+ (b.epicId != null && doomed.has(b.epicId)) ||
45
+ (b.initiativeId != null && doomed.has(b.initiativeId))),
46
+ )
47
+ .map((b) => ({
48
+ id: b.id,
49
+ dependsOn: [...b.dependsOn],
50
+ epicId: b.epicId ?? null,
51
+ initiativeId: b.initiativeId ?? null,
52
+ }))
53
+ blocks.value = blocks.value.filter((b) => !doomed.has(b.id))
54
+ for (const b of blocks.value) {
55
+ if (b.dependsOn.some((d) => doomed.has(d))) {
56
+ b.dependsOn = b.dependsOn.filter((d) => !doomed.has(d))
57
+ }
58
+ // A member of a deleted epic loses its membership (the task itself survives).
59
+ if (b.epicId != null && doomed.has(b.epicId)) b.epicId = null
60
+ // Likewise a task spawned by a deleted initiative loses its (non-structural) membership.
61
+ if (b.initiativeId != null && doomed.has(b.initiativeId)) b.initiativeId = null
62
+ }
63
+ return { removed, edges }
64
+ }
65
+
66
+ /** Re-insert a detached subtree and restore its broken edges (delete rollback). */
67
+ function reattach(snap: RemovalSnapshot) {
68
+ for (const b of snap.removed) if (!getBlock(b.id)) blocks.value.push(b)
69
+ const restored = new Set(snap.removed.map((b) => b.id))
70
+ for (const e of snap.edges) {
71
+ const b = getBlock(e.id)
72
+ if (!b) continue
73
+ // Re-establish only the links that pointed at a now-restored block, merged with
74
+ // whatever the survivor gained meanwhile — so a delayed undo (the delete is deferred by
75
+ // a window during which a live event may add edges) doesn't clobber a newer dependency /
76
+ // epic / initiative link with the stale detach-time snapshot.
77
+ const readd = e.dependsOn.filter((d) => restored.has(d) && !b.dependsOn.includes(d))
78
+ if (readd.length) b.dependsOn = [...b.dependsOn, ...readd]
79
+ if (b.epicId == null && e.epicId != null && restored.has(e.epicId)) b.epicId = e.epicId
80
+ if (b.initiativeId == null && e.initiativeId != null && restored.has(e.initiativeId)) {
81
+ b.initiativeId = e.initiativeId
82
+ }
83
+ }
84
+ }
85
+
86
+ /** Cancel a still-pending delete and restore the hidden subtree. */
87
+ function undoRemove(id: string) {
88
+ const pending = pendingRemovals.get(id)
89
+ if (!pending) return
90
+ clearTimeout(pending.timer)
91
+ pendingRemovals.delete(id)
92
+ for (const b of pending.snap.removed) pendingDoomed.delete(b.id)
93
+ // Don't resurrect the subtree into a different workspace if the user navigated away
94
+ // mid-window; the delete was already cancelled, which is the safe outcome there.
95
+ if (useWorkspaceStore().workspaceId !== pending.wsId) return
96
+ reattach(pending.snap)
97
+ }
98
+
99
+ /**
100
+ * Delete a block. The subtree is hidden IMMEDIATELY (optimistic) so the board feels
101
+ * instant, and the real backend delete is DEFERRED by {@link UNDO_WINDOW_MS} so the
102
+ * accompanying "Deleted — Undo" toast can cancel it in place (a genuine undo, since
103
+ * nothing was destroyed server-side yet). The subtree stays filtered out of any
104
+ * hydrate/upsert in the meantime (see `applyPendingRemovals`). If the deferred
105
+ * call ultimately fails we put the subtree back and surface an error toast.
106
+ */
107
+ function removeBlock(
108
+ id: string,
109
+ opts: { onCommit?: (wsId: string) => void | Promise<void> } = {},
110
+ ) {
111
+ const block = getBlock(id)
112
+ const snap = detach(id)
113
+ if (!block || !snap) return
114
+ // Capture the workspace now: the deferred delete must target the workspace the block
115
+ // was deleted from even if the user has since switched.
116
+ const wsId = useWorkspaceStore().requireId()
117
+ for (const b of snap.removed) pendingDoomed.add(b.id)
118
+
119
+ const finalize = async () => {
120
+ const pending = pendingRemovals.get(id)
121
+ if (!pending) return
122
+ pendingRemovals.delete(id)
123
+ try {
124
+ // Any irreversible side effect the delete implies (e.g. cancelling the block's run)
125
+ // is deferred to here so it fires only once the delete truly commits — undo within
126
+ // the window then leaves the run untouched instead of restoring an already-cancelled one.
127
+ await opts.onCommit?.(pending.wsId)
128
+ await api.removeBlock(pending.wsId, id)
129
+ // A service frame's delete also reclaims its account-owned service server-side, but this
130
+ // tab is not echoed its own coarse `board` event, so nothing re-hydrates the service
131
+ // catalog here — drop the deleted service locally so the add-service picker stops flagging
132
+ // its repo as "already on board" (a no-op for a task/module). Only after the commit, so a
133
+ // still-linked repo is never briefly presented as addable.
134
+ if (useWorkspaceStore().workspaceId === pending.wsId)
135
+ useServicesStore().dropByFrameBlock(id)
136
+ // Stop filtering the subtree only after the server has actually dropped it, so a
137
+ // snapshot that raced the in-flight delete can't briefly resurrect it.
138
+ for (const b of pending.snap.removed) pendingDoomed.delete(b.id)
139
+ } catch (e) {
140
+ for (const b of pending.snap.removed) pendingDoomed.delete(b.id)
141
+ // Only restore into the board the block was deleted from; a mid-window workspace
142
+ // switch must not inject the old subtree onto the workspace now on screen (it
143
+ // re-hydrates from the server there on the next refresh anyway).
144
+ if (useWorkspaceStore().workspaceId === pending.wsId) reattach(pending.snap)
145
+ toast.add({
146
+ title: tr('board.toast.deleteFailed'),
147
+ description: e instanceof Error ? e.message : String(e),
148
+ icon: 'i-lucide-triangle-alert',
149
+ color: 'error',
150
+ })
151
+ }
152
+ }
153
+ const timer = setTimeout(() => void finalize(), UNDO_WINDOW_MS)
154
+ pendingRemovals.set(id, { snap, timer, wsId })
155
+
156
+ toast.add({
157
+ title: tr('board.toast.deleted', { name: block.title }),
158
+ icon: 'i-lucide-trash-2',
159
+ color: 'neutral',
160
+ duration: UNDO_WINDOW_MS,
161
+ actions: [
162
+ {
163
+ label: tr('common.undo'),
164
+ icon: 'i-lucide-undo-2',
165
+ onClick: () => undoRemove(id),
166
+ },
167
+ ],
168
+ })
169
+ }
170
+
171
+ return { detach, reattach, removeBlock }
172
+ }
@@ -1,34 +1,20 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref } from 'vue'
3
- import type { UpdateBlockInput } from '@cat-factory/contracts'
4
- import type {
5
- Block,
6
- BlockType,
7
- CreateTaskType,
8
- FrameRepoType,
9
- TaskTypeFields,
10
- } from '~/types/domain'
11
- import { useServicesStore } from '~/stores/services'
12
- import { useWorkspaceStore } from '~/stores/workspace'
3
+ import type { Block } from '~/types/domain'
13
4
  import { useBlockQueries } from '~/composables/useBlockQueries'
5
+ import type { PendingRemoval } from '~/stores/board/context'
6
+ import { createBoardMutations } from '~/stores/board/mutations'
7
+ import { createBoardRemoval } from '~/stores/board/removal'
14
8
 
15
9
  /**
16
10
  * The board: architecture blocks and the dependency edges between them. Blocks
17
11
  * are owned by the backend — this store is a hydrated cache. Read getters are
18
12
  * pure client logic (see {@link useBlockQueries}); every mutation calls the API
19
- * and applies the authoritative block the server returns.
13
+ * and applies the authoritative block the server returns. The write operations
14
+ * live in cohesive factories ({@link createBoardMutations} / {@link createBoardRemoval},
15
+ * under `stores/board/`) that close over the shared state assembled here — a size-only
16
+ * split, not a new seam.
20
17
  */
21
- /** A detached subtree captured before an optimistic delete, restored on failure. */
22
- interface RemovalSnapshot {
23
- /** The removed block + all its descendants, in their original order. */
24
- removed: Block[]
25
- /**
26
- * Survivors whose `dependsOn`/`epicId`/`initiativeId` lost an edge to a removed block
27
- * (originals to restore on rollback).
28
- */
29
- edges: { id: string; dependsOn: string[]; epicId: string | null; initiativeId: string | null }[]
30
- }
31
-
32
18
  export const useBoardStore = defineStore('board', () => {
33
19
  const api = useApi()
34
20
  const toast = useToast()
@@ -61,22 +47,13 @@ export const useBoardStore = defineStore('board', () => {
61
47
  const queries = useBlockQueries(blocks)
62
48
  const { getBlock } = queries
63
49
 
64
- /**
65
- * How long a deleted block stays undoable. The backend delete is DEFERRED for this
66
- * window (a real "undo", not a client illusion) — the block is hidden immediately but
67
- * only actually deleted once the window elapses, so undo just cancels the pending call.
68
- */
69
- const UNDO_WINDOW_MS = 6000
70
50
  /**
71
51
  * Blocks hidden by an optimistic delete whose backend call hasn't fired yet, keyed by
72
52
  * the deleted root's id. Their subtree stays filtered out of every incoming server
73
53
  * snapshot (`hydrate`) and single-block live event (`upsert`) for the undo window, so a
74
54
  * coarse refresh or a stray event can't resurrect a block the user just deleted.
75
55
  */
76
- const pendingRemovals = new Map<
77
- string,
78
- { snap: RemovalSnapshot; timer: ReturnType<typeof setTimeout>; wsId: string }
79
- >()
56
+ const pendingRemovals = new Map<string, PendingRemoval>()
80
57
  // Flat set of every id in a pending removal (root + descendants), for O(1) checks in the
81
58
  // hot upsert path. Kept in lockstep with `pendingRemovals`.
82
59
  const pendingDoomed = new Set<string>()
@@ -148,23 +125,6 @@ export const useBoardStore = defineStore('board', () => {
148
125
  archived.value = [...next]
149
126
  }
150
127
 
151
- /**
152
- * Archive a service (hide it + its subtree, restorable with no expiry) — the non-destructive
153
- * alternative to deleting a service that still has unfinished tasks. The acting tab isn't
154
- * echoed its own coarse board event, so re-hydrate explicitly to drop the frame from the board
155
- * and surface it under the archived list.
156
- */
157
- async function archiveService(id: string) {
158
- await api.archiveBlock(useWorkspaceStore().requireId(), id)
159
- await useWorkspaceStore().refresh()
160
- }
161
-
162
- /** Restore an archived service back onto the board. Re-hydrates to pull its subtree back in. */
163
- async function restoreService(id: string) {
164
- await api.restoreBlock(useWorkspaceStore().requireId(), id)
165
- await useWorkspaceStore().refresh()
166
- }
167
-
168
128
  /** Insert or replace a block returned by the backend. */
169
129
  function upsert(block: Block) {
170
130
  // A live event for a block awaiting its deferred delete must not resurrect it.
@@ -176,466 +136,12 @@ export const useBoardStore = defineStore('board', () => {
176
136
  else blocks.value.push(block)
177
137
  }
178
138
 
179
- async function addBlock(type: BlockType, position: { x: number; y: number }): Promise<Block> {
180
- const block = await api.addFrame(useWorkspaceStore().requireId(), { type, position })
181
- upsert(block)
182
- return block
183
- }
184
-
185
- /**
186
- * Import an existing GitHub repo (the App is installed + it's projected) as a
187
- * service frame, with no bootstrap run. The backend links the repo to the new
188
- * frame and returns it `ready`; we upsert it onto the board. When the repo already
189
- * backs an org service, the backend MOUNTS that shared service here instead of
190
- * minting a rival — so refresh the snapshot to pull in the shared frame's subtree
191
- * + its mount layout (a fresh import has no subtree, but the reconcile is harmless).
192
- */
193
- async function addServiceFromRepo(
194
- repoGithubId: number,
195
- opts?: {
196
- directory?: string
197
- isMonorepo?: boolean
198
- type?: FrameRepoType
199
- position?: { x: number; y: number }
200
- },
201
- ): Promise<Block> {
202
- const block = await api.addServiceFromRepo(useWorkspaceStore().requireId(), {
203
- repoGithubId,
204
- ...(opts?.directory ? { directory: opts.directory } : {}),
205
- ...(opts?.isMonorepo !== undefined ? { isMonorepo: opts.isMonorepo } : {}),
206
- ...(opts?.type ? { type: opts.type } : {}),
207
- ...(opts?.position ? { position: opts.position } : {}),
208
- })
209
- upsert(block)
210
- await useWorkspaceStore().refresh()
211
- return block
212
- }
213
-
214
- /**
215
- * Add a task inside a container (a service or a module). The user supplies the
216
- * title (and optional description) — the task is created in `planned` state and
217
- * is not launched until the user explicitly starts a pipeline on it.
218
- */
219
- async function addTask(
220
- containerId: string,
221
- title: string,
222
- description?: string,
223
- options?: {
224
- taskType?: CreateTaskType
225
- taskTypeFields?: TaskTypeFields
226
- riskPolicyId?: string
227
- modelPresetId?: string
228
- pipelineId?: string
229
- agentConfig?: Record<string, string>
230
- fragmentIds?: string[]
231
- technical?: boolean
232
- },
233
- ): Promise<Block | undefined> {
234
- if (!getBlock(containerId)) return
235
- const block = await api.addTask(useWorkspaceStore().requireId(), containerId, {
236
- title,
237
- description,
238
- ...(options?.taskType ? { taskType: options.taskType } : {}),
239
- ...(options?.taskTypeFields ? { taskTypeFields: options.taskTypeFields } : {}),
240
- ...(options?.riskPolicyId ? { riskPolicyId: options.riskPolicyId } : {}),
241
- ...(options?.modelPresetId ? { modelPresetId: options.modelPresetId } : {}),
242
- ...(options?.pipelineId ? { pipelineId: options.pipelineId } : {}),
243
- ...(options?.agentConfig ? { agentConfig: options.agentConfig } : {}),
244
- // Forward the selection when the caller provides one (the create form always does, even
245
- // when empty — an explicit clear the backend must honour rather than re-seed); omit only
246
- // when a caller doesn't manage fragments at all (then the backend seeds from the service).
247
- ...(options?.fragmentIds !== undefined ? { fragmentIds: options.fragmentIds } : {}),
248
- ...(options?.technical ? { technical: true } : {}),
249
- })
250
- upsert(block)
251
- return block
252
- }
253
-
254
- /**
255
- * Add an epic grouping node. Epics are non-structural: they group tasks via the tasks'
256
- * `epicId`, so this just drops a new `epic`-level block on the board.
257
- */
258
- async function addEpic(
259
- title: string,
260
- position: { x: number; y: number },
261
- options?: { description?: string; parentId?: string },
262
- ): Promise<Block> {
263
- const block = await api.addEpic(useWorkspaceStore().requireId(), {
264
- title,
265
- position,
266
- ...(options?.description ? { description: options.description } : {}),
267
- ...(options?.parentId ? { parentId: options.parentId } : {}),
268
- })
269
- upsert(block)
270
- return block
271
- }
272
-
273
- /** Assign a task to an epic, or detach it (epicId: null). */
274
- async function assignToEpic(taskId: string, epicId: string | null) {
275
- const t = getBlock(taskId)
276
- if (!t) return
277
- const prev = t.epicId ?? null
278
- t.epicId = epicId // optimistic
279
- try {
280
- upsert(await api.assignToEpic(useWorkspaceStore().requireId(), taskId, { epicId }))
281
- } catch (e) {
282
- t.epicId = prev
283
- toast.add({
284
- title: tr('board.toast.epicFailed'),
285
- description: e instanceof Error ? e.message : String(e),
286
- icon: 'i-lucide-triangle-alert',
287
- color: 'error',
288
- })
289
- }
290
- }
291
-
292
- /** Add a module (sub-frame) inside a service. */
293
- async function addModule(
294
- serviceId: string,
295
- name: string,
296
- position?: { x: number; y: number },
297
- ): Promise<Block | undefined> {
298
- if (!getBlock(serviceId)) return
299
- const block = await api.addModule(useWorkspaceStore().requireId(), serviceId, {
300
- name,
301
- position,
302
- })
303
- upsert(block)
304
- return block
305
- }
306
-
307
- /**
308
- * Move a block into a new container at a new local position. Drag-reparent commits
309
- * silently on a small overshoot, so a successful move (into a *different* container,
310
- * not an undo of one) offers a one-click undo back to its previous home.
311
- */
312
- async function reparentBlock(
313
- id: string,
314
- newParentId: string,
315
- position: { x: number; y: number },
316
- opts: { undoable?: boolean } = { undoable: true },
317
- ) {
318
- const b = getBlock(id)
319
- const parent = getBlock(newParentId)
320
- if (!b || !parent || b.id === newParentId) return
321
- // tasks may live in services or modules; modules only in services
322
- if (b.level === 'task' && parent.level !== 'frame' && parent.level !== 'module') return
323
- if (b.level === 'module' && parent.level !== 'frame') return
324
- // Optimistic: drop the block into the new container immediately so it doesn't
325
- // briefly snap back to its old home while the request is in flight. Snapshot
326
- // the old home so a rejected reparent restores it rather than leaving the
327
- // block in the wrong container (a structural lie that survives until re-hydrate).
328
- const prevParentId = b.parentId
329
- const prevPosition = b.position
330
- const name = b.title
331
- b.parentId = newParentId
332
- b.position = position
333
- try {
334
- upsert(
335
- await api.reparentBlock(useWorkspaceStore().requireId(), id, {
336
- parentId: newParentId,
337
- position,
338
- }),
339
- )
340
- // Offer an undo back to the previous container (a drag overshoot is easy). The undo
341
- // move is itself non-undoable so the toast doesn't ping-pong.
342
- if (opts.undoable && prevParentId) {
343
- toast.add({
344
- title: tr('board.toast.moved', { name }),
345
- icon: 'i-lucide-move',
346
- color: 'neutral',
347
- duration: UNDO_WINDOW_MS,
348
- actions: [
349
- {
350
- label: tr('common.undo'),
351
- icon: 'i-lucide-undo-2',
352
- onClick: () =>
353
- void reparentBlock(id, prevParentId, prevPosition, { undoable: false }),
354
- },
355
- ],
356
- })
357
- }
358
- } catch (e) {
359
- b.parentId = prevParentId
360
- b.position = prevPosition
361
- toast.add({
362
- title: tr('board.toast.moveFailed'),
363
- description: e instanceof Error ? e.message : String(e),
364
- icon: 'i-lucide-triangle-alert',
365
- color: 'error',
366
- })
367
- }
368
- }
369
-
370
- /**
371
- * Optimistically drop a block and its descendants from the cache, returning a
372
- * snapshot so the removal can be undone if the backend call fails. The server
373
- * cascades to descendants, so we mirror that here. Exposed for other stores
374
- * (e.g. recurring pipelines) that delete a block through their own endpoint.
375
- */
376
- function detach(id: string): RemovalSnapshot | null {
377
- if (!getBlock(id)) return null
378
- const doomed = new Set<string>([id])
379
- let grew = true
380
- while (grew) {
381
- grew = false
382
- for (const b of blocks.value) {
383
- if (b.parentId && doomed.has(b.parentId) && !doomed.has(b.id)) {
384
- doomed.add(b.id)
385
- grew = true
386
- }
387
- }
388
- }
389
- const removed = blocks.value.filter((b) => doomed.has(b.id))
390
- // Survivors that pointed at a doomed block (dependency edge, epic membership, or initiative
391
- // membership) lose that link — snapshot the originals so a failed delete restores them
392
- // faithfully. Mirrors the backend `pruneDanglingEdges` detach.
393
- const edges = blocks.value
394
- .filter(
395
- (b) =>
396
- !doomed.has(b.id) &&
397
- (b.dependsOn.some((d) => doomed.has(d)) ||
398
- (b.epicId != null && doomed.has(b.epicId)) ||
399
- (b.initiativeId != null && doomed.has(b.initiativeId))),
400
- )
401
- .map((b) => ({
402
- id: b.id,
403
- dependsOn: [...b.dependsOn],
404
- epicId: b.epicId ?? null,
405
- initiativeId: b.initiativeId ?? null,
406
- }))
407
- blocks.value = blocks.value.filter((b) => !doomed.has(b.id))
408
- for (const b of blocks.value) {
409
- if (b.dependsOn.some((d) => doomed.has(d))) {
410
- b.dependsOn = b.dependsOn.filter((d) => !doomed.has(d))
411
- }
412
- // A member of a deleted epic loses its membership (the task itself survives).
413
- if (b.epicId != null && doomed.has(b.epicId)) b.epicId = null
414
- // Likewise a task spawned by a deleted initiative loses its (non-structural) membership.
415
- if (b.initiativeId != null && doomed.has(b.initiativeId)) b.initiativeId = null
416
- }
417
- return { removed, edges }
418
- }
419
-
420
- /** Re-insert a detached subtree and restore its broken edges (delete rollback). */
421
- function reattach(snap: RemovalSnapshot) {
422
- for (const b of snap.removed) if (!getBlock(b.id)) blocks.value.push(b)
423
- const restored = new Set(snap.removed.map((b) => b.id))
424
- for (const e of snap.edges) {
425
- const b = getBlock(e.id)
426
- if (!b) continue
427
- // Re-establish only the links that pointed at a now-restored block, merged with
428
- // whatever the survivor gained meanwhile — so a delayed undo (the delete is deferred by
429
- // a window during which a live event may add edges) doesn't clobber a newer dependency /
430
- // epic / initiative link with the stale detach-time snapshot.
431
- const readd = e.dependsOn.filter((d) => restored.has(d) && !b.dependsOn.includes(d))
432
- if (readd.length) b.dependsOn = [...b.dependsOn, ...readd]
433
- if (b.epicId == null && e.epicId != null && restored.has(e.epicId)) b.epicId = e.epicId
434
- if (b.initiativeId == null && e.initiativeId != null && restored.has(e.initiativeId)) {
435
- b.initiativeId = e.initiativeId
436
- }
437
- }
438
- }
439
-
440
- /**
441
- * Delete a block. The subtree is hidden IMMEDIATELY (optimistic) so the board feels
442
- * instant, and the real backend delete is DEFERRED by {@link UNDO_WINDOW_MS} so the
443
- * accompanying "Deleted — Undo" toast can cancel it in place (a genuine undo, since
444
- * nothing was destroyed server-side yet). The subtree stays filtered out of any
445
- * hydrate/upsert in the meantime (see {@link applyPendingRemovals}). If the deferred
446
- * call ultimately fails we put the subtree back and surface an error toast.
447
- */
448
- function removeBlock(
449
- id: string,
450
- opts: { onCommit?: (wsId: string) => void | Promise<void> } = {},
451
- ) {
452
- const block = getBlock(id)
453
- const snap = detach(id)
454
- if (!block || !snap) return
455
- // Capture the workspace now: the deferred delete must target the workspace the block
456
- // was deleted from even if the user has since switched.
457
- const wsId = useWorkspaceStore().requireId()
458
- for (const b of snap.removed) pendingDoomed.add(b.id)
459
-
460
- const finalize = async () => {
461
- const pending = pendingRemovals.get(id)
462
- if (!pending) return
463
- pendingRemovals.delete(id)
464
- try {
465
- // Any irreversible side effect the delete implies (e.g. cancelling the block's run)
466
- // is deferred to here so it fires only once the delete truly commits — undo within
467
- // the window then leaves the run untouched instead of restoring an already-cancelled one.
468
- await opts.onCommit?.(pending.wsId)
469
- await api.removeBlock(pending.wsId, id)
470
- // A service frame's delete also reclaims its account-owned service server-side, but this
471
- // tab is not echoed its own coarse `board` event, so nothing re-hydrates the service
472
- // catalog here — drop the deleted service locally so the add-service picker stops flagging
473
- // its repo as "already on board" (a no-op for a task/module). Only after the commit, so a
474
- // still-linked repo is never briefly presented as addable.
475
- if (useWorkspaceStore().workspaceId === pending.wsId)
476
- useServicesStore().dropByFrameBlock(id)
477
- // Stop filtering the subtree only after the server has actually dropped it, so a
478
- // snapshot that raced the in-flight delete can't briefly resurrect it.
479
- for (const b of pending.snap.removed) pendingDoomed.delete(b.id)
480
- } catch (e) {
481
- for (const b of pending.snap.removed) pendingDoomed.delete(b.id)
482
- // Only restore into the board the block was deleted from; a mid-window workspace
483
- // switch must not inject the old subtree onto the workspace now on screen (it
484
- // re-hydrates from the server there on the next refresh anyway).
485
- if (useWorkspaceStore().workspaceId === pending.wsId) reattach(pending.snap)
486
- toast.add({
487
- title: tr('board.toast.deleteFailed'),
488
- description: e instanceof Error ? e.message : String(e),
489
- icon: 'i-lucide-triangle-alert',
490
- color: 'error',
491
- })
492
- }
493
- }
494
- const timer = setTimeout(() => void finalize(), UNDO_WINDOW_MS)
495
- pendingRemovals.set(id, { snap, timer, wsId })
496
-
497
- toast.add({
498
- title: tr('board.toast.deleted', { name: block.title }),
499
- icon: 'i-lucide-trash-2',
500
- color: 'neutral',
501
- duration: UNDO_WINDOW_MS,
502
- actions: [
503
- {
504
- label: tr('common.undo'),
505
- icon: 'i-lucide-undo-2',
506
- onClick: () => undoRemove(id),
507
- },
508
- ],
509
- })
510
- }
511
-
512
- /** Cancel a still-pending delete and restore the hidden subtree. */
513
- function undoRemove(id: string) {
514
- const pending = pendingRemovals.get(id)
515
- if (!pending) return
516
- clearTimeout(pending.timer)
517
- pendingRemovals.delete(id)
518
- for (const b of pending.snap.removed) pendingDoomed.delete(b.id)
519
- // Don't resurrect the subtree into a different workspace if the user navigated away
520
- // mid-window; the delete was already cancelled, which is the safe outcome there.
521
- if (useWorkspaceStore().workspaceId !== pending.wsId) return
522
- reattach(pending.snap)
523
- }
524
-
525
- /**
526
- * Local-only optimistic position update during an active drag — no persistence.
527
- * A drag fires this on every pointer move so the block tracks the cursor without
528
- * a per-move API round-trip; the final position is committed once via
529
- * {@link moveBlock} (or {@link reparentBlock}) on release. Persisting every move
530
- * raced: out-of-order responses to the burst of in-flight writes could land a
531
- * stale position last, snapping the block back after the user let go.
532
- */
533
- function previewMove(id: string, position: { x: number; y: number }) {
534
- const b = getBlock(id)
535
- if (b) b.position = position
536
- }
537
-
538
- async function moveBlock(id: string, position: { x: number; y: number }) {
539
- const b = getBlock(id)
540
- if (!b) return
541
- const prevPosition = b.position
542
- b.position = position // optimistic: keep the drag feeling instant
543
- try {
544
- // A mounted service frame's position is a PER-WORKSPACE layout override on the mount, not
545
- // on the (shared) block — so route a frame drag there. Other moves write the block.
546
- const services = useServicesStore()
547
- const mount = services.serviceByFrameBlock[id]
548
- ? services.byServiceId[services.serviceByFrameBlock[id]!.id]
549
- : undefined
550
- if (mount) {
551
- await services.updateLayout(mount.serviceId, position)
552
- return
553
- }
554
- upsert(await api.moveBlock(useWorkspaceStore().requireId(), id, { position }))
555
- } catch (e) {
556
- // Restore the pre-drag position — a rejected move must not leave the block at a
557
- // spot the server never stored (a lie that survives until the next re-hydrate).
558
- b.position = prevPosition
559
- toast.add({
560
- title: tr('board.toast.moveFailed'),
561
- description: e instanceof Error ? e.message : String(e),
562
- icon: 'i-lucide-triangle-alert',
563
- color: 'error',
564
- })
565
- }
566
- }
567
-
568
- /** Patch the user-editable fields of a block (title, features, threshold…). */
569
- async function updateBlock(id: string, patch: UpdateBlockInput) {
570
- const b = getBlock(id)
571
- if (!b) return
572
- // Snapshot ONLY the fields this patch touches so a rejected write restores them exactly
573
- // (a patch may set several at once) rather than leaving a stale optimistic value stuck on
574
- // screen with no feedback — the same rollback contract the other mutations here follow.
575
- const prev: Record<string, unknown> = {}
576
- const patchRecord = patch as Record<string, unknown>
577
- const record = b as unknown as Record<string, unknown>
578
- for (const key of Object.keys(patch)) prev[key] = record[key]
579
- Object.assign(b, patch) // optimistic
580
- try {
581
- upsert(await api.updateBlock(useWorkspaceStore().requireId(), id, patch))
582
- } catch (e) {
583
- // Re-resolve the block: a live event may have replaced its object reference (`upsert`
584
- // swaps in a fresh one) while the write was in flight, so `b` can be stale. Only revert
585
- // fields that still hold OUR optimistic value, so a newer server value that landed
586
- // mid-flight isn't clobbered by the rollback.
587
- const cur = getBlock(id) as unknown as Record<string, unknown> | undefined
588
- if (cur) {
589
- for (const key of Object.keys(patch)) {
590
- if (cur[key] === patchRecord[key]) cur[key] = prev[key]
591
- }
592
- }
593
- toast.add({
594
- title: tr('board.toast.updateFailed'),
595
- description: e instanceof Error ? e.message : String(e),
596
- icon: 'i-lucide-triangle-alert',
597
- color: 'error',
598
- })
599
- }
600
- }
601
-
602
- /**
603
- * Toggle a dependency edge target -> source (target dependsOn source). The backend
604
- * rejects an edge that would close a cycle (422) — surface that as a toast rather than
605
- * letting it throw unhandled out of a board gesture.
606
- */
607
- async function toggleDependency(targetId: string, sourceId: string) {
608
- if (targetId === sourceId || !getBlock(targetId)) return
609
- try {
610
- upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
611
- } catch (e) {
612
- toast.add({
613
- title: tr('board.toast.linkFailed'),
614
- description: e instanceof Error ? e.message : String(e),
615
- icon: 'i-lucide-triangle-alert',
616
- color: 'error',
617
- })
618
- }
619
- }
620
-
621
- /** Remove a dependency edge target -> source if it exists. */
622
- async function removeDependency(targetId: string, sourceId: string) {
623
- const t = getBlock(targetId)
624
- if (!t || !t.dependsOn.includes(sourceId)) return
625
- // the backend exposes a single toggle; the edge exists, so toggling removes it
626
- try {
627
- upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
628
- } catch (e) {
629
- // Mirror `toggleDependency`: a failure must surface (and leave the edge visible) rather
630
- // than rejecting unhandled with no feedback.
631
- toast.add({
632
- title: tr('board.toast.unlinkFailed'),
633
- description: e instanceof Error ? e.message : String(e),
634
- icon: 'i-lucide-triangle-alert',
635
- color: 'error',
636
- })
637
- }
638
- }
139
+ // The write operations, split into cohesive factories sharing the state above (a size-only
140
+ // extraction behaviour is identical to the former in-closure functions). `undoRemove` stays
141
+ // internal to the removal factory (only its delete toast wires it), so it is NOT re-exported.
142
+ const context = { blocks, getBlock, upsert, pendingRemovals, pendingDoomed, api, toast, tr }
143
+ const mutations = createBoardMutations(context)
144
+ const { detach, reattach, removeBlock } = createBoardRemoval(context)
639
145
 
640
146
  return {
641
147
  blocks,
@@ -645,22 +151,9 @@ export const useBoardStore = defineStore('board', () => {
645
151
  hydrateArchived,
646
152
  upsert,
647
153
  ...queries,
648
- addBlock,
649
- addServiceFromRepo,
650
- addTask,
651
- addModule,
652
- addEpic,
653
- assignToEpic,
654
- reparentBlock,
154
+ ...mutations,
655
155
  detach,
656
156
  reattach,
657
157
  removeBlock,
658
- archiveService,
659
- restoreService,
660
- previewMove,
661
- moveBlock,
662
- updateBlock,
663
- toggleDependency,
664
- removeDependency,
665
158
  }
666
159
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.147.6",
3
+ "version": "0.147.7",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",