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