@cat-factory/app 1.0.0

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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -0
  3. package/app/app.config.ts +8 -0
  4. package/app/app.vue +11 -0
  5. package/app/assets/css/main.css +100 -0
  6. package/app/components/auth/AuthGate.vue +24 -0
  7. package/app/components/auth/LoginScreen.vue +18 -0
  8. package/app/components/auth/UserMenu.vue +39 -0
  9. package/app/components/board/AgentFailureCard.vue +97 -0
  10. package/app/components/board/AgentStopButton.vue +61 -0
  11. package/app/components/board/BoardCanvas.vue +146 -0
  12. package/app/components/board/TaskDependencyEdges.vue +132 -0
  13. package/app/components/board/nodes/AgentChip.vue +59 -0
  14. package/app/components/board/nodes/BlockNode.vue +347 -0
  15. package/app/components/board/nodes/DecisionBadge.vue +21 -0
  16. package/app/components/board/nodes/DraggableTask.vue +69 -0
  17. package/app/components/board/nodes/ModuleFrame.vue +70 -0
  18. package/app/components/board/nodes/TaskCard.vue +237 -0
  19. package/app/components/bootstrap/BootstrapModal.vue +665 -0
  20. package/app/components/documents/DocumentImportModal.vue +161 -0
  21. package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
  22. package/app/components/documents/SpawnPreviewModal.vue +161 -0
  23. package/app/components/documents/TaskContextDocs.vue +83 -0
  24. package/app/components/focus/BlockFocusView.vue +161 -0
  25. package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
  26. package/app/components/github/GitHubConnect.vue +183 -0
  27. package/app/components/github/GitHubPanel.vue +584 -0
  28. package/app/components/layout/BoardSwitcher.vue +202 -0
  29. package/app/components/layout/BoardToolbar.vue +109 -0
  30. package/app/components/layout/SideBar.vue +193 -0
  31. package/app/components/layout/SpendWarningBanner.vue +107 -0
  32. package/app/components/palettes/AgentPalette.vue +33 -0
  33. package/app/components/palettes/BlockPalette.vue +41 -0
  34. package/app/components/palettes/PipelinePalette.vue +74 -0
  35. package/app/components/panels/DecisionModal.vue +71 -0
  36. package/app/components/panels/InspectorPanel.vue +296 -0
  37. package/app/components/panels/inspector/ContainerSummary.vue +74 -0
  38. package/app/components/panels/inspector/TaskDependencies.vue +70 -0
  39. package/app/components/panels/inspector/TaskExecution.vue +175 -0
  40. package/app/components/panels/inspector/TaskModelSettings.vue +128 -0
  41. package/app/components/panels/inspector/TaskStructure.vue +139 -0
  42. package/app/components/pipeline/PipelineBuilder.vue +227 -0
  43. package/app/components/pipeline/PipelineProgress.vue +246 -0
  44. package/app/components/requirements/RequirementReviewModal.vue +328 -0
  45. package/app/components/scenarios/FeatureScenarios.vue +162 -0
  46. package/app/components/scenarios/ScenarioCard.vue +109 -0
  47. package/app/components/tasks/TaskContextIssues.vue +88 -0
  48. package/app/components/tasks/TaskImportModal.vue +140 -0
  49. package/app/components/tasks/TaskSourceConnectModal.vue +122 -0
  50. package/app/composables/useApi.ts +535 -0
  51. package/app/composables/useBlockDrag.ts +75 -0
  52. package/app/composables/useBlockQueries.ts +136 -0
  53. package/app/composables/useBoardFlow.ts +11 -0
  54. package/app/composables/useDepLabels.ts +26 -0
  55. package/app/composables/useSemanticZoom.ts +16 -0
  56. package/app/composables/useWorkspaceStream.ts +125 -0
  57. package/app/docs/architecture.md +31 -0
  58. package/app/pages/index.vue +80 -0
  59. package/app/stores/accounts.ts +64 -0
  60. package/app/stores/agentRuns.ts +117 -0
  61. package/app/stores/agents.ts +40 -0
  62. package/app/stores/auth.ts +97 -0
  63. package/app/stores/board.spec.ts +197 -0
  64. package/app/stores/board.ts +147 -0
  65. package/app/stores/bootstrap.ts +97 -0
  66. package/app/stores/documents.ts +165 -0
  67. package/app/stores/execution.ts +115 -0
  68. package/app/stores/fragmentLibrary.ts +147 -0
  69. package/app/stores/fragments.ts +40 -0
  70. package/app/stores/github.ts +291 -0
  71. package/app/stores/models.ts +48 -0
  72. package/app/stores/pipelines.ts +77 -0
  73. package/app/stores/requirements.ts +133 -0
  74. package/app/stores/scenarios.spec.ts +82 -0
  75. package/app/stores/scenarios.ts +196 -0
  76. package/app/stores/tasks.spec.ts +71 -0
  77. package/app/stores/tasks.ts +149 -0
  78. package/app/stores/ui.ts +204 -0
  79. package/app/stores/workspace.ts +201 -0
  80. package/app/types/accounts.ts +38 -0
  81. package/app/types/bootstrap.ts +83 -0
  82. package/app/types/documents.ts +92 -0
  83. package/app/types/domain.ts +216 -0
  84. package/app/types/execution.ts +110 -0
  85. package/app/types/fragments.ts +72 -0
  86. package/app/types/github.ts +153 -0
  87. package/app/types/models.ts +48 -0
  88. package/app/types/requirements.ts +38 -0
  89. package/app/types/scenarios.ts +36 -0
  90. package/app/types/tasks.ts +67 -0
  91. package/app/utils/catalog.spec.ts +82 -0
  92. package/app/utils/catalog.ts +185 -0
  93. package/app/utils/dnd.ts +29 -0
  94. package/nuxt.config.ts +43 -0
  95. package/package.json +43 -0
@@ -0,0 +1,136 @@
1
+ import { computed, type Ref } from 'vue'
2
+ import type { Block, BlockStatus } from '~/types/domain'
3
+
4
+ /**
5
+ * Pure, read-only queries over a board's blocks. Extracted from the board store
6
+ * so the (sizeable) derivation logic — hierarchy traversal, status/progress
7
+ * rollups and container sizing — lives in one focused, independently testable
8
+ * place. The store wires these against its reactive `blocks` cache and re-exposes
9
+ * them unchanged, so callers and tests are unaffected.
10
+ */
11
+ export function useBlockQueries(blocks: Ref<Block[]>) {
12
+ const byId = computed(() => {
13
+ const map = new Map<string, Block>()
14
+ for (const b of blocks.value) map.set(b.id, b)
15
+ return map
16
+ })
17
+
18
+ function getBlock(id: string) {
19
+ return byId.value.get(id)
20
+ }
21
+
22
+ /** Top-level architecture blocks (the only ones drawn as Vue Flow nodes). */
23
+ const frames = computed(() => blocks.value.filter((b) => (b.level ?? 'frame') === 'frame'))
24
+
25
+ /** Direct children of a block, in insertion order. */
26
+ function childrenOf(parentId: string) {
27
+ return blocks.value.filter((b) => b.parentId === parentId)
28
+ }
29
+
30
+ /** Tasks directly inside a container (a service or a module). */
31
+ function tasksOf(containerId: string) {
32
+ return blocks.value.filter((b) => b.parentId === containerId && b.level === 'task')
33
+ }
34
+
35
+ /** Modules (sub-frames) inside a service. */
36
+ function modulesOf(serviceId: string) {
37
+ return blocks.value.filter((b) => b.parentId === serviceId && b.level === 'module')
38
+ }
39
+
40
+ /** Tasks anywhere under a container — directly, or nested inside its modules. */
41
+ function allTasksUnder(containerId: string): Block[] {
42
+ const direct = tasksOf(containerId)
43
+ const nested = modulesOf(containerId).flatMap((m) => tasksOf(m.id))
44
+ return [...direct, ...nested]
45
+ }
46
+
47
+ /** The top-level service a block ultimately belongs to. */
48
+ function serviceOf(block: Block): Block | undefined {
49
+ let cur: Block | undefined = block
50
+ while (cur && cur.level !== 'frame') {
51
+ cur = cur.parentId ? getBlock(cur.parentId) : undefined
52
+ }
53
+ return cur
54
+ }
55
+
56
+ /** All tasks across every service (used for the dependency picker). */
57
+ const allTasks = computed(() => blocks.value.filter((b) => b.level === 'task'))
58
+
59
+ /** A task's dependencies that are not yet merged (i.e. block it from running). */
60
+ function unmetDeps(taskId: string) {
61
+ const t = getBlock(taskId)
62
+ if (!t) return [] as Block[]
63
+ return t.dependsOn
64
+ .map((id) => getBlock(id))
65
+ .filter((b): b is Block => !!b && b.status !== 'done')
66
+ }
67
+
68
+ /** A task may run only once all of its dependencies have merged. */
69
+ function isRunnable(taskId: string) {
70
+ return unmetDeps(taskId).length === 0
71
+ }
72
+
73
+ /** Container status/progress are derived from the tasks under it (containers have no PR). */
74
+ function frameProgress(frameId: string) {
75
+ const tasks = allTasksUnder(frameId)
76
+ if (tasks.length === 0) return getBlock(frameId)?.progress ?? 0
77
+ const sum = tasks.reduce((n, t) => n + (t.status === 'done' ? 1 : t.progress), 0)
78
+ return sum / tasks.length
79
+ }
80
+
81
+ /**
82
+ * A frame is a long-lived service: it never reaches a terminal "done" —
83
+ * tasks keep appearing. So its status reflects current *activity*, mapped
84
+ * onto the shared status palette but capped below `done`:
85
+ * planned → no tasks yet (empty)
86
+ * ready → has tasks but nothing active (idle / caught up / "live")
87
+ * in_progress → at least one task running or with an open PR
88
+ * blocked → at least one task needs a decision
89
+ */
90
+ function frameStatus(frameId: string): BlockStatus {
91
+ const tasks = allTasksUnder(frameId)
92
+ if (tasks.length === 0) return 'planned'
93
+ if (tasks.some((t) => t.status === 'blocked')) return 'blocked'
94
+ if (tasks.some((t) => t.status === 'in_progress' || t.status === 'pr_ready'))
95
+ return 'in_progress'
96
+ return 'ready'
97
+ }
98
+
99
+ /** Pixel size of a container's inner 2D canvas, derived from its children. */
100
+ function containerSize(id: string): { w: number; h: number } {
101
+ const b = getBlock(id)
102
+ const isModule = b?.level === 'module'
103
+ const TASK_W = 180
104
+ const TASK_H = 160
105
+ const headerH = isModule ? 30 : 0
106
+ let w = isModule ? 200 : 360
107
+ let inner = isModule ? 60 : 220
108
+ for (const t of tasksOf(id)) {
109
+ w = Math.max(w, t.position.x + TASK_W + 12)
110
+ inner = Math.max(inner, t.position.y + TASK_H + 12)
111
+ }
112
+ for (const m of modulesOf(id)) {
113
+ const s = containerSize(m.id)
114
+ w = Math.max(w, m.position.x + s.w + 12)
115
+ inner = Math.max(inner, m.position.y + s.h + 12)
116
+ }
117
+ return { w, h: inner + headerH }
118
+ }
119
+
120
+ return {
121
+ byId,
122
+ getBlock,
123
+ frames,
124
+ allTasks,
125
+ childrenOf,
126
+ tasksOf,
127
+ modulesOf,
128
+ allTasksUnder,
129
+ serviceOf,
130
+ unmetDeps,
131
+ isRunnable,
132
+ frameProgress,
133
+ frameStatus,
134
+ containerSize,
135
+ }
136
+ }
@@ -0,0 +1,11 @@
1
+ import { useVueFlow } from '@vue-flow/core'
2
+
3
+ /** Stable id for the main board's Vue Flow instance. Passing this id to
4
+ * useVueFlow() from anywhere (e.g. the toolbar) accesses the same instance. */
5
+ export const BOARD_FLOW_ID = 'board'
6
+
7
+ /** Camera controls for the main board, usable outside the canvas component. */
8
+ export function useBoardFlow() {
9
+ const { fitView, zoomIn, zoomOut, viewport } = useVueFlow(BOARD_FLOW_ID)
10
+ return { fitView, zoomIn, zoomOut, viewport }
11
+ }
@@ -0,0 +1,26 @@
1
+ import type { Block } from '~/types/domain'
2
+
3
+ /**
4
+ * Labels a dependency block, qualifying it with its owning frame when it lives
5
+ * in a different container than the task depending on it (a "cross-frame" edge).
6
+ * Shared by the inspector and the task card so the two read identically.
7
+ */
8
+ export function useDepLabels() {
9
+ const board = useBoardStore()
10
+
11
+ /** Title of a block's parent container, if any. */
12
+ function frameTitle(b: Block): string | undefined {
13
+ return b.parentId ? board.getBlock(b.parentId)?.title : undefined
14
+ }
15
+
16
+ /**
17
+ * `Parent / Title` when `dep` lives in a different container than
18
+ * `contextParentId`, otherwise just the dependency's own title.
19
+ */
20
+ function depLabel(dep: Block, contextParentId?: string | null): string {
21
+ const f = frameTitle(dep)
22
+ return f && dep.parentId !== contextParentId ? `${f} / ${dep.title}` : dep.title
23
+ }
24
+
25
+ return { frameTitle, depLabel }
26
+ }
@@ -0,0 +1,16 @@
1
+ import type { LodLevel } from '~/types/domain'
2
+
3
+ /** Map a raw zoom factor to a level-of-detail bucket. Shared by the main board
4
+ * and the drill-down focus view so both honour the same thresholds. */
5
+ export function zoomToLod(zoom: number): LodLevel {
6
+ if (zoom < 0.6) return 'far'
7
+ if (zoom < 1.2) return 'mid'
8
+ return 'close'
9
+ }
10
+
11
+ /** Reactive LOD bound to the global UI zoom (set by the board canvas). */
12
+ export function useSemanticZoom() {
13
+ const ui = useUiStore()
14
+ const lod = computed<LodLevel>(() => zoomToLod(ui.zoom))
15
+ return { lod, zoom: computed(() => ui.zoom) }
16
+ }
@@ -0,0 +1,125 @@
1
+ import { ref, onScopeDispose } from 'vue'
2
+ import type { WorkspaceEvent } from '~/types/domain'
3
+
4
+ /**
5
+ * Subscribes to the backend's per-workspace WebSocket event stream and keeps the
6
+ * board in sync in real time — the replacement for the old polling clock. Mount
7
+ * once (e.g. on the board page) after the workspace is ready.
8
+ *
9
+ * `execution` events patch the run + its block directly; `bootstrap` events patch
10
+ * a repo-bootstrap run + its service frame (live "bootstrapping…" progress); the
11
+ * coarse `board` event (module materialised, run cancelled) triggers a debounced
12
+ * full refresh. On every (re)connect we refresh once to reconcile anything missed
13
+ * while disconnected, so the server stays the source of truth and a dropped socket
14
+ * self-heals.
15
+ */
16
+ export function useWorkspaceStream() {
17
+ const workspace = useWorkspaceStore()
18
+ const execution = useExecutionStore()
19
+ const board = useBoardStore()
20
+ const agentRuns = useAgentRunsStore()
21
+ const api = useApi()
22
+ const apiBase = useRuntimeConfig().public.apiBase
23
+
24
+ const connected = ref(false)
25
+
26
+ let socket: WebSocket | null = null
27
+ let stopped = false
28
+ let attempt = 0
29
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
30
+ let boardDebounce: ReturnType<typeof setTimeout> | null = null
31
+
32
+ // http→ws, https→wss (apiBase is an absolute origin, see nuxt.config.ts).
33
+ const wsBase = String(apiBase).replace(/^http/, 'ws')
34
+
35
+ function debouncedBoardRefresh() {
36
+ if (boardDebounce) clearTimeout(boardDebounce)
37
+ boardDebounce = setTimeout(() => void workspace.refresh(), 300)
38
+ }
39
+
40
+ function onMessage(raw: string) {
41
+ let event: WorkspaceEvent
42
+ try {
43
+ event = JSON.parse(raw) as WorkspaceEvent
44
+ } catch {
45
+ return
46
+ }
47
+ if (event.type === 'execution') {
48
+ // Full instance drives the step-level UI; agentRuns derives its coarse
49
+ // failure/retry summary from the same store, so no extra call is needed.
50
+ execution.upsert(event.instance)
51
+ if (event.block) board.upsert(event.block)
52
+ } else if (event.type === 'board') {
53
+ debouncedBoardRefresh()
54
+ } else if (event.type === 'bootstrap') {
55
+ // Patch the run's live status/subtasks and its provisional/linked frame so
56
+ // the "bootstrapping…" card updates in place (then flips to a ready service
57
+ // or a failed badge) without a full refresh.
58
+ agentRuns.upsertBootstrap(event.job)
59
+ if (event.block) board.upsert(event.block)
60
+ }
61
+ }
62
+
63
+ async function connect() {
64
+ if (stopped || !workspace.workspaceId) return
65
+ const workspaceId = workspace.workspaceId
66
+
67
+ // A browser can't set Authorization on a WS handshake, so mint a short-lived,
68
+ // workspace-scoped ticket over the authenticated REST channel and pass it as
69
+ // `?ticket=`. Empty when auth is disabled (dev) — the handshake is open then.
70
+ let ticket: string
71
+ try {
72
+ ticket = (await api.mintEventsTicket(workspaceId)).ticket
73
+ } catch {
74
+ // Couldn't mint (offline, token lapsed) — back off and retry.
75
+ scheduleReconnect()
76
+ return
77
+ }
78
+ // A workspace switch (or stop()) may have happened while awaiting the mint.
79
+ if (stopped || workspace.workspaceId !== workspaceId) return
80
+
81
+ const query = ticket ? `?ticket=${encodeURIComponent(ticket)}` : ''
82
+ socket = new WebSocket(`${wsBase}/workspaces/${workspaceId}/events${query}`)
83
+
84
+ socket.onopen = () => {
85
+ attempt = 0
86
+ connected.value = true
87
+ // Resync on (re)connect: any event missed while disconnected is reconciled.
88
+ // The snapshot carries `bootstrapJobs` + executions, so one refresh rehydrates
89
+ // agentRuns too — a missed terminal event (e.g. a container eviction that
90
+ // failed the run) can't leave a frame stuck on a stale "bootstrapping…" badge.
91
+ void workspace.refresh()
92
+ }
93
+ socket.onmessage = (e) => onMessage(typeof e.data === 'string' ? e.data : '')
94
+ socket.onclose = () => {
95
+ connected.value = false
96
+ scheduleReconnect()
97
+ }
98
+ socket.onerror = () => socket?.close()
99
+ }
100
+
101
+ function scheduleReconnect() {
102
+ if (stopped) return
103
+ socket = null
104
+ const delay = Math.min(30_000, 500 * 2 ** attempt) // 0.5s → 30s cap
105
+ attempt += 1
106
+ reconnectTimer = setTimeout(connect, delay)
107
+ }
108
+
109
+ function start() {
110
+ stopped = false
111
+ connect()
112
+ }
113
+
114
+ function stop() {
115
+ stopped = true
116
+ if (reconnectTimer) clearTimeout(reconnectTimer)
117
+ if (boardDebounce) clearTimeout(boardDebounce)
118
+ socket?.close()
119
+ socket = null
120
+ connected.value = false
121
+ }
122
+
123
+ onScopeDispose(stop)
124
+ return { start, stop, connected }
125
+ }
@@ -0,0 +1,31 @@
1
+ # Frontend architecture — state & data flow
2
+
3
+ How the SPA stays in sync with the backend. The app is a **thin client**: it holds
4
+ no business logic, calls the Worker for every mutation, and hydrates its stores
5
+ from server snapshots plus pushed events. For the high-level tour see
6
+ [`../README.md`](../README.md).
7
+
8
+ ## The three paths
9
+
10
+ ```
11
+ REST (useApi) ─────────────▶ Worker ─────────────▶ D1
12
+ ▲ │
13
+ │ mutations │ persisted transition
14
+ stores (Pinia) ◀── patch ── useWorkspaceStream ◀── WebSocket push (events hub)
15
+ ```
16
+
17
+ - **Read path** — the `workspace` store loads the full snapshot and fans it into
18
+ `board`, `pipelines`, `execution`, `spend`, etc.
19
+ - **Write path** — components call `useApi` → Worker; the response (or a pushed
20
+ event) patches the relevant store. No optimistic business logic.
21
+ - **Live path** — `useWorkspaceStream` opens one WebSocket to
22
+ `GET /workspaces/:ws/events?token=…`, patches `execution` / `agentRuns` /
23
+ `board` as events arrive, and refreshes on reconnect to reconcile anything
24
+ missed.
25
+
26
+ ## Stores
27
+
28
+ One Pinia store per feature domain: `workspace`, `accounts`, `auth`, `board`,
29
+ `ui`, `pipelines`, `agents`, `execution`, `agentRuns`, `models`, `github`,
30
+ `bootstrap`, `documents`, `tasks`, `requirements`, `scenarios`, `fragments`
31
+ (built-in catalog), `fragmentLibrary` (tenant tiers + sources), and `spend`.
@@ -0,0 +1,80 @@
1
+ <script setup lang="ts">
2
+ import BoardCanvas from '~/components/board/BoardCanvas.vue'
3
+ import SideBar from '~/components/layout/SideBar.vue'
4
+ import BoardToolbar from '~/components/layout/BoardToolbar.vue'
5
+ import SpendWarningBanner from '~/components/layout/SpendWarningBanner.vue'
6
+ import PipelineBuilder from '~/components/pipeline/PipelineBuilder.vue'
7
+ import InspectorPanel from '~/components/panels/InspectorPanel.vue'
8
+ import DecisionModal from '~/components/panels/DecisionModal.vue'
9
+ import BlockFocusView from '~/components/focus/BlockFocusView.vue'
10
+ import DocumentSourceConnectModal from '~/components/documents/DocumentSourceConnectModal.vue'
11
+ import DocumentImportModal from '~/components/documents/DocumentImportModal.vue'
12
+ import SpawnPreviewModal from '~/components/documents/SpawnPreviewModal.vue'
13
+ import TaskSourceConnectModal from '~/components/tasks/TaskSourceConnectModal.vue'
14
+ import TaskImportModal from '~/components/tasks/TaskImportModal.vue'
15
+ import BootstrapModal from '~/components/bootstrap/BootstrapModal.vue'
16
+ import GitHubPanel from '~/components/github/GitHubPanel.vue'
17
+ import FragmentLibraryPanel from '~/components/fragments/FragmentLibraryPanel.vue'
18
+ import RequirementReviewModal from '~/components/requirements/RequirementReviewModal.vue'
19
+
20
+ const workspace = useWorkspaceStore()
21
+
22
+ // Load the board from the backend before rendering it.
23
+ onMounted(() => workspace.init())
24
+
25
+ // Subscribe to the backend's real-time event stream and (re)connect whenever the
26
+ // active workspace changes. Runs advance durably server-side; progress arrives as
27
+ // pushed events rather than by polling.
28
+ const stream = useWorkspaceStream()
29
+ watch(
30
+ () => workspace.workspaceId,
31
+ (id) => {
32
+ stream.stop()
33
+ if (id) stream.start()
34
+ },
35
+ { immediate: true },
36
+ )
37
+ </script>
38
+
39
+ <template>
40
+ <div class="flex h-screen w-screen overflow-hidden bg-slate-950 text-slate-100">
41
+ <template v-if="workspace.ready">
42
+ <SideBar />
43
+ <main class="relative min-w-0 flex-1">
44
+ <BoardCanvas />
45
+ <BoardToolbar />
46
+ <SpendWarningBanner />
47
+ <InspectorPanel />
48
+ <BlockFocusView />
49
+ </main>
50
+
51
+ <PipelineBuilder />
52
+ <DecisionModal />
53
+ <DocumentSourceConnectModal />
54
+ <DocumentImportModal />
55
+ <SpawnPreviewModal />
56
+ <TaskSourceConnectModal />
57
+ <TaskImportModal />
58
+ <BootstrapModal />
59
+ <GitHubPanel />
60
+ <FragmentLibraryPanel />
61
+ <RequirementReviewModal />
62
+ </template>
63
+
64
+ <!-- Backend unreachable / bootstrap failed -->
65
+ <div v-else-if="workspace.error" class="m-auto max-w-md p-8 text-center">
66
+ <UIcon name="i-lucide-plug-zap" class="mx-auto mb-3 h-10 w-10 text-amber-400" />
67
+ <h1 class="mb-1 text-lg font-semibold">Can’t reach the backend</h1>
68
+ <p class="mb-4 text-sm text-slate-400">{{ workspace.error }}</p>
69
+ <UButton color="primary" icon="i-lucide-rotate-ccw" @click="workspace.init()">
70
+ Retry
71
+ </UButton>
72
+ </div>
73
+
74
+ <!-- Initial load -->
75
+ <div v-else class="m-auto flex flex-col items-center gap-3 text-slate-400">
76
+ <UIcon name="i-lucide-loader" class="h-8 w-8 animate-spin" />
77
+ <span class="text-sm">Loading board…</span>
78
+ </div>
79
+ </div>
80
+ </template>
@@ -0,0 +1,64 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type { Account } from '~/types/domain'
4
+
5
+ /**
6
+ * Account tenancy on the client: the accounts the signed-in user can switch
7
+ * between (their personal account plus any orgs they belong to) and which one is
8
+ * active. The active account scopes the board switcher and stamps new boards, so
9
+ * a team can keep org boards separate from personal ones.
10
+ *
11
+ * Empty when auth is disabled (the backend returns no accounts in dev), in which
12
+ * case the UI simply hides the account switcher and boards stay unscoped.
13
+ */
14
+ export const useAccountsStore = defineStore(
15
+ 'accounts',
16
+ () => {
17
+ const api = useApi()
18
+
19
+ const accounts = ref<Account[]>([])
20
+ /** Active account id (persisted so a reload keeps the same context). */
21
+ const activeAccountId = ref<string | null>(null)
22
+ const ready = ref(false)
23
+
24
+ const activeAccount = computed(
25
+ () => accounts.value.find((a) => a.id === activeAccountId.value) ?? null,
26
+ )
27
+ /** Whether accounts exist (auth on); gates the switcher UI. */
28
+ const enabled = computed(() => accounts.value.length > 0)
29
+
30
+ /** Load the user's accounts and resolve the active one (persisted or first). */
31
+ async function load() {
32
+ accounts.value = await api.listAccounts()
33
+ if (!activeAccountId.value || !accounts.value.some((a) => a.id === activeAccountId.value)) {
34
+ activeAccountId.value = accounts.value[0]?.id ?? null
35
+ }
36
+ ready.value = true
37
+ }
38
+
39
+ /** Create a shared org account and make it active. */
40
+ async function createOrg(name: string) {
41
+ const account = await api.createAccount({ name })
42
+ accounts.value.push(account)
43
+ activeAccountId.value = account.id
44
+ return account
45
+ }
46
+
47
+ /** Switch the active account (the caller re-scopes the board list). */
48
+ function switchTo(id: string) {
49
+ activeAccountId.value = id
50
+ }
51
+
52
+ return {
53
+ accounts,
54
+ activeAccountId,
55
+ activeAccount,
56
+ enabled,
57
+ ready,
58
+ load,
59
+ createOrg,
60
+ switchTo,
61
+ }
62
+ },
63
+ { persist: { pick: ['activeAccountId'] } },
64
+ )
@@ -0,0 +1,117 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type { AgentFailure, AgentRunKind, BootstrapJob, StepSubtasks } from '~/types/domain'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+ import { useExecutionStore } from '~/stores/execution'
6
+
7
+ /**
8
+ * A coarse, per-block view of the current "agent run" against a block, regardless
9
+ * of which flow produced it — enough for the board to render a failure banner +
10
+ * retry and a "working…" progress badge uniformly. The rich step-level UI still
11
+ * reads the full {@link ExecutionInstance} from the execution store.
12
+ */
13
+ export interface AgentRunSummary {
14
+ blockId: string
15
+ kind: AgentRunKind
16
+ /** The run's own status: execution running|blocked|done|paused|failed,
17
+ * bootstrap pending|running|succeeded|failed. */
18
+ status: string
19
+ /** Id of the run, for the unified retry endpoint. */
20
+ runId: string
21
+ /** Structured failure when `status` is `failed`; null otherwise. */
22
+ failure: AgentFailure | null
23
+ /** Latest subtask counts for a live progress bar (null until reported). */
24
+ subtasks: StepSubtasks | null
25
+ }
26
+
27
+ /**
28
+ * Unified failure/retry surface over BOTH agent flows. Bootstrap runs are held
29
+ * here (they have no other home on the client); executions are read from the
30
+ * execution store so they're never duplicated. `byBlock` merges the two so a
31
+ * board card / inspector can look itself up and show the same failure banner +
32
+ * retry whether the block was made by a bootstrap or is running a task pipeline.
33
+ *
34
+ * This replaces the old bootstrap-only `bootstrap.byBlock`/`retry`, whose retry
35
+ * could silently vanish when the separate jobs projection failed to resolve.
36
+ */
37
+ export const useAgentRunsStore = defineStore('agentRuns', () => {
38
+ const api = useApi()
39
+ const execution = useExecutionStore()
40
+
41
+ /** Bootstrap runs for this workspace, newest-first. */
42
+ const bootstrapJobs = ref<BootstrapJob[]>([])
43
+
44
+ /** Replace the cached bootstrap runs with a server snapshot. */
45
+ function hydrate(jobs: BootstrapJob[]) {
46
+ bootstrapJobs.value = [...jobs].sort((a, b) => b.createdAt - a.createdAt)
47
+ }
48
+
49
+ /**
50
+ * Patch a bootstrap run from a real-time `bootstrap` event (or after launching
51
+ * one): replace it in place by id, else prepend it. Keeps the service card
52
+ * reactive to live progress without a refetch.
53
+ */
54
+ function upsertBootstrap(job: BootstrapJob) {
55
+ const i = bootstrapJobs.value.findIndex((j) => j.id === job.id)
56
+ if (i >= 0) bootstrapJobs.value[i] = job
57
+ else bootstrapJobs.value.unshift(job)
58
+ }
59
+
60
+ /**
61
+ * The current run summary per block, merged from execution instances (task
62
+ * runs) and bootstrap runs (service frames). Executions take a block first;
63
+ * a frame block has no execution, so the newest bootstrap run wins there.
64
+ */
65
+ const byBlock = computed<Record<string, AgentRunSummary>>(() => {
66
+ const map: Record<string, AgentRunSummary> = {}
67
+ for (const e of execution.instances) {
68
+ map[e.blockId] = {
69
+ blockId: e.blockId,
70
+ kind: 'execution',
71
+ status: e.status,
72
+ runId: e.id,
73
+ failure: e.failure ?? null,
74
+ subtasks: e.steps[e.currentStep]?.subtasks ?? null,
75
+ }
76
+ }
77
+ // `bootstrapJobs` is newest-first; keep the first (newest) seen per block.
78
+ for (const job of bootstrapJobs.value) {
79
+ if (!job.blockId || map[job.blockId]) continue
80
+ map[job.blockId] = {
81
+ blockId: job.blockId,
82
+ kind: 'bootstrap',
83
+ status: job.status,
84
+ runId: job.id,
85
+ failure: job.failure,
86
+ subtasks: job.subtasks,
87
+ }
88
+ }
89
+ return map
90
+ })
91
+
92
+ /**
93
+ * Retry a failed run (bootstrap or execution) via the unified endpoint, then
94
+ * refresh the snapshot so both stores rehydrate — the card flips from failed
95
+ * back to "working…" as a fresh run is dispatched server-side.
96
+ */
97
+ async function retry(runId: string) {
98
+ const ws = useWorkspaceStore()
99
+ await api.retryAgentRun(ws.requireId(), runId)
100
+ await ws.refresh()
101
+ }
102
+
103
+ /**
104
+ * Explicitly stop a running run (bootstrap or execution) via the unified endpoint:
105
+ * the backend kills the per-run container + tears down the durable driver, then
106
+ * marks the run cancelled. Refresh so both stores rehydrate and the card flips out
107
+ * of its "running" state. Returns the resolved kind so the caller can word a toast.
108
+ */
109
+ async function stop(runId: string): Promise<AgentRunKind> {
110
+ const ws = useWorkspaceStore()
111
+ const { kind } = await api.stopAgentRun(ws.requireId(), runId)
112
+ await ws.refresh()
113
+ return kind
114
+ }
115
+
116
+ return { bootstrapJobs, hydrate, upsertBootstrap, byBlock, retry, stop }
117
+ })
@@ -0,0 +1,40 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import { AGENT_ARCHETYPES, AGENT_BY_KIND, uid } from '~/utils/catalog'
4
+ import type { AgentArchetype, AgentKind } from '~/types/domain'
5
+
6
+ /**
7
+ * The agent palette. Seeded from the static catalog, but custom agents can be
8
+ * added at runtime (they show up in the pipeline builder). Newly created agents
9
+ * are also registered into AGENT_BY_KIND so the many components that look an
10
+ * agent up by kind keep rendering it correctly.
11
+ */
12
+ export const useAgentsStore = defineStore('agents', () => {
13
+ const archetypes = ref<AgentArchetype[]>([...AGENT_ARCHETYPES])
14
+
15
+ function get(kind: AgentKind) {
16
+ return AGENT_BY_KIND[kind]
17
+ }
18
+
19
+ function addAgent(input: {
20
+ label: string
21
+ description?: string
22
+ icon?: string
23
+ color?: string
24
+ }): AgentArchetype {
25
+ const archetype: AgentArchetype = {
26
+ // custom kinds are free-form ids; cast keeps the existing AgentKind typing happy
27
+ kind: uid('agent') as AgentKind,
28
+ label: input.label.trim() || 'Custom Agent',
29
+ description: input.description?.trim() || 'Custom agent.',
30
+ icon: input.icon || 'i-lucide-sparkles',
31
+ color: input.color || '#22d3ee',
32
+ }
33
+ // register for kind-based lookups across the app, then surface in the palette
34
+ AGENT_BY_KIND[archetype.kind] = archetype
35
+ archetypes.value.push(archetype)
36
+ return archetype
37
+ }
38
+
39
+ return { archetypes, get, addAgent }
40
+ })