@cat-factory/app 0.6.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 +143 -0
- package/app/components/auth/UserMenu.vue +39 -0
- package/app/components/board/AddTaskModal.vue +444 -0
- package/app/components/board/AgentFailureCard.vue +97 -0
- package/app/components/board/AgentStopButton.vue +61 -0
- package/app/components/board/BoardCanvas.vue +183 -0
- package/app/components/board/ContextPicker.vue +367 -0
- package/app/components/board/RecurringPipelineModal.vue +219 -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 +433 -0
- package/app/components/board/nodes/DecisionBadge.vue +27 -0
- package/app/components/board/nodes/DraggableTask.vue +48 -0
- package/app/components/board/nodes/ModuleFrame.vue +97 -0
- package/app/components/board/nodes/TaskCard.vue +359 -0
- package/app/components/board/nodes/TaskPipelineMini.vue +159 -0
- package/app/components/bootstrap/BootstrapModal.vue +665 -0
- package/app/components/clarity/ClarityReviewWindow.vue +611 -0
- package/app/components/consensus/ConsensusSessionWindow.vue +210 -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 +171 -0
- package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
- package/app/components/gates/GateResultView.vue +282 -0
- package/app/components/github/AddServiceFromRepoModal.vue +354 -0
- package/app/components/github/GitHubConnect.vue +183 -0
- package/app/components/github/GitHubOnboarding.vue +45 -0
- package/app/components/github/GitHubPanel.vue +584 -0
- package/app/components/github/RepoTreeBrowser.vue +171 -0
- package/app/components/layout/AccountTeamSettings.vue +237 -0
- package/app/components/layout/BoardSwitcher.vue +280 -0
- package/app/components/layout/BoardToolbar.vue +156 -0
- package/app/components/layout/CommandBar.vue +336 -0
- package/app/components/layout/GitHubPatBanner.vue +73 -0
- package/app/components/layout/NotificationsInbox.vue +175 -0
- package/app/components/layout/SideBar.vue +314 -0
- package/app/components/layout/SpendWarningBanner.vue +107 -0
- package/app/components/observability/StepMetricsBar.vue +102 -0
- package/app/components/palettes/AgentPalette.vue +86 -0
- package/app/components/panels/AgentStepDetail.vue +737 -0
- package/app/components/panels/DecisionModal.vue +71 -0
- package/app/components/panels/InspectorPanel.vue +465 -0
- package/app/components/panels/ObservabilityPanel.vue +351 -0
- package/app/components/panels/StepMetadataCard.vue +253 -0
- package/app/components/panels/StepRestartControl.vue +90 -0
- package/app/components/panels/StepResultViewHost.vue +40 -0
- package/app/components/panels/StepTestReport.vue +84 -0
- package/app/components/panels/inspector/ContainerSummary.vue +74 -0
- package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -0
- package/app/components/panels/inspector/ServiceFragments.vue +82 -0
- package/app/components/panels/inspector/ServiceTestConfig.vue +198 -0
- package/app/components/panels/inspector/TaskAgentConfig.vue +81 -0
- package/app/components/panels/inspector/TaskDependencies.vue +70 -0
- package/app/components/panels/inspector/TaskEstimateBadge.vue +56 -0
- package/app/components/panels/inspector/TaskExecution.vue +364 -0
- package/app/components/panels/inspector/TaskRunSettings.vue +187 -0
- package/app/components/panels/inspector/TaskStructure.vue +96 -0
- package/app/components/pipeline/AgentKindIcon.vue +30 -0
- package/app/components/pipeline/IterationCapPrompt.vue +70 -0
- package/app/components/pipeline/PipelineBuilder.vue +817 -0
- package/app/components/pipeline/PipelineProgress.vue +484 -0
- package/app/components/providers/ApiKeysSection.vue +273 -0
- package/app/components/providers/PersonalCredentialModal.vue +128 -0
- package/app/components/providers/PersonalSubscriptionSection.vue +225 -0
- package/app/components/providers/VendorCredentialsModal.vue +197 -0
- package/app/components/recurring/RecurrenceEditor.vue +124 -0
- package/app/components/requirements/RequirementsReviewWindow.vue +620 -0
- package/app/components/settings/DatadogPanel.vue +213 -0
- package/app/components/settings/LocalModelEndpointsPanel.vue +286 -0
- package/app/components/settings/MergeThresholdsPanel.vue +378 -0
- package/app/components/settings/ModelDefaultsPanel.vue +250 -0
- package/app/components/settings/ServiceFragmentDefaultsPanel.vue +124 -0
- package/app/components/settings/WorkspaceSettingsPanel.vue +142 -0
- package/app/components/slack/SlackPanel.vue +299 -0
- package/app/components/tasks/TaskContextIssues.vue +88 -0
- package/app/components/tasks/TaskImportModal.vue +207 -0
- package/app/components/tasks/TaskSourceConnectModal.vue +133 -0
- package/app/components/testing/TestReportWindow.vue +404 -0
- package/app/composables/api/accounts.ts +81 -0
- package/app/composables/api/auth.ts +45 -0
- package/app/composables/api/board.ts +101 -0
- package/app/composables/api/bootstrap.ts +62 -0
- package/app/composables/api/context.ts +25 -0
- package/app/composables/api/documents.ts +74 -0
- package/app/composables/api/execution.ts +127 -0
- package/app/composables/api/fragments.ts +71 -0
- package/app/composables/api/github.ts +131 -0
- package/app/composables/api/models.ts +127 -0
- package/app/composables/api/notifications.ts +23 -0
- package/app/composables/api/presets.ts +29 -0
- package/app/composables/api/recurring.ts +68 -0
- package/app/composables/api/releaseHealth.ts +43 -0
- package/app/composables/api/reviews.ts +146 -0
- package/app/composables/api/slack.ts +54 -0
- package/app/composables/api/tasks.ts +72 -0
- package/app/composables/api/workspaces.ts +36 -0
- package/app/composables/useApi.ts +89 -0
- package/app/composables/useBlockDrag.ts +90 -0
- package/app/composables/useBlockQueries.ts +154 -0
- package/app/composables/useBoardFlow.ts +11 -0
- package/app/composables/useContextLinking.ts +65 -0
- package/app/composables/useDepLabels.ts +26 -0
- package/app/composables/useFrameResize.ts +54 -0
- package/app/composables/useResultView.ts +48 -0
- package/app/composables/useReviewStage.ts +40 -0
- package/app/composables/useSemanticZoom.ts +31 -0
- package/app/composables/useStepApproval.ts +233 -0
- package/app/composables/useStepProse.ts +78 -0
- package/app/composables/useStepTimer.ts +63 -0
- package/app/composables/useTaskExpansion.ts +92 -0
- package/app/composables/useWorkspaceStream.ts +155 -0
- package/app/docs/architecture.md +31 -0
- package/app/pages/index.vue +141 -0
- package/app/stores/accounts.ts +152 -0
- package/app/stores/agentConfig.ts +35 -0
- package/app/stores/agentRuns.ts +122 -0
- package/app/stores/agents.ts +40 -0
- package/app/stores/apiKeys.ts +108 -0
- package/app/stores/auth.ts +166 -0
- package/app/stores/board.spec.ts +205 -0
- package/app/stores/board.ts +286 -0
- package/app/stores/bootstrap.ts +97 -0
- package/app/stores/clarity.ts +196 -0
- package/app/stores/consensus.ts +60 -0
- package/app/stores/documents.ts +176 -0
- package/app/stores/execution.ts +273 -0
- package/app/stores/fragmentLibrary.ts +147 -0
- package/app/stores/fragments.ts +40 -0
- package/app/stores/github.ts +305 -0
- package/app/stores/localModels.ts +51 -0
- package/app/stores/mergePresets.ts +58 -0
- package/app/stores/modelDefaults.ts +76 -0
- package/app/stores/models.ts +134 -0
- package/app/stores/notifications.ts +70 -0
- package/app/stores/observability.ts +144 -0
- package/app/stores/personalSubscriptions.ts +215 -0
- package/app/stores/pipelines.ts +327 -0
- package/app/stores/recurringPipelines.ts +112 -0
- package/app/stores/releaseHealth.ts +75 -0
- package/app/stores/requirements.spec.ts +94 -0
- package/app/stores/requirements.ts +208 -0
- package/app/stores/serviceFragmentDefaults.ts +29 -0
- package/app/stores/services.ts +87 -0
- package/app/stores/slack.ts +142 -0
- package/app/stores/taskExpansion.ts +36 -0
- package/app/stores/tasks.spec.ts +71 -0
- package/app/stores/tasks.ts +176 -0
- package/app/stores/tracker.ts +27 -0
- package/app/stores/ui.ts +434 -0
- package/app/stores/vendorCredentials.ts +54 -0
- package/app/stores/workspace.ts +215 -0
- package/app/stores/workspaceSettings.ts +36 -0
- package/app/types/accounts.ts +77 -0
- package/app/types/bootstrap.ts +83 -0
- package/app/types/clarity.ts +59 -0
- package/app/types/consensus.ts +91 -0
- package/app/types/documents.ts +104 -0
- package/app/types/domain.ts +495 -0
- package/app/types/execution.ts +383 -0
- package/app/types/fragments.ts +72 -0
- package/app/types/github.ts +173 -0
- package/app/types/localModels.ts +73 -0
- package/app/types/merge.ts +71 -0
- package/app/types/models.ts +157 -0
- package/app/types/notifications.ts +74 -0
- package/app/types/recurring.ts +69 -0
- package/app/types/releaseHealth.ts +31 -0
- package/app/types/requirements.ts +61 -0
- package/app/types/services.ts +27 -0
- package/app/types/slack.ts +57 -0
- package/app/types/tasks.ts +82 -0
- package/app/types/tracker.ts +18 -0
- package/app/utils/agentOutput.spec.ts +128 -0
- package/app/utils/agentOutput.ts +173 -0
- package/app/utils/catalog.spec.ts +112 -0
- package/app/utils/catalog.ts +455 -0
- package/app/utils/dnd.ts +29 -0
- package/app/utils/observability.ts +52 -0
- package/app/utils/pipelineRender.ts +151 -0
- package/nuxt.config.ts +55 -0
- package/package.json +45 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import type { Block, BlockStatus } from '~/types/domain'
|
|
3
|
+
import { useBoardStore } from '~/stores/board'
|
|
4
|
+
|
|
5
|
+
/** Minimal Block factory — only the fields the read getters care about. */
|
|
6
|
+
function block(id: string, over: Partial<Block> = {}): Block {
|
|
7
|
+
return {
|
|
8
|
+
id,
|
|
9
|
+
title: id,
|
|
10
|
+
type: 'service',
|
|
11
|
+
description: '',
|
|
12
|
+
position: { x: 0, y: 0 },
|
|
13
|
+
status: 'planned',
|
|
14
|
+
progress: 0,
|
|
15
|
+
dependsOn: [],
|
|
16
|
+
executionId: null,
|
|
17
|
+
level: 'frame',
|
|
18
|
+
parentId: null,
|
|
19
|
+
...over,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const frame = (id: string, over: Partial<Block> = {}) => block(id, { level: 'frame', ...over })
|
|
24
|
+
const moduleBlock = (id: string, parentId: string, over: Partial<Block> = {}) =>
|
|
25
|
+
block(id, { level: 'module', parentId, ...over })
|
|
26
|
+
const task = (id: string, parentId: string, over: Partial<Block> = {}) =>
|
|
27
|
+
block(id, { level: 'task', parentId, ...over })
|
|
28
|
+
|
|
29
|
+
describe('board store read getters', () => {
|
|
30
|
+
let store: ReturnType<typeof useBoardStore>
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
store = useBoardStore()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('byId / getBlock index blocks by id', () => {
|
|
36
|
+
store.hydrate([frame('f1'), task('t1', 'f1')])
|
|
37
|
+
expect(store.getBlock('f1')?.id).toBe('f1')
|
|
38
|
+
expect(store.getBlock('t1')?.level).toBe('task')
|
|
39
|
+
expect(store.getBlock('missing')).toBeUndefined()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('frames returns only top-level blocks (level absent defaults to frame)', () => {
|
|
43
|
+
const legacy = block('legacy')
|
|
44
|
+
// @ts-expect-error simulate legacy/persisted data without a level
|
|
45
|
+
delete legacy.level
|
|
46
|
+
store.hydrate([frame('f1'), moduleBlock('m1', 'f1'), task('t1', 'f1'), legacy])
|
|
47
|
+
expect(store.frames.map((b) => b.id).sort()).toEqual(['f1', 'legacy'])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('allTasks returns every task across the board', () => {
|
|
51
|
+
store.hydrate([frame('f1'), task('t1', 'f1'), moduleBlock('m1', 'f1'), task('t2', 'm1')])
|
|
52
|
+
expect(store.allTasks.map((b) => b.id).sort()).toEqual(['t1', 't2'])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('childrenOf / tasksOf / modulesOf filter by parent and level', () => {
|
|
56
|
+
store.hydrate([frame('f1'), moduleBlock('m1', 'f1'), task('t1', 'f1'), task('t2', 'm1')])
|
|
57
|
+
expect(
|
|
58
|
+
store
|
|
59
|
+
.childrenOf('f1')
|
|
60
|
+
.map((b) => b.id)
|
|
61
|
+
.sort(),
|
|
62
|
+
).toEqual(['m1', 't1'])
|
|
63
|
+
expect(store.tasksOf('f1').map((b) => b.id)).toEqual(['t1'])
|
|
64
|
+
expect(store.modulesOf('f1').map((b) => b.id)).toEqual(['m1'])
|
|
65
|
+
expect(store.tasksOf('m1').map((b) => b.id)).toEqual(['t2'])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('allTasksUnder includes direct tasks and tasks nested in modules', () => {
|
|
69
|
+
store.hydrate([
|
|
70
|
+
frame('f1'),
|
|
71
|
+
task('t1', 'f1'),
|
|
72
|
+
moduleBlock('m1', 'f1'),
|
|
73
|
+
task('t2', 'm1'),
|
|
74
|
+
task('t3', 'm1'),
|
|
75
|
+
])
|
|
76
|
+
expect(
|
|
77
|
+
store
|
|
78
|
+
.allTasksUnder('f1')
|
|
79
|
+
.map((b) => b.id)
|
|
80
|
+
.sort(),
|
|
81
|
+
).toEqual(['t1', 't2', 't3'])
|
|
82
|
+
expect(
|
|
83
|
+
store
|
|
84
|
+
.allTasksUnder('m1')
|
|
85
|
+
.map((b) => b.id)
|
|
86
|
+
.sort(),
|
|
87
|
+
).toEqual(['t2', 't3'])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('serviceOf walks up to the owning top-level frame', () => {
|
|
91
|
+
store.hydrate([frame('f1'), moduleBlock('m1', 'f1'), task('t1', 'm1'), task('t2', 'f1')])
|
|
92
|
+
expect(store.serviceOf(store.getBlock('t1')!)?.id).toBe('f1')
|
|
93
|
+
expect(store.serviceOf(store.getBlock('t2')!)?.id).toBe('f1')
|
|
94
|
+
expect(store.serviceOf(store.getBlock('m1')!)?.id).toBe('f1')
|
|
95
|
+
expect(store.serviceOf(store.getBlock('f1')!)?.id).toBe('f1')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('dependencies', () => {
|
|
99
|
+
const status = (s: BlockStatus) => ({ status: s })
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
store.hydrate([
|
|
102
|
+
frame('f1'),
|
|
103
|
+
task('done', 'f1', status('done')),
|
|
104
|
+
task('open', 'f1', status('in_progress')),
|
|
105
|
+
task('t', 'f1', { dependsOn: ['done', 'open', 'ghost'] }),
|
|
106
|
+
])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('unmetDeps lists existing dependencies that are not done', () => {
|
|
110
|
+
expect(store.unmetDeps('t').map((b) => b.id)).toEqual(['open'])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('isRunnable is true only when no dependency is outstanding', () => {
|
|
114
|
+
expect(store.isRunnable('t')).toBe(false)
|
|
115
|
+
expect(store.isRunnable('done')).toBe(true)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('frameStatus', () => {
|
|
120
|
+
const seed = (...statuses: BlockStatus[]) =>
|
|
121
|
+
store.hydrate([frame('f1'), ...statuses.map((s, i) => task(`t${i}`, 'f1', { status: s }))])
|
|
122
|
+
|
|
123
|
+
it('is planned when there are no tasks', () => {
|
|
124
|
+
store.hydrate([frame('f1')])
|
|
125
|
+
expect(store.frameStatus('f1')).toBe('planned')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('is blocked when any task is blocked (highest priority)', () => {
|
|
129
|
+
seed('done', 'in_progress', 'blocked')
|
|
130
|
+
expect(store.frameStatus('f1')).toBe('blocked')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('is in_progress when a task is running or has an open PR', () => {
|
|
134
|
+
seed('done', 'pr_ready')
|
|
135
|
+
expect(store.frameStatus('f1')).toBe('in_progress')
|
|
136
|
+
seed('ready', 'in_progress')
|
|
137
|
+
expect(store.frameStatus('f1')).toBe('in_progress')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('is ready when there are tasks but none active', () => {
|
|
141
|
+
seed('done', 'ready')
|
|
142
|
+
expect(store.frameStatus('f1')).toBe('ready')
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('frameProgress', () => {
|
|
147
|
+
it("falls back to the frame's own progress when it has no tasks", () => {
|
|
148
|
+
store.hydrate([frame('f1', { progress: 0.42 })])
|
|
149
|
+
expect(store.frameProgress('f1')).toBe(0.42)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('averages task progress, counting done as 1', () => {
|
|
153
|
+
store.hydrate([
|
|
154
|
+
frame('f1'),
|
|
155
|
+
task('t1', 'f1', { status: 'done', progress: 0 }),
|
|
156
|
+
task('t2', 'f1', { status: 'in_progress', progress: 0.5 }),
|
|
157
|
+
])
|
|
158
|
+
expect(store.frameProgress('f1')).toBeCloseTo(0.75)
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe('containerSize', () => {
|
|
163
|
+
it('returns base dimensions for an empty service', () => {
|
|
164
|
+
store.hydrate([frame('f1')])
|
|
165
|
+
expect(store.containerSize('f1')).toEqual({ w: 360, h: 220 })
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('grows to fit a task and adds the module header height for modules', () => {
|
|
169
|
+
store.hydrate([
|
|
170
|
+
frame('f1'),
|
|
171
|
+
moduleBlock('m1', 'f1', { position: { x: 0, y: 0 } }),
|
|
172
|
+
task('t1', 'm1', { position: { x: 300, y: 200 } }),
|
|
173
|
+
])
|
|
174
|
+
// module inner width/height fit the task, plus the 30px module header.
|
|
175
|
+
const size = store.containerSize('m1')
|
|
176
|
+
expect(size.w).toBe(300 + 180 + 12)
|
|
177
|
+
expect(size.h).toBe(200 + 160 + 12 + 30)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('expands a service to enclose its nested modules', () => {
|
|
181
|
+
store.hydrate([frame('f1'), moduleBlock('m1', 'f1', { position: { x: 400, y: 300 } })])
|
|
182
|
+
const mod = store.containerSize('m1')
|
|
183
|
+
const svc = store.containerSize('f1')
|
|
184
|
+
expect(svc.w).toBe(400 + mod.w + 12)
|
|
185
|
+
expect(svc.h).toBe(300 + mod.h + 12)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('previewMove updates a block position locally without persisting', () => {
|
|
190
|
+
store.hydrate([frame('f1'), task('t1', 'f1', { position: { x: 0, y: 0 } })])
|
|
191
|
+
store.previewMove('t1', { x: 120, y: 40 })
|
|
192
|
+
expect(store.getBlock('t1')?.position).toEqual({ x: 120, y: 40 })
|
|
193
|
+
// a no-op for unknown ids (no throw)
|
|
194
|
+
expect(() => store.previewMove('missing', { x: 1, y: 1 })).not.toThrow()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('hydrate replaces and upsert inserts/updates cached blocks', () => {
|
|
198
|
+
store.hydrate([frame('f1')])
|
|
199
|
+
store.upsert(task('t1', 'f1', { title: 'first' }))
|
|
200
|
+
expect(store.getBlock('t1')?.title).toBe('first')
|
|
201
|
+
store.upsert(task('t1', 'f1', { title: 'second' }))
|
|
202
|
+
expect(store.getBlock('t1')?.title).toBe('second')
|
|
203
|
+
expect(store.allTasks).toHaveLength(1)
|
|
204
|
+
})
|
|
205
|
+
})
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { Block, BlockType, CreateTaskType, TaskTypeFields } from '~/types/domain'
|
|
4
|
+
import { useServicesStore } from '~/stores/services'
|
|
5
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
6
|
+
import { useBlockQueries } from '~/composables/useBlockQueries'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The board: architecture blocks and the dependency edges between them. Blocks
|
|
10
|
+
* are owned by the backend — this store is a hydrated cache. Read getters are
|
|
11
|
+
* pure client logic (see {@link useBlockQueries}); every mutation calls the API
|
|
12
|
+
* and applies the authoritative block the server returns.
|
|
13
|
+
*/
|
|
14
|
+
/** A detached subtree captured before an optimistic delete, restored on failure. */
|
|
15
|
+
interface RemovalSnapshot {
|
|
16
|
+
/** The removed block + all its descendants, in their original order. */
|
|
17
|
+
removed: Block[]
|
|
18
|
+
/** Survivors whose `dependsOn` lost an edge to a removed block (originals to restore). */
|
|
19
|
+
edges: { id: string; dependsOn: string[] }[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const useBoardStore = defineStore('board', () => {
|
|
23
|
+
const api = useApi()
|
|
24
|
+
const toast = useToast()
|
|
25
|
+
const blocks = ref<Block[]>([])
|
|
26
|
+
|
|
27
|
+
// Pure derivations (hierarchy, status/progress, sizing) live in the composable.
|
|
28
|
+
const queries = useBlockQueries(blocks)
|
|
29
|
+
const { getBlock } = queries
|
|
30
|
+
|
|
31
|
+
/** Replace the cached blocks with a server snapshot. */
|
|
32
|
+
function hydrate(next: Block[]) {
|
|
33
|
+
blocks.value = next
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Insert or replace a block returned by the backend. */
|
|
37
|
+
function upsert(block: Block) {
|
|
38
|
+
const i = blocks.value.findIndex((b) => b.id === block.id)
|
|
39
|
+
if (i >= 0) blocks.value[i] = block
|
|
40
|
+
else blocks.value.push(block)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function addBlock(type: BlockType, position: { x: number; y: number }): Promise<Block> {
|
|
44
|
+
const block = await api.addFrame(useWorkspaceStore().requireId(), { type, position })
|
|
45
|
+
upsert(block)
|
|
46
|
+
return block
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Import an existing GitHub repo (the App is installed + it's projected) as a
|
|
51
|
+
* service frame, with no bootstrap run. The backend links the repo to the new
|
|
52
|
+
* frame and returns it `ready`; we upsert it onto the board.
|
|
53
|
+
*/
|
|
54
|
+
async function addServiceFromRepo(
|
|
55
|
+
repoGithubId: number,
|
|
56
|
+
opts?: { directory?: string; isMonorepo?: boolean },
|
|
57
|
+
): Promise<Block> {
|
|
58
|
+
const block = await api.addServiceFromRepo(useWorkspaceStore().requireId(), {
|
|
59
|
+
repoGithubId,
|
|
60
|
+
...(opts?.directory ? { directory: opts.directory } : {}),
|
|
61
|
+
...(opts?.isMonorepo !== undefined ? { isMonorepo: opts.isMonorepo } : {}),
|
|
62
|
+
})
|
|
63
|
+
upsert(block)
|
|
64
|
+
return block
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Add a task inside a container (a service or a module). The user supplies the
|
|
69
|
+
* title (and optional description) — the task is created in `planned` state and
|
|
70
|
+
* is not launched until the user explicitly starts a pipeline on it.
|
|
71
|
+
*/
|
|
72
|
+
async function addTask(
|
|
73
|
+
containerId: string,
|
|
74
|
+
title: string,
|
|
75
|
+
description?: string,
|
|
76
|
+
options?: {
|
|
77
|
+
taskType?: CreateTaskType
|
|
78
|
+
taskTypeFields?: TaskTypeFields
|
|
79
|
+
mergePresetId?: string
|
|
80
|
+
pipelineId?: string
|
|
81
|
+
agentConfig?: Record<string, string>
|
|
82
|
+
},
|
|
83
|
+
): Promise<Block | undefined> {
|
|
84
|
+
if (!getBlock(containerId)) return
|
|
85
|
+
const block = await api.addTask(useWorkspaceStore().requireId(), containerId, {
|
|
86
|
+
title,
|
|
87
|
+
description,
|
|
88
|
+
...(options?.taskType ? { taskType: options.taskType } : {}),
|
|
89
|
+
...(options?.taskTypeFields ? { taskTypeFields: options.taskTypeFields } : {}),
|
|
90
|
+
...(options?.mergePresetId ? { mergePresetId: options.mergePresetId } : {}),
|
|
91
|
+
...(options?.pipelineId ? { pipelineId: options.pipelineId } : {}),
|
|
92
|
+
...(options?.agentConfig ? { agentConfig: options.agentConfig } : {}),
|
|
93
|
+
})
|
|
94
|
+
upsert(block)
|
|
95
|
+
return block
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Add a module (sub-frame) inside a service. */
|
|
99
|
+
async function addModule(
|
|
100
|
+
serviceId: string,
|
|
101
|
+
name: string,
|
|
102
|
+
position?: { x: number; y: number },
|
|
103
|
+
): Promise<Block | undefined> {
|
|
104
|
+
if (!getBlock(serviceId)) return
|
|
105
|
+
const block = await api.addModule(useWorkspaceStore().requireId(), serviceId, {
|
|
106
|
+
name,
|
|
107
|
+
position,
|
|
108
|
+
})
|
|
109
|
+
upsert(block)
|
|
110
|
+
return block
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Move a block into a new container at a new local position. */
|
|
114
|
+
async function reparentBlock(
|
|
115
|
+
id: string,
|
|
116
|
+
newParentId: string,
|
|
117
|
+
position: { x: number; y: number },
|
|
118
|
+
) {
|
|
119
|
+
const b = getBlock(id)
|
|
120
|
+
const parent = getBlock(newParentId)
|
|
121
|
+
if (!b || !parent || b.id === newParentId) return
|
|
122
|
+
// tasks may live in services or modules; modules only in services
|
|
123
|
+
if (b.level === 'task' && parent.level !== 'frame' && parent.level !== 'module') return
|
|
124
|
+
if (b.level === 'module' && parent.level !== 'frame') return
|
|
125
|
+
// Optimistic: drop the block into the new container immediately so it doesn't
|
|
126
|
+
// briefly snap back to its old home while the request is in flight. Snapshot
|
|
127
|
+
// the old home so a rejected reparent restores it rather than leaving the
|
|
128
|
+
// block in the wrong container (a structural lie that survives until re-hydrate).
|
|
129
|
+
const prevParentId = b.parentId
|
|
130
|
+
const prevPosition = b.position
|
|
131
|
+
b.parentId = newParentId
|
|
132
|
+
b.position = position
|
|
133
|
+
try {
|
|
134
|
+
upsert(
|
|
135
|
+
await api.reparentBlock(useWorkspaceStore().requireId(), id, {
|
|
136
|
+
parentId: newParentId,
|
|
137
|
+
position,
|
|
138
|
+
}),
|
|
139
|
+
)
|
|
140
|
+
} catch (e) {
|
|
141
|
+
b.parentId = prevParentId
|
|
142
|
+
b.position = prevPosition
|
|
143
|
+
toast.add({
|
|
144
|
+
title: 'Could not move',
|
|
145
|
+
description: e instanceof Error ? e.message : String(e),
|
|
146
|
+
icon: 'i-lucide-triangle-alert',
|
|
147
|
+
color: 'error',
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Optimistically drop a block and its descendants from the cache, returning a
|
|
154
|
+
* snapshot so the removal can be undone if the backend call fails. The server
|
|
155
|
+
* cascades to descendants, so we mirror that here. Exposed for other stores
|
|
156
|
+
* (e.g. recurring pipelines) that delete a block through their own endpoint.
|
|
157
|
+
*/
|
|
158
|
+
function detach(id: string): RemovalSnapshot | null {
|
|
159
|
+
if (!getBlock(id)) return null
|
|
160
|
+
const doomed = new Set<string>([id])
|
|
161
|
+
let grew = true
|
|
162
|
+
while (grew) {
|
|
163
|
+
grew = false
|
|
164
|
+
for (const b of blocks.value) {
|
|
165
|
+
if (b.parentId && doomed.has(b.parentId) && !doomed.has(b.id)) {
|
|
166
|
+
doomed.add(b.id)
|
|
167
|
+
grew = true
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const removed = blocks.value.filter((b) => doomed.has(b.id))
|
|
172
|
+
// Survivors that pointed at a doomed block lose that edge — snapshot the originals.
|
|
173
|
+
const edges = blocks.value
|
|
174
|
+
.filter((b) => !doomed.has(b.id) && b.dependsOn.some((d) => doomed.has(d)))
|
|
175
|
+
.map((b) => ({ id: b.id, dependsOn: [...b.dependsOn] }))
|
|
176
|
+
blocks.value = blocks.value.filter((b) => !doomed.has(b.id))
|
|
177
|
+
for (const b of blocks.value) {
|
|
178
|
+
if (b.dependsOn.some((d) => doomed.has(d))) {
|
|
179
|
+
b.dependsOn = b.dependsOn.filter((d) => !doomed.has(d))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { removed, edges }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Re-insert a detached subtree and restore its broken edges (delete rollback). */
|
|
186
|
+
function reattach(snap: RemovalSnapshot) {
|
|
187
|
+
for (const b of snap.removed) if (!getBlock(b.id)) blocks.value.push(b)
|
|
188
|
+
for (const e of snap.edges) {
|
|
189
|
+
const b = getBlock(e.id)
|
|
190
|
+
if (b) b.dependsOn = e.dependsOn
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Delete a block. The subtree is hidden IMMEDIATELY (optimistic) so the board
|
|
196
|
+
* feels instant; if the backend rejects the delete we put it back and surface a
|
|
197
|
+
* toast rather than silently leaving a ghost.
|
|
198
|
+
*/
|
|
199
|
+
async function removeBlock(id: string) {
|
|
200
|
+
const snap = detach(id)
|
|
201
|
+
if (!snap) return
|
|
202
|
+
try {
|
|
203
|
+
await api.removeBlock(useWorkspaceStore().requireId(), id)
|
|
204
|
+
} catch (e) {
|
|
205
|
+
reattach(snap)
|
|
206
|
+
toast.add({
|
|
207
|
+
title: 'Could not delete',
|
|
208
|
+
description: e instanceof Error ? e.message : String(e),
|
|
209
|
+
icon: 'i-lucide-triangle-alert',
|
|
210
|
+
color: 'error',
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Local-only optimistic position update during an active drag — no persistence.
|
|
217
|
+
* A drag fires this on every pointer move so the block tracks the cursor without
|
|
218
|
+
* a per-move API round-trip; the final position is committed once via
|
|
219
|
+
* {@link moveBlock} (or {@link reparentBlock}) on release. Persisting every move
|
|
220
|
+
* raced: out-of-order responses to the burst of in-flight writes could land a
|
|
221
|
+
* stale position last, snapping the block back after the user let go.
|
|
222
|
+
*/
|
|
223
|
+
function previewMove(id: string, position: { x: number; y: number }) {
|
|
224
|
+
const b = getBlock(id)
|
|
225
|
+
if (b) b.position = position
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function moveBlock(id: string, position: { x: number; y: number }) {
|
|
229
|
+
const b = getBlock(id)
|
|
230
|
+
if (!b) return
|
|
231
|
+
b.position = position // optimistic: keep the drag feeling instant
|
|
232
|
+
// A mounted service frame's position is a PER-WORKSPACE layout override on the mount, not
|
|
233
|
+
// on the (shared) block — so route a frame drag there. Other moves write the block.
|
|
234
|
+
const services = useServicesStore()
|
|
235
|
+
const mount = services.serviceByFrameBlock[id]
|
|
236
|
+
? services.byServiceId[services.serviceByFrameBlock[id]!.id]
|
|
237
|
+
: undefined
|
|
238
|
+
if (mount) {
|
|
239
|
+
await services.updateLayout(mount.serviceId, position)
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
upsert(await api.moveBlock(useWorkspaceStore().requireId(), id, { position }))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Patch the user-editable fields of a block (title, features, threshold…). */
|
|
246
|
+
async function updateBlock(id: string, patch: Partial<Block>) {
|
|
247
|
+
const b = getBlock(id)
|
|
248
|
+
if (!b) return
|
|
249
|
+
Object.assign(b, patch) // optimistic
|
|
250
|
+
upsert(await api.updateBlock(useWorkspaceStore().requireId(), id, patch))
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Toggle a dependency edge target -> source (target dependsOn source). */
|
|
254
|
+
async function toggleDependency(targetId: string, sourceId: string) {
|
|
255
|
+
if (targetId === sourceId || !getBlock(targetId)) return
|
|
256
|
+
upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Remove a dependency edge target -> source if it exists. */
|
|
260
|
+
async function removeDependency(targetId: string, sourceId: string) {
|
|
261
|
+
const t = getBlock(targetId)
|
|
262
|
+
if (!t || !t.dependsOn.includes(sourceId)) return
|
|
263
|
+
// the backend exposes a single toggle; the edge exists, so toggling removes it
|
|
264
|
+
upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
blocks,
|
|
269
|
+
hydrate,
|
|
270
|
+
upsert,
|
|
271
|
+
...queries,
|
|
272
|
+
addBlock,
|
|
273
|
+
addServiceFromRepo,
|
|
274
|
+
addTask,
|
|
275
|
+
addModule,
|
|
276
|
+
reparentBlock,
|
|
277
|
+
detach,
|
|
278
|
+
reattach,
|
|
279
|
+
removeBlock,
|
|
280
|
+
previewMove,
|
|
281
|
+
moveBlock,
|
|
282
|
+
updateBlock,
|
|
283
|
+
toggleDependency,
|
|
284
|
+
removeDependency,
|
|
285
|
+
}
|
|
286
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import type {
|
|
4
|
+
BootstrapRepoInput,
|
|
5
|
+
CreateReferenceArchitectureInput,
|
|
6
|
+
ReferenceArchitecture,
|
|
7
|
+
UpdateReferenceArchitectureInput,
|
|
8
|
+
} from '~/types/domain'
|
|
9
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
10
|
+
import { useAgentRunsStore } from '~/stores/agentRuns'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Repo-bootstrap state: the workspace's managed reference architectures, plus the
|
|
14
|
+
* actions that CRUD the bases and launch a "bootstrap repo" run. Per-workspace,
|
|
15
|
+
* like the board itself; nothing is persisted client-side. `available` mirrors
|
|
16
|
+
* whether the bootstrap module is reachable (CRUD always is); a run may still come
|
|
17
|
+
* back 503 when the GitHub + container machinery is not configured, which the
|
|
18
|
+
* caller surfaces as an error.
|
|
19
|
+
*
|
|
20
|
+
* The runs themselves (status, progress, failure + retry) now live in the unified
|
|
21
|
+
* {@link useAgentRunsStore}, shared with task executions — this store only owns the
|
|
22
|
+
* managed bases and the launch action.
|
|
23
|
+
*/
|
|
24
|
+
export const useBootstrapStore = defineStore('bootstrap', () => {
|
|
25
|
+
const api = useApi()
|
|
26
|
+
const workspace = useWorkspaceStore()
|
|
27
|
+
|
|
28
|
+
/** null = unknown (not probed yet), true/false = module reachable or not. */
|
|
29
|
+
const available = ref<boolean | null>(null)
|
|
30
|
+
const architectures = ref<ReferenceArchitecture[]>([])
|
|
31
|
+
const loading = ref(false)
|
|
32
|
+
|
|
33
|
+
const hasArchitectures = computed(() => architectures.value.length > 0)
|
|
34
|
+
|
|
35
|
+
/** Load reference architectures; resolves `available`. */
|
|
36
|
+
async function load() {
|
|
37
|
+
if (!workspace.workspaceId) return
|
|
38
|
+
loading.value = true
|
|
39
|
+
try {
|
|
40
|
+
architectures.value = await api.listReferenceArchitectures(workspace.requireId())
|
|
41
|
+
available.value = true
|
|
42
|
+
} catch {
|
|
43
|
+
// 503 (module disabled) or any error → hide the UI entry points.
|
|
44
|
+
available.value = false
|
|
45
|
+
} finally {
|
|
46
|
+
loading.value = false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Register a new reference architecture. */
|
|
51
|
+
async function createArchitecture(input: CreateReferenceArchitectureInput) {
|
|
52
|
+
const created = await api.createReferenceArchitecture(workspace.requireId(), input)
|
|
53
|
+
architectures.value.unshift(created)
|
|
54
|
+
return created
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Patch a reference architecture. */
|
|
58
|
+
async function updateArchitecture(id: string, input: UpdateReferenceArchitectureInput) {
|
|
59
|
+
const updated = await api.updateReferenceArchitecture(workspace.requireId(), id, input)
|
|
60
|
+
const i = architectures.value.findIndex((a) => a.id === id)
|
|
61
|
+
if (i >= 0) architectures.value[i] = updated
|
|
62
|
+
return updated
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Remove a reference architecture. */
|
|
66
|
+
async function deleteArchitecture(id: string) {
|
|
67
|
+
await api.deleteReferenceArchitecture(workspace.requireId(), id)
|
|
68
|
+
architectures.value = architectures.value.filter((a) => a.id !== id)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Kick off a "bootstrap repo" run. Returns immediately with the `running` job —
|
|
73
|
+
* the container keeps working in the background; the provisional service frame
|
|
74
|
+
* already shows on the board and live progress arrives over the event stream.
|
|
75
|
+
* The run is recorded in {@link useAgentRunsStore} so its card appears at once.
|
|
76
|
+
*/
|
|
77
|
+
async function bootstrap(input: BootstrapRepoInput) {
|
|
78
|
+
const job = await api.bootstrapRepo(workspace.requireId(), input)
|
|
79
|
+
useAgentRunsStore().upsertBootstrap(job)
|
|
80
|
+
// The new run materialised a provisional frame server-side; pull it onto the
|
|
81
|
+
// board now so the card appears even before the first event arrives.
|
|
82
|
+
await workspace.refresh()
|
|
83
|
+
return job
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
available,
|
|
88
|
+
architectures,
|
|
89
|
+
loading,
|
|
90
|
+
hasArchitectures,
|
|
91
|
+
load,
|
|
92
|
+
createArchitecture,
|
|
93
|
+
updateArchitecture,
|
|
94
|
+
deleteArchitecture,
|
|
95
|
+
bootstrap,
|
|
96
|
+
}
|
|
97
|
+
})
|