@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.
- package/app/stores/board/context.ts +46 -0
- package/app/stores/board/mutations.ts +362 -0
- package/app/stores/board/removal.ts +172 -0
- package/app/stores/board.ts +16 -523
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/app/stores/board.ts
CHANGED
|
@@ -1,34 +1,20 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { ref } from 'vue'
|
|
3
|
-
import type {
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|