@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.
Files changed (189) 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 +143 -0
  8. package/app/components/auth/UserMenu.vue +39 -0
  9. package/app/components/board/AddTaskModal.vue +444 -0
  10. package/app/components/board/AgentFailureCard.vue +97 -0
  11. package/app/components/board/AgentStopButton.vue +61 -0
  12. package/app/components/board/BoardCanvas.vue +183 -0
  13. package/app/components/board/ContextPicker.vue +367 -0
  14. package/app/components/board/RecurringPipelineModal.vue +219 -0
  15. package/app/components/board/TaskDependencyEdges.vue +132 -0
  16. package/app/components/board/nodes/AgentChip.vue +59 -0
  17. package/app/components/board/nodes/BlockNode.vue +433 -0
  18. package/app/components/board/nodes/DecisionBadge.vue +27 -0
  19. package/app/components/board/nodes/DraggableTask.vue +48 -0
  20. package/app/components/board/nodes/ModuleFrame.vue +97 -0
  21. package/app/components/board/nodes/TaskCard.vue +359 -0
  22. package/app/components/board/nodes/TaskPipelineMini.vue +159 -0
  23. package/app/components/bootstrap/BootstrapModal.vue +665 -0
  24. package/app/components/clarity/ClarityReviewWindow.vue +611 -0
  25. package/app/components/consensus/ConsensusSessionWindow.vue +210 -0
  26. package/app/components/documents/DocumentImportModal.vue +161 -0
  27. package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
  28. package/app/components/documents/SpawnPreviewModal.vue +161 -0
  29. package/app/components/documents/TaskContextDocs.vue +83 -0
  30. package/app/components/focus/BlockFocusView.vue +171 -0
  31. package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
  32. package/app/components/gates/GateResultView.vue +282 -0
  33. package/app/components/github/AddServiceFromRepoModal.vue +354 -0
  34. package/app/components/github/GitHubConnect.vue +183 -0
  35. package/app/components/github/GitHubOnboarding.vue +45 -0
  36. package/app/components/github/GitHubPanel.vue +584 -0
  37. package/app/components/github/RepoTreeBrowser.vue +171 -0
  38. package/app/components/layout/AccountTeamSettings.vue +237 -0
  39. package/app/components/layout/BoardSwitcher.vue +280 -0
  40. package/app/components/layout/BoardToolbar.vue +156 -0
  41. package/app/components/layout/CommandBar.vue +336 -0
  42. package/app/components/layout/GitHubPatBanner.vue +73 -0
  43. package/app/components/layout/NotificationsInbox.vue +175 -0
  44. package/app/components/layout/SideBar.vue +314 -0
  45. package/app/components/layout/SpendWarningBanner.vue +107 -0
  46. package/app/components/observability/StepMetricsBar.vue +102 -0
  47. package/app/components/palettes/AgentPalette.vue +86 -0
  48. package/app/components/panels/AgentStepDetail.vue +737 -0
  49. package/app/components/panels/DecisionModal.vue +71 -0
  50. package/app/components/panels/InspectorPanel.vue +465 -0
  51. package/app/components/panels/ObservabilityPanel.vue +351 -0
  52. package/app/components/panels/StepMetadataCard.vue +253 -0
  53. package/app/components/panels/StepRestartControl.vue +90 -0
  54. package/app/components/panels/StepResultViewHost.vue +40 -0
  55. package/app/components/panels/StepTestReport.vue +84 -0
  56. package/app/components/panels/inspector/ContainerSummary.vue +74 -0
  57. package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -0
  58. package/app/components/panels/inspector/ServiceFragments.vue +82 -0
  59. package/app/components/panels/inspector/ServiceTestConfig.vue +198 -0
  60. package/app/components/panels/inspector/TaskAgentConfig.vue +81 -0
  61. package/app/components/panels/inspector/TaskDependencies.vue +70 -0
  62. package/app/components/panels/inspector/TaskEstimateBadge.vue +56 -0
  63. package/app/components/panels/inspector/TaskExecution.vue +364 -0
  64. package/app/components/panels/inspector/TaskRunSettings.vue +187 -0
  65. package/app/components/panels/inspector/TaskStructure.vue +96 -0
  66. package/app/components/pipeline/AgentKindIcon.vue +30 -0
  67. package/app/components/pipeline/IterationCapPrompt.vue +70 -0
  68. package/app/components/pipeline/PipelineBuilder.vue +817 -0
  69. package/app/components/pipeline/PipelineProgress.vue +484 -0
  70. package/app/components/providers/ApiKeysSection.vue +273 -0
  71. package/app/components/providers/PersonalCredentialModal.vue +128 -0
  72. package/app/components/providers/PersonalSubscriptionSection.vue +225 -0
  73. package/app/components/providers/VendorCredentialsModal.vue +197 -0
  74. package/app/components/recurring/RecurrenceEditor.vue +124 -0
  75. package/app/components/requirements/RequirementsReviewWindow.vue +620 -0
  76. package/app/components/settings/DatadogPanel.vue +213 -0
  77. package/app/components/settings/LocalModelEndpointsPanel.vue +286 -0
  78. package/app/components/settings/MergeThresholdsPanel.vue +378 -0
  79. package/app/components/settings/ModelDefaultsPanel.vue +250 -0
  80. package/app/components/settings/ServiceFragmentDefaultsPanel.vue +124 -0
  81. package/app/components/settings/WorkspaceSettingsPanel.vue +142 -0
  82. package/app/components/slack/SlackPanel.vue +299 -0
  83. package/app/components/tasks/TaskContextIssues.vue +88 -0
  84. package/app/components/tasks/TaskImportModal.vue +207 -0
  85. package/app/components/tasks/TaskSourceConnectModal.vue +133 -0
  86. package/app/components/testing/TestReportWindow.vue +404 -0
  87. package/app/composables/api/accounts.ts +81 -0
  88. package/app/composables/api/auth.ts +45 -0
  89. package/app/composables/api/board.ts +101 -0
  90. package/app/composables/api/bootstrap.ts +62 -0
  91. package/app/composables/api/context.ts +25 -0
  92. package/app/composables/api/documents.ts +74 -0
  93. package/app/composables/api/execution.ts +127 -0
  94. package/app/composables/api/fragments.ts +71 -0
  95. package/app/composables/api/github.ts +131 -0
  96. package/app/composables/api/models.ts +127 -0
  97. package/app/composables/api/notifications.ts +23 -0
  98. package/app/composables/api/presets.ts +29 -0
  99. package/app/composables/api/recurring.ts +68 -0
  100. package/app/composables/api/releaseHealth.ts +43 -0
  101. package/app/composables/api/reviews.ts +146 -0
  102. package/app/composables/api/slack.ts +54 -0
  103. package/app/composables/api/tasks.ts +72 -0
  104. package/app/composables/api/workspaces.ts +36 -0
  105. package/app/composables/useApi.ts +89 -0
  106. package/app/composables/useBlockDrag.ts +90 -0
  107. package/app/composables/useBlockQueries.ts +154 -0
  108. package/app/composables/useBoardFlow.ts +11 -0
  109. package/app/composables/useContextLinking.ts +65 -0
  110. package/app/composables/useDepLabels.ts +26 -0
  111. package/app/composables/useFrameResize.ts +54 -0
  112. package/app/composables/useResultView.ts +48 -0
  113. package/app/composables/useReviewStage.ts +40 -0
  114. package/app/composables/useSemanticZoom.ts +31 -0
  115. package/app/composables/useStepApproval.ts +233 -0
  116. package/app/composables/useStepProse.ts +78 -0
  117. package/app/composables/useStepTimer.ts +63 -0
  118. package/app/composables/useTaskExpansion.ts +92 -0
  119. package/app/composables/useWorkspaceStream.ts +155 -0
  120. package/app/docs/architecture.md +31 -0
  121. package/app/pages/index.vue +141 -0
  122. package/app/stores/accounts.ts +152 -0
  123. package/app/stores/agentConfig.ts +35 -0
  124. package/app/stores/agentRuns.ts +122 -0
  125. package/app/stores/agents.ts +40 -0
  126. package/app/stores/apiKeys.ts +108 -0
  127. package/app/stores/auth.ts +166 -0
  128. package/app/stores/board.spec.ts +205 -0
  129. package/app/stores/board.ts +286 -0
  130. package/app/stores/bootstrap.ts +97 -0
  131. package/app/stores/clarity.ts +196 -0
  132. package/app/stores/consensus.ts +60 -0
  133. package/app/stores/documents.ts +176 -0
  134. package/app/stores/execution.ts +273 -0
  135. package/app/stores/fragmentLibrary.ts +147 -0
  136. package/app/stores/fragments.ts +40 -0
  137. package/app/stores/github.ts +305 -0
  138. package/app/stores/localModels.ts +51 -0
  139. package/app/stores/mergePresets.ts +58 -0
  140. package/app/stores/modelDefaults.ts +76 -0
  141. package/app/stores/models.ts +134 -0
  142. package/app/stores/notifications.ts +70 -0
  143. package/app/stores/observability.ts +144 -0
  144. package/app/stores/personalSubscriptions.ts +215 -0
  145. package/app/stores/pipelines.ts +327 -0
  146. package/app/stores/recurringPipelines.ts +112 -0
  147. package/app/stores/releaseHealth.ts +75 -0
  148. package/app/stores/requirements.spec.ts +94 -0
  149. package/app/stores/requirements.ts +208 -0
  150. package/app/stores/serviceFragmentDefaults.ts +29 -0
  151. package/app/stores/services.ts +87 -0
  152. package/app/stores/slack.ts +142 -0
  153. package/app/stores/taskExpansion.ts +36 -0
  154. package/app/stores/tasks.spec.ts +71 -0
  155. package/app/stores/tasks.ts +176 -0
  156. package/app/stores/tracker.ts +27 -0
  157. package/app/stores/ui.ts +434 -0
  158. package/app/stores/vendorCredentials.ts +54 -0
  159. package/app/stores/workspace.ts +215 -0
  160. package/app/stores/workspaceSettings.ts +36 -0
  161. package/app/types/accounts.ts +77 -0
  162. package/app/types/bootstrap.ts +83 -0
  163. package/app/types/clarity.ts +59 -0
  164. package/app/types/consensus.ts +91 -0
  165. package/app/types/documents.ts +104 -0
  166. package/app/types/domain.ts +495 -0
  167. package/app/types/execution.ts +383 -0
  168. package/app/types/fragments.ts +72 -0
  169. package/app/types/github.ts +173 -0
  170. package/app/types/localModels.ts +73 -0
  171. package/app/types/merge.ts +71 -0
  172. package/app/types/models.ts +157 -0
  173. package/app/types/notifications.ts +74 -0
  174. package/app/types/recurring.ts +69 -0
  175. package/app/types/releaseHealth.ts +31 -0
  176. package/app/types/requirements.ts +61 -0
  177. package/app/types/services.ts +27 -0
  178. package/app/types/slack.ts +57 -0
  179. package/app/types/tasks.ts +82 -0
  180. package/app/types/tracker.ts +18 -0
  181. package/app/utils/agentOutput.spec.ts +128 -0
  182. package/app/utils/agentOutput.ts +173 -0
  183. package/app/utils/catalog.spec.ts +112 -0
  184. package/app/utils/catalog.ts +455 -0
  185. package/app/utils/dnd.ts +29 -0
  186. package/app/utils/observability.ts +52 -0
  187. package/app/utils/pipelineRender.ts +151 -0
  188. package/nuxt.config.ts +55 -0
  189. 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
+ })