@cat-factory/app 0.147.6 → 0.148.1

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,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
+ }