@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.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/app/app.config.ts +8 -0
- package/app/app.vue +11 -0
- package/app/assets/css/main.css +100 -0
- package/app/components/auth/AuthGate.vue +24 -0
- package/app/components/auth/LoginScreen.vue +18 -0
- package/app/components/auth/UserMenu.vue +39 -0
- package/app/components/board/AgentFailureCard.vue +97 -0
- package/app/components/board/AgentStopButton.vue +61 -0
- package/app/components/board/BoardCanvas.vue +146 -0
- package/app/components/board/TaskDependencyEdges.vue +132 -0
- package/app/components/board/nodes/AgentChip.vue +59 -0
- package/app/components/board/nodes/BlockNode.vue +347 -0
- package/app/components/board/nodes/DecisionBadge.vue +21 -0
- package/app/components/board/nodes/DraggableTask.vue +69 -0
- package/app/components/board/nodes/ModuleFrame.vue +70 -0
- package/app/components/board/nodes/TaskCard.vue +237 -0
- package/app/components/bootstrap/BootstrapModal.vue +665 -0
- package/app/components/documents/DocumentImportModal.vue +161 -0
- package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
- package/app/components/documents/SpawnPreviewModal.vue +161 -0
- package/app/components/documents/TaskContextDocs.vue +83 -0
- package/app/components/focus/BlockFocusView.vue +161 -0
- package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
- package/app/components/github/GitHubConnect.vue +183 -0
- package/app/components/github/GitHubPanel.vue +584 -0
- package/app/components/layout/BoardSwitcher.vue +202 -0
- package/app/components/layout/BoardToolbar.vue +109 -0
- package/app/components/layout/SideBar.vue +193 -0
- package/app/components/layout/SpendWarningBanner.vue +107 -0
- package/app/components/palettes/AgentPalette.vue +33 -0
- package/app/components/palettes/BlockPalette.vue +41 -0
- package/app/components/palettes/PipelinePalette.vue +74 -0
- package/app/components/panels/DecisionModal.vue +71 -0
- package/app/components/panels/InspectorPanel.vue +296 -0
- package/app/components/panels/inspector/ContainerSummary.vue +74 -0
- package/app/components/panels/inspector/TaskDependencies.vue +70 -0
- package/app/components/panels/inspector/TaskExecution.vue +175 -0
- package/app/components/panels/inspector/TaskModelSettings.vue +128 -0
- package/app/components/panels/inspector/TaskStructure.vue +139 -0
- package/app/components/pipeline/PipelineBuilder.vue +227 -0
- package/app/components/pipeline/PipelineProgress.vue +246 -0
- package/app/components/requirements/RequirementReviewModal.vue +328 -0
- package/app/components/scenarios/FeatureScenarios.vue +162 -0
- package/app/components/scenarios/ScenarioCard.vue +109 -0
- package/app/components/tasks/TaskContextIssues.vue +88 -0
- package/app/components/tasks/TaskImportModal.vue +140 -0
- package/app/components/tasks/TaskSourceConnectModal.vue +122 -0
- package/app/composables/useApi.ts +535 -0
- package/app/composables/useBlockDrag.ts +75 -0
- package/app/composables/useBlockQueries.ts +136 -0
- package/app/composables/useBoardFlow.ts +11 -0
- package/app/composables/useDepLabels.ts +26 -0
- package/app/composables/useSemanticZoom.ts +16 -0
- package/app/composables/useWorkspaceStream.ts +125 -0
- package/app/docs/architecture.md +31 -0
- package/app/pages/index.vue +80 -0
- package/app/stores/accounts.ts +64 -0
- package/app/stores/agentRuns.ts +117 -0
- package/app/stores/agents.ts +40 -0
- package/app/stores/auth.ts +97 -0
- package/app/stores/board.spec.ts +197 -0
- package/app/stores/board.ts +147 -0
- package/app/stores/bootstrap.ts +97 -0
- package/app/stores/documents.ts +165 -0
- package/app/stores/execution.ts +115 -0
- package/app/stores/fragmentLibrary.ts +147 -0
- package/app/stores/fragments.ts +40 -0
- package/app/stores/github.ts +291 -0
- package/app/stores/models.ts +48 -0
- package/app/stores/pipelines.ts +77 -0
- package/app/stores/requirements.ts +133 -0
- package/app/stores/scenarios.spec.ts +82 -0
- package/app/stores/scenarios.ts +196 -0
- package/app/stores/tasks.spec.ts +71 -0
- package/app/stores/tasks.ts +149 -0
- package/app/stores/ui.ts +204 -0
- package/app/stores/workspace.ts +201 -0
- package/app/types/accounts.ts +38 -0
- package/app/types/bootstrap.ts +83 -0
- package/app/types/documents.ts +92 -0
- package/app/types/domain.ts +216 -0
- package/app/types/execution.ts +110 -0
- package/app/types/fragments.ts +72 -0
- package/app/types/github.ts +153 -0
- package/app/types/models.ts +48 -0
- package/app/types/requirements.ts +38 -0
- package/app/types/scenarios.ts +36 -0
- package/app/types/tasks.ts +67 -0
- package/app/utils/catalog.spec.ts +82 -0
- package/app/utils/catalog.ts +185 -0
- package/app/utils/dnd.ts +29 -0
- package/nuxt.config.ts +43 -0
- 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
|
+
})
|