@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.
- package/app/components/board/AddTaskModal.vue +146 -3
- package/app/components/board/nodes/TaskCard.vue +29 -1
- package/app/components/settings/WorkspaceSettingsPanel.vue +14 -8
- package/app/docs/consumer-extensions.md +41 -8
- package/app/modular/agent-kinds.spec.ts +1 -39
- package/app/modular/agent-kinds.ts +7 -58
- package/app/modular/capabilities.spec.ts +88 -0
- package/app/modular/capabilities.ts +77 -0
- package/app/modular/nav-contributions.spec.ts +2 -0
- package/app/modular/registry.ts +8 -1
- package/app/modular/slots.ts +13 -1
- package/app/modular/task-types.ts +27 -0
- package/app/plugins/modular.client.ts +8 -3
- package/app/stores/agents.spec.ts +13 -9
- package/app/stores/agents.ts +16 -17
- 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/app/stores/taskTypes.spec.ts +100 -0
- package/app/stores/taskTypes.ts +88 -0
- package/app/stores/workspace.ts +14 -4
- package/app/types/domain.ts +23 -0
- package/app/utils/catalog.ts +107 -0
- package/package.json +2 -2
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
|
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { useTaskTypesStore } from './taskTypes'
|
|
3
|
+
import { buildWorkspaceCapabilitiesManifest } from '~/modular/capabilities'
|
|
4
|
+
import { __resetCustomTaskTypeMetaForTest, isKnownTaskType, taskTypeMeta } from '~/utils/catalog'
|
|
5
|
+
import type { CustomTaskType } from '~/types/domain'
|
|
6
|
+
|
|
7
|
+
const backendType = (
|
|
8
|
+
taskType: string,
|
|
9
|
+
over: Partial<CustomTaskType['presentation']> = {},
|
|
10
|
+
): CustomTaskType => ({
|
|
11
|
+
taskType,
|
|
12
|
+
presentation: {
|
|
13
|
+
label: `L:${taskType}`,
|
|
14
|
+
icon: 'i-lucide-siren',
|
|
15
|
+
color: '#ef4444',
|
|
16
|
+
description: 'd',
|
|
17
|
+
...over,
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
/** Hydrate the store's backend-manifest half with `taskTypes` (via the shared capability manifest). */
|
|
22
|
+
const hydrate = (
|
|
23
|
+
store: ReturnType<typeof useTaskTypesStore>,
|
|
24
|
+
taskTypes: CustomTaskType[],
|
|
25
|
+
): void => {
|
|
26
|
+
store.hydrateCapabilities(buildWorkspaceCapabilitiesManifest([], taskTypes))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('taskTypes store — custom task-type catalog (extension slice B)', () => {
|
|
30
|
+
it('exposes no custom types before any hydrate', () => {
|
|
31
|
+
const store = useTaskTypesStore()
|
|
32
|
+
expect(store.customTaskTypes).toEqual([])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('folds a backend remote-manifest custom type into the catalog + the pure-util projection', () => {
|
|
36
|
+
__resetCustomTaskTypeMetaForTest()
|
|
37
|
+
const store = useTaskTypesStore()
|
|
38
|
+
// Before hydrate the pure-util lookups don't know the type; taskTypeMeta degrades to `feature`.
|
|
39
|
+
expect(isKnownTaskType('acme:incident')).toBe(false)
|
|
40
|
+
const before = taskTypeMeta('acme:incident')
|
|
41
|
+
expect(before.icon).toBe(taskTypeMeta('feature').icon) // feature fallback icon
|
|
42
|
+
expect(before.label).toBe('acme:incident') // raw id, never blank
|
|
43
|
+
|
|
44
|
+
hydrate(store, [backendType('acme:incident', { label: 'Incident' })])
|
|
45
|
+
|
|
46
|
+
// Catalog + lookup both see it now (sync-flush projection — no tick needed).
|
|
47
|
+
expect(store.customTaskTypes.some((t) => t.taskType === 'acme:incident')).toBe(true)
|
|
48
|
+
expect(isKnownTaskType('acme:incident')).toBe(true)
|
|
49
|
+
const meta = taskTypeMeta('acme:incident')
|
|
50
|
+
expect(meta.label).toBe('Incident')
|
|
51
|
+
expect(meta.labelKey).toBeUndefined() // custom types carry a literal label, not an i18n key
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('never lets a custom type shadow a built-in type', () => {
|
|
55
|
+
const store = useTaskTypesStore()
|
|
56
|
+
hydrate(store, [backendType('feature', { label: 'Evil Feature' })])
|
|
57
|
+
expect(store.customTaskTypes.some((t) => t.taskType === 'feature')).toBe(false)
|
|
58
|
+
expect(taskTypeMeta('feature').labelKey).toBe('board.addTask.types.feature')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('merges CODE-shipped consumer types with BACKEND manifest types, de-duplicated', () => {
|
|
62
|
+
const store = useTaskTypesStore()
|
|
63
|
+
store.registerConsumerTaskTypes([backendType('acme:consumer')])
|
|
64
|
+
hydrate(store, [backendType('acme:backend'), backendType('acme:consumer')])
|
|
65
|
+
const ids = store.customTaskTypes.map((t) => t.taskType)
|
|
66
|
+
expect(ids).toContain('acme:consumer')
|
|
67
|
+
expect(ids).toContain('acme:backend')
|
|
68
|
+
expect(ids.filter((k) => k === 'acme:consumer')).toHaveLength(1)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('no-ops a re-hydrate of identical content (content-versioned manifest)', () => {
|
|
72
|
+
const store = useTaskTypesStore()
|
|
73
|
+
hydrate(store, [backendType('acme:x')])
|
|
74
|
+
const first = store.customTaskTypes
|
|
75
|
+
hydrate(store, [backendType('acme:x')])
|
|
76
|
+
expect(store.customTaskTypes).toBe(first)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('swaps the backend catalog wholesale on re-hydrate (per-workspace manifest)', () => {
|
|
80
|
+
const store = useTaskTypesStore()
|
|
81
|
+
hydrate(store, [backendType('acme:ws1')])
|
|
82
|
+
expect(store.customTaskTypes.some((t) => t.taskType === 'acme:ws1')).toBe(true)
|
|
83
|
+
hydrate(store, [backendType('acme:ws2')])
|
|
84
|
+
expect(store.customTaskTypes.some((t) => t.taskType === 'acme:ws1')).toBe(false)
|
|
85
|
+
expect(store.customTaskTypes.some((t) => t.taskType === 'acme:ws2')).toBe(true)
|
|
86
|
+
expect(isKnownTaskType('acme:ws1')).toBe(false)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('get() returns the full registration (for the create-form field descriptors)', () => {
|
|
90
|
+
const store = useTaskTypesStore()
|
|
91
|
+
hydrate(store, [
|
|
92
|
+
{
|
|
93
|
+
...backendType('acme:incident'),
|
|
94
|
+
fields: [{ key: 'sev', label: 'Severity', type: 'text' }],
|
|
95
|
+
},
|
|
96
|
+
])
|
|
97
|
+
expect(store.get('acme:incident')?.fields?.[0]?.key).toBe('sev')
|
|
98
|
+
expect(store.get('acme:unknown')).toBeUndefined()
|
|
99
|
+
})
|
|
100
|
+
})
|