@cat-factory/app 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -0
  3. package/app/app.config.ts +8 -0
  4. package/app/app.vue +11 -0
  5. package/app/assets/css/main.css +100 -0
  6. package/app/components/auth/AuthGate.vue +24 -0
  7. package/app/components/auth/LoginScreen.vue +18 -0
  8. package/app/components/auth/UserMenu.vue +39 -0
  9. package/app/components/board/AgentFailureCard.vue +97 -0
  10. package/app/components/board/AgentStopButton.vue +61 -0
  11. package/app/components/board/BoardCanvas.vue +146 -0
  12. package/app/components/board/TaskDependencyEdges.vue +132 -0
  13. package/app/components/board/nodes/AgentChip.vue +59 -0
  14. package/app/components/board/nodes/BlockNode.vue +347 -0
  15. package/app/components/board/nodes/DecisionBadge.vue +21 -0
  16. package/app/components/board/nodes/DraggableTask.vue +69 -0
  17. package/app/components/board/nodes/ModuleFrame.vue +70 -0
  18. package/app/components/board/nodes/TaskCard.vue +237 -0
  19. package/app/components/bootstrap/BootstrapModal.vue +665 -0
  20. package/app/components/documents/DocumentImportModal.vue +161 -0
  21. package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
  22. package/app/components/documents/SpawnPreviewModal.vue +161 -0
  23. package/app/components/documents/TaskContextDocs.vue +83 -0
  24. package/app/components/focus/BlockFocusView.vue +161 -0
  25. package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
  26. package/app/components/github/GitHubConnect.vue +183 -0
  27. package/app/components/github/GitHubPanel.vue +584 -0
  28. package/app/components/layout/BoardSwitcher.vue +202 -0
  29. package/app/components/layout/BoardToolbar.vue +109 -0
  30. package/app/components/layout/SideBar.vue +193 -0
  31. package/app/components/layout/SpendWarningBanner.vue +107 -0
  32. package/app/components/palettes/AgentPalette.vue +33 -0
  33. package/app/components/palettes/BlockPalette.vue +41 -0
  34. package/app/components/palettes/PipelinePalette.vue +74 -0
  35. package/app/components/panels/DecisionModal.vue +71 -0
  36. package/app/components/panels/InspectorPanel.vue +296 -0
  37. package/app/components/panels/inspector/ContainerSummary.vue +74 -0
  38. package/app/components/panels/inspector/TaskDependencies.vue +70 -0
  39. package/app/components/panels/inspector/TaskExecution.vue +175 -0
  40. package/app/components/panels/inspector/TaskModelSettings.vue +128 -0
  41. package/app/components/panels/inspector/TaskStructure.vue +139 -0
  42. package/app/components/pipeline/PipelineBuilder.vue +227 -0
  43. package/app/components/pipeline/PipelineProgress.vue +246 -0
  44. package/app/components/requirements/RequirementReviewModal.vue +328 -0
  45. package/app/components/scenarios/FeatureScenarios.vue +162 -0
  46. package/app/components/scenarios/ScenarioCard.vue +109 -0
  47. package/app/components/tasks/TaskContextIssues.vue +88 -0
  48. package/app/components/tasks/TaskImportModal.vue +140 -0
  49. package/app/components/tasks/TaskSourceConnectModal.vue +122 -0
  50. package/app/composables/useApi.ts +535 -0
  51. package/app/composables/useBlockDrag.ts +75 -0
  52. package/app/composables/useBlockQueries.ts +136 -0
  53. package/app/composables/useBoardFlow.ts +11 -0
  54. package/app/composables/useDepLabels.ts +26 -0
  55. package/app/composables/useSemanticZoom.ts +16 -0
  56. package/app/composables/useWorkspaceStream.ts +125 -0
  57. package/app/docs/architecture.md +31 -0
  58. package/app/pages/index.vue +80 -0
  59. package/app/stores/accounts.ts +64 -0
  60. package/app/stores/agentRuns.ts +117 -0
  61. package/app/stores/agents.ts +40 -0
  62. package/app/stores/auth.ts +97 -0
  63. package/app/stores/board.spec.ts +197 -0
  64. package/app/stores/board.ts +147 -0
  65. package/app/stores/bootstrap.ts +97 -0
  66. package/app/stores/documents.ts +165 -0
  67. package/app/stores/execution.ts +115 -0
  68. package/app/stores/fragmentLibrary.ts +147 -0
  69. package/app/stores/fragments.ts +40 -0
  70. package/app/stores/github.ts +291 -0
  71. package/app/stores/models.ts +48 -0
  72. package/app/stores/pipelines.ts +77 -0
  73. package/app/stores/requirements.ts +133 -0
  74. package/app/stores/scenarios.spec.ts +82 -0
  75. package/app/stores/scenarios.ts +196 -0
  76. package/app/stores/tasks.spec.ts +71 -0
  77. package/app/stores/tasks.ts +149 -0
  78. package/app/stores/ui.ts +204 -0
  79. package/app/stores/workspace.ts +201 -0
  80. package/app/types/accounts.ts +38 -0
  81. package/app/types/bootstrap.ts +83 -0
  82. package/app/types/documents.ts +92 -0
  83. package/app/types/domain.ts +216 -0
  84. package/app/types/execution.ts +110 -0
  85. package/app/types/fragments.ts +72 -0
  86. package/app/types/github.ts +153 -0
  87. package/app/types/models.ts +48 -0
  88. package/app/types/requirements.ts +38 -0
  89. package/app/types/scenarios.ts +36 -0
  90. package/app/types/tasks.ts +67 -0
  91. package/app/utils/catalog.spec.ts +82 -0
  92. package/app/utils/catalog.ts +185 -0
  93. package/app/utils/dnd.ts +29 -0
  94. package/nuxt.config.ts +43 -0
  95. package/package.json +43 -0
@@ -0,0 +1,97 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type { AuthUser } from '~/types/domain'
4
+
5
+ /**
6
+ * "Login with GitHub" session state. The backend mints a signed session token
7
+ * and hands it back via a URL fragment after the OAuth round-trip; we persist
8
+ * just that token and replay it as a bearer header on every API call (see
9
+ * `useApi`). Auth is opt-in on the backend, so `required` gates whether the UI
10
+ * shows a login screen at all — when the backend has auth disabled the app runs
11
+ * exactly as before.
12
+ */
13
+ export const useAuthStore = defineStore(
14
+ 'auth',
15
+ () => {
16
+ const api = useApi()
17
+ const apiBase = useRuntimeConfig().public.apiBase
18
+
19
+ /** Signed session token (persisted), or null when signed out. */
20
+ const token = ref<string | null>(null)
21
+ /** The signed-in user, resolved from the token on boot. */
22
+ const user = ref<AuthUser | null>(null)
23
+ /** Whether the backend requires authentication. */
24
+ const required = ref(false)
25
+ /** True once the initial auth handshake has settled. */
26
+ const ready = ref(false)
27
+
28
+ /** May the app render? True when auth is off, or on with a known user. */
29
+ const isAuthenticated = computed(() => !required.value || user.value !== null)
30
+
31
+ /** Pull a token handed back in the post-login URL fragment (#token=…). */
32
+ function consumeRedirectToken() {
33
+ if (typeof window === 'undefined') return
34
+ const match = /(?:^#|[#&])token=([^&]+)/.exec(window.location.hash)
35
+ if (!match) return
36
+ token.value = decodeURIComponent(match[1]!)
37
+ // Strip the token from the URL so it isn't left in history or shared.
38
+ history.replaceState(null, '', window.location.pathname + window.location.search)
39
+ }
40
+
41
+ /** Resolve auth state: capture any redirect token, then check the backend. */
42
+ async function bootstrap() {
43
+ consumeRedirectToken()
44
+ try {
45
+ required.value = (await api.getAuthConfig()).enabled
46
+ } catch {
47
+ // Backend unreachable — let the board's own error UI handle it.
48
+ required.value = false
49
+ ready.value = true
50
+ return
51
+ }
52
+
53
+ if (required.value && token.value) {
54
+ try {
55
+ user.value = (await api.getMe()).user
56
+ } catch {
57
+ user.value = null
58
+ }
59
+ if (!user.value) token.value = null
60
+ }
61
+ ready.value = true
62
+ }
63
+
64
+ /** Send the browser to the backend's GitHub login, returning here after. */
65
+ function login() {
66
+ if (typeof window === 'undefined') return
67
+ const here = window.location.origin + window.location.pathname
68
+ window.location.href = `${apiBase}/auth/login?redirect=${encodeURIComponent(here)}`
69
+ }
70
+
71
+ /** Drop the local session (sessions are stateless server-side). */
72
+ function logout() {
73
+ api.logout().catch(() => {})
74
+ token.value = null
75
+ user.value = null
76
+ }
77
+
78
+ /** Called by the API client when a request comes back 401. */
79
+ function handleUnauthorized() {
80
+ token.value = null
81
+ user.value = null
82
+ }
83
+
84
+ return {
85
+ token,
86
+ user,
87
+ required,
88
+ ready,
89
+ isAuthenticated,
90
+ bootstrap,
91
+ login,
92
+ logout,
93
+ handleUnauthorized,
94
+ }
95
+ },
96
+ { persist: { pick: ['token'] } },
97
+ )
@@ -0,0 +1,197 @@
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('hydrate replaces and upsert inserts/updates cached blocks', () => {
190
+ store.hydrate([frame('f1')])
191
+ store.upsert(task('t1', 'f1', { title: 'first' }))
192
+ expect(store.getBlock('t1')?.title).toBe('first')
193
+ store.upsert(task('t1', 'f1', { title: 'second' }))
194
+ expect(store.getBlock('t1')?.title).toBe('second')
195
+ expect(store.allTasks).toHaveLength(1)
196
+ })
197
+ })
@@ -0,0 +1,147 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type { Block, BlockType } from '~/types/domain'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+ import { useBlockQueries } from '~/composables/useBlockQueries'
6
+
7
+ /**
8
+ * The board: architecture blocks and the dependency edges between them. Blocks
9
+ * are owned by the backend — this store is a hydrated cache. Read getters are
10
+ * pure client logic (see {@link useBlockQueries}); every mutation calls the API
11
+ * and applies the authoritative block the server returns.
12
+ */
13
+ export const useBoardStore = defineStore('board', () => {
14
+ const api = useApi()
15
+ const blocks = ref<Block[]>([])
16
+
17
+ // Pure derivations (hierarchy, status/progress, sizing) live in the composable.
18
+ const queries = useBlockQueries(blocks)
19
+ const { getBlock } = queries
20
+
21
+ /** Replace the cached blocks with a server snapshot. */
22
+ function hydrate(next: Block[]) {
23
+ blocks.value = next
24
+ }
25
+
26
+ /** Insert or replace a block returned by the backend. */
27
+ function upsert(block: Block) {
28
+ const i = blocks.value.findIndex((b) => b.id === block.id)
29
+ if (i >= 0) blocks.value[i] = block
30
+ else blocks.value.push(block)
31
+ }
32
+
33
+ async function addBlock(type: BlockType, position: { x: number; y: number }): Promise<Block> {
34
+ const block = await api.addFrame(useWorkspaceStore().requireId(), { type, position })
35
+ upsert(block)
36
+ return block
37
+ }
38
+
39
+ /** Add a task inside a container (a service or a module). */
40
+ async function addTask(containerId: string, title?: string): Promise<Block | undefined> {
41
+ if (!getBlock(containerId)) return
42
+ const block = await api.addTask(useWorkspaceStore().requireId(), containerId, { title })
43
+ upsert(block)
44
+ return block
45
+ }
46
+
47
+ /** Add a module (sub-frame) inside a service. */
48
+ async function addModule(
49
+ serviceId: string,
50
+ name: string,
51
+ position?: { x: number; y: number },
52
+ ): Promise<Block | undefined> {
53
+ if (!getBlock(serviceId)) return
54
+ const block = await api.addModule(useWorkspaceStore().requireId(), serviceId, {
55
+ name,
56
+ position,
57
+ })
58
+ upsert(block)
59
+ return block
60
+ }
61
+
62
+ /** Move a block into a new container at a new local position. */
63
+ async function reparentBlock(
64
+ id: string,
65
+ newParentId: string,
66
+ position: { x: number; y: number },
67
+ ) {
68
+ const b = getBlock(id)
69
+ const parent = getBlock(newParentId)
70
+ if (!b || !parent || b.id === newParentId) return
71
+ // tasks may live in services or modules; modules only in services
72
+ if (b.level === 'task' && parent.level !== 'frame' && parent.level !== 'module') return
73
+ if (b.level === 'module' && parent.level !== 'frame') return
74
+ upsert(
75
+ await api.reparentBlock(useWorkspaceStore().requireId(), id, {
76
+ parentId: newParentId,
77
+ position,
78
+ }),
79
+ )
80
+ }
81
+
82
+ async function removeBlock(id: string) {
83
+ if (!getBlock(id)) return
84
+ await api.removeBlock(useWorkspaceStore().requireId(), id)
85
+ // the server cascades to descendants; mirror that in the local cache
86
+ const doomed = new Set<string>([id])
87
+ let grew = true
88
+ while (grew) {
89
+ grew = false
90
+ for (const b of blocks.value) {
91
+ if (b.parentId && doomed.has(b.parentId) && !doomed.has(b.id)) {
92
+ doomed.add(b.id)
93
+ grew = true
94
+ }
95
+ }
96
+ }
97
+ blocks.value = blocks.value.filter((b) => !doomed.has(b.id))
98
+ for (const b of blocks.value) {
99
+ b.dependsOn = b.dependsOn.filter((d) => !doomed.has(d))
100
+ }
101
+ }
102
+
103
+ async function moveBlock(id: string, position: { x: number; y: number }) {
104
+ const b = getBlock(id)
105
+ if (!b) return
106
+ b.position = position // optimistic: keep the drag feeling instant
107
+ upsert(await api.moveBlock(useWorkspaceStore().requireId(), id, { position }))
108
+ }
109
+
110
+ /** Patch the user-editable fields of a block (title, features, threshold…). */
111
+ async function updateBlock(id: string, patch: Partial<Block>) {
112
+ const b = getBlock(id)
113
+ if (!b) return
114
+ Object.assign(b, patch) // optimistic
115
+ upsert(await api.updateBlock(useWorkspaceStore().requireId(), id, patch))
116
+ }
117
+
118
+ /** Toggle a dependency edge target -> source (target dependsOn source). */
119
+ async function toggleDependency(targetId: string, sourceId: string) {
120
+ if (targetId === sourceId || !getBlock(targetId)) return
121
+ upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
122
+ }
123
+
124
+ /** Remove a dependency edge target -> source if it exists. */
125
+ async function removeDependency(targetId: string, sourceId: string) {
126
+ const t = getBlock(targetId)
127
+ if (!t || !t.dependsOn.includes(sourceId)) return
128
+ // the backend exposes a single toggle; the edge exists, so toggling removes it
129
+ upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
130
+ }
131
+
132
+ return {
133
+ blocks,
134
+ hydrate,
135
+ upsert,
136
+ ...queries,
137
+ addBlock,
138
+ addTask,
139
+ addModule,
140
+ reparentBlock,
141
+ removeBlock,
142
+ moveBlock,
143
+ updateBlock,
144
+ toggleDependency,
145
+ removeDependency,
146
+ }
147
+ })
@@ -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
+ })