@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,291 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import type {
|
|
4
|
+
CreateBranchInput,
|
|
5
|
+
CreateRepoRequest,
|
|
6
|
+
GitHubAvailableRepo,
|
|
7
|
+
GitHubBranch,
|
|
8
|
+
GitHubConnection,
|
|
9
|
+
GitHubInstallationOption,
|
|
10
|
+
GitHubIssue,
|
|
11
|
+
GitHubPullRequest,
|
|
12
|
+
GitHubRepo,
|
|
13
|
+
MergePullRequestInput,
|
|
14
|
+
OpenPullRequestInput,
|
|
15
|
+
ResyncRequest,
|
|
16
|
+
} from '~/types/domain'
|
|
17
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* GitHub integration state: the workspace's App installation, the projected
|
|
21
|
+
* repos/branches/pull-requests/issues the backend caches in D1, and the actions
|
|
22
|
+
* that connect/resync and write against the repo. `available` mirrors the
|
|
23
|
+
* backend's opt-in gate — a 503 from the connection probe means the integration
|
|
24
|
+
* is off, and the UI hides its entry points (exactly as the documents store
|
|
25
|
+
* gates on its source probe, and `auth.required` gates the login UI). Per
|
|
26
|
+
* workspace, like the board itself; nothing is persisted client-side.
|
|
27
|
+
*/
|
|
28
|
+
export const useGitHubStore = defineStore('github', () => {
|
|
29
|
+
const api = useApi()
|
|
30
|
+
const workspace = useWorkspaceStore()
|
|
31
|
+
|
|
32
|
+
/** null = unknown (not probed yet), true/false = integration on/off. */
|
|
33
|
+
const available = ref<boolean | null>(null)
|
|
34
|
+
/** The workspace's App installation, or null when not yet connected. */
|
|
35
|
+
const connection = ref<GitHubConnection | null>(null)
|
|
36
|
+
/** Discovered App installations for the connect picker; loaded on demand. */
|
|
37
|
+
const installations = ref<GitHubInstallationOption[]>([])
|
|
38
|
+
const loadingInstallations = ref(false)
|
|
39
|
+
const repos = ref<GitHubRepo[]>([])
|
|
40
|
+
/** Repos the installation can access, for the per-workspace link picker. */
|
|
41
|
+
const availableRepos = ref<GitHubAvailableRepo[]>([])
|
|
42
|
+
const loadingAvailable = ref(false)
|
|
43
|
+
const savingRepos = ref(false)
|
|
44
|
+
const pulls = ref<GitHubPullRequest[]>([])
|
|
45
|
+
const issues = ref<GitHubIssue[]>([])
|
|
46
|
+
/** Branches loaded lazily per repo (by GitHub numeric id). */
|
|
47
|
+
const branches = ref<Record<number, GitHubBranch[]>>({})
|
|
48
|
+
const loading = ref(false)
|
|
49
|
+
const syncing = ref(false)
|
|
50
|
+
|
|
51
|
+
const connected = computed(() => connection.value !== null)
|
|
52
|
+
/** Whether cat-factory can create repos under the connected account itself. */
|
|
53
|
+
const canCreateRepos = computed(() => connection.value?.canCreateRepos === true)
|
|
54
|
+
|
|
55
|
+
function repoFor(repoGithubId: number): GitHubRepo | undefined {
|
|
56
|
+
return repos.value.find((r) => r.githubId === repoGithubId)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The repo linked to a board block (its backing service repo), if any. */
|
|
60
|
+
function repoForBlock(blockId: string): GitHubRepo | undefined {
|
|
61
|
+
return repos.value.find((r) => r.blockId === blockId)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function pullsForRepo(repoGithubId: number): GitHubPullRequest[] {
|
|
65
|
+
return pulls.value.filter((p) => p.repoGithubId === repoGithubId)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function issuesForRepo(repoGithubId: number): GitHubIssue[] {
|
|
69
|
+
return issues.value.filter((i) => i.repoGithubId === repoGithubId)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Build the github.com URL for a repo / PR / issue from the projection row. */
|
|
73
|
+
function repoUrl(repoGithubId: number): string | null {
|
|
74
|
+
const r = repoFor(repoGithubId)
|
|
75
|
+
return r ? `https://github.com/${r.owner}/${r.name}` : null
|
|
76
|
+
}
|
|
77
|
+
function pullUrl(pr: GitHubPullRequest): string | null {
|
|
78
|
+
const base = repoUrl(pr.repoGithubId)
|
|
79
|
+
return base ? `${base}/pull/${pr.number}` : null
|
|
80
|
+
}
|
|
81
|
+
function issueUrl(issue: GitHubIssue): string | null {
|
|
82
|
+
const base = repoUrl(issue.repoGithubId)
|
|
83
|
+
return base ? `${base}/issues/${issue.number}` : null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Probe the integration: resolves `available` and the current connection. */
|
|
87
|
+
async function probe() {
|
|
88
|
+
if (!workspace.workspaceId) return
|
|
89
|
+
try {
|
|
90
|
+
const { connection: conn } = await api.getGitHubConnection(workspace.requireId())
|
|
91
|
+
available.value = true
|
|
92
|
+
connection.value = conn
|
|
93
|
+
} catch {
|
|
94
|
+
// 503 (integration disabled) or any error → hide the UI entry points.
|
|
95
|
+
available.value = false
|
|
96
|
+
connection.value = null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Load the cached repos, pull requests and issues for the workspace. */
|
|
101
|
+
async function load() {
|
|
102
|
+
if (!connected.value) return
|
|
103
|
+
loading.value = true
|
|
104
|
+
try {
|
|
105
|
+
const [r, p, i] = await Promise.all([
|
|
106
|
+
api.listGitHubRepos(workspace.requireId()),
|
|
107
|
+
api.listGitHubPullRequests(workspace.requireId()),
|
|
108
|
+
api.listGitHubIssues(workspace.requireId()),
|
|
109
|
+
])
|
|
110
|
+
repos.value = r
|
|
111
|
+
pulls.value = p
|
|
112
|
+
issues.value = i
|
|
113
|
+
} finally {
|
|
114
|
+
loading.value = false
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Ensure the projection (repos/PRs/issues) is loaded at least once — for views
|
|
120
|
+
* that need it without opening the GitHub panel (e.g. the inspector's repo link).
|
|
121
|
+
* Probes the integration first if it hasn't been yet.
|
|
122
|
+
*/
|
|
123
|
+
async function ensureLoaded() {
|
|
124
|
+
if (available.value === null) await probe()
|
|
125
|
+
if (connected.value && repos.value.length === 0) await load()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Load the repos the installation can access, with this workspace's link state. */
|
|
129
|
+
async function loadAvailableRepos() {
|
|
130
|
+
if (!connected.value) return
|
|
131
|
+
loadingAvailable.value = true
|
|
132
|
+
try {
|
|
133
|
+
availableRepos.value = await api.listGitHubAvailableRepos(workspace.requireId())
|
|
134
|
+
} finally {
|
|
135
|
+
loadingAvailable.value = false
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Set the exact set of repos this workspace links, then refresh projections. */
|
|
140
|
+
async function setLinkedRepos(repoGithubIds: number[]) {
|
|
141
|
+
savingRepos.value = true
|
|
142
|
+
try {
|
|
143
|
+
repos.value = await api.setGitHubLinkedRepos(workspace.requireId(), repoGithubIds)
|
|
144
|
+
// Reflect the new link state in the picker and refresh PRs/issues.
|
|
145
|
+
const linked = new Set(repoGithubIds)
|
|
146
|
+
availableRepos.value = availableRepos.value.map((r) => ({
|
|
147
|
+
...r,
|
|
148
|
+
linked: linked.has(r.githubId),
|
|
149
|
+
}))
|
|
150
|
+
await load()
|
|
151
|
+
} finally {
|
|
152
|
+
savingRepos.value = false
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Lazily load (and cache) the branches for a single repo. */
|
|
157
|
+
async function loadBranches(repoGithubId: number): Promise<GitHubBranch[]> {
|
|
158
|
+
const list = await api.listGitHubBranches(workspace.requireId(), repoGithubId)
|
|
159
|
+
branches.value = { ...branches.value, [repoGithubId]: list }
|
|
160
|
+
return list
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The URL a workspace owner visits to install the App against this workspace. */
|
|
164
|
+
function getInstallUrl(): Promise<string> {
|
|
165
|
+
return api.getGitHubInstallUrl(workspace.requireId()).then((r) => r.url)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Discover the App's installations so the user can connect one without typing an id. */
|
|
169
|
+
async function loadInstallations() {
|
|
170
|
+
loadingInstallations.value = true
|
|
171
|
+
try {
|
|
172
|
+
const { installations: list } = await api.listGitHubInstallations(workspace.requireId())
|
|
173
|
+
installations.value = list
|
|
174
|
+
} finally {
|
|
175
|
+
loadingInstallations.value = false
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Programmatic bind by installation id (the browser flow uses the redirect). */
|
|
180
|
+
async function connect(installationId: number) {
|
|
181
|
+
connection.value = await api.connectGitHub(workspace.requireId(), installationId)
|
|
182
|
+
available.value = true
|
|
183
|
+
await load()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function disconnect() {
|
|
187
|
+
await api.disconnectGitHub(workspace.requireId())
|
|
188
|
+
connection.value = null
|
|
189
|
+
repos.value = []
|
|
190
|
+
availableRepos.value = []
|
|
191
|
+
pulls.value = []
|
|
192
|
+
issues.value = []
|
|
193
|
+
branches.value = {}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Trigger a resync, then refresh projections (no-op for queued/backfill). */
|
|
197
|
+
async function resync(body: ResyncRequest = {}) {
|
|
198
|
+
syncing.value = true
|
|
199
|
+
try {
|
|
200
|
+
const res = await api.resyncGitHub(workspace.requireId(), body)
|
|
201
|
+
await load()
|
|
202
|
+
return res
|
|
203
|
+
} finally {
|
|
204
|
+
syncing.value = false
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---- repo writes ----------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Create a repository under the connected account (privileged App tier). Only
|
|
212
|
+
* meaningful when `canCreateRepos`; the backend 409s otherwise. Returns the
|
|
213
|
+
* created repo so the caller can confirm/link it.
|
|
214
|
+
*/
|
|
215
|
+
function createRepo(input: CreateRepoRequest) {
|
|
216
|
+
return api.createGitHubRepo(workspace.requireId(), input)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function createBranch(repoGithubId: number, input: CreateBranchInput) {
|
|
220
|
+
const branch = await api.createGitHubBranch(workspace.requireId(), repoGithubId, input)
|
|
221
|
+
const next = branches.value[repoGithubId] ?? []
|
|
222
|
+
branches.value = { ...branches.value, [repoGithubId]: [branch, ...next] }
|
|
223
|
+
return branch
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function openPullRequest(repoGithubId: number, input: OpenPullRequestInput) {
|
|
227
|
+
const pr = await api.openGitHubPullRequest(workspace.requireId(), repoGithubId, input)
|
|
228
|
+
const i = pulls.value.findIndex(
|
|
229
|
+
(p) => p.repoGithubId === pr.repoGithubId && p.number === pr.number,
|
|
230
|
+
)
|
|
231
|
+
if (i >= 0) pulls.value[i] = pr
|
|
232
|
+
else pulls.value.unshift(pr)
|
|
233
|
+
return pr
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function mergePullRequest(
|
|
237
|
+
repoGithubId: number,
|
|
238
|
+
number: number,
|
|
239
|
+
input: MergePullRequestInput = {},
|
|
240
|
+
) {
|
|
241
|
+
await api.mergeGitHubPullRequest(workspace.requireId(), repoGithubId, number, input)
|
|
242
|
+
// Optimistically reflect the merge until the next sync confirms it.
|
|
243
|
+
const i = pulls.value.findIndex((p) => p.repoGithubId === repoGithubId && p.number === number)
|
|
244
|
+
if (i >= 0) pulls.value[i] = { ...pulls.value[i]!, state: 'closed', merged: true }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function comment(repoGithubId: number, number: number, body: string) {
|
|
248
|
+
return api.commentGitHubIssue(workspace.requireId(), repoGithubId, number, body)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
available,
|
|
253
|
+
connection,
|
|
254
|
+
installations,
|
|
255
|
+
loadingInstallations,
|
|
256
|
+
repos,
|
|
257
|
+
availableRepos,
|
|
258
|
+
loadingAvailable,
|
|
259
|
+
savingRepos,
|
|
260
|
+
pulls,
|
|
261
|
+
issues,
|
|
262
|
+
branches,
|
|
263
|
+
loading,
|
|
264
|
+
syncing,
|
|
265
|
+
connected,
|
|
266
|
+
canCreateRepos,
|
|
267
|
+
repoFor,
|
|
268
|
+
repoForBlock,
|
|
269
|
+
pullsForRepo,
|
|
270
|
+
issuesForRepo,
|
|
271
|
+
repoUrl,
|
|
272
|
+
pullUrl,
|
|
273
|
+
issueUrl,
|
|
274
|
+
probe,
|
|
275
|
+
load,
|
|
276
|
+
ensureLoaded,
|
|
277
|
+
loadAvailableRepos,
|
|
278
|
+
setLinkedRepos,
|
|
279
|
+
loadBranches,
|
|
280
|
+
getInstallUrl,
|
|
281
|
+
loadInstallations,
|
|
282
|
+
connect,
|
|
283
|
+
disconnect,
|
|
284
|
+
resync,
|
|
285
|
+
createRepo,
|
|
286
|
+
createBranch,
|
|
287
|
+
openPullRequest,
|
|
288
|
+
mergePullRequest,
|
|
289
|
+
comment,
|
|
290
|
+
}
|
|
291
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
|
+
import type { ModelOption } from '~/types/domain'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The model picker catalog. Served by `GET /models`, where each model is already
|
|
7
|
+
* resolved to the flavour in use for this deployment (direct when the provider's
|
|
8
|
+
* key is configured, else the Cloudflare fallback). Fetched once and cached for
|
|
9
|
+
* the per-block picker, and used to label which model produced a step's output.
|
|
10
|
+
*/
|
|
11
|
+
export const useModelsStore = defineStore('models', () => {
|
|
12
|
+
const api = useApi()
|
|
13
|
+
const models = ref<ModelOption[]>([])
|
|
14
|
+
const loaded = ref(false)
|
|
15
|
+
|
|
16
|
+
/** Fetch the catalog once; subsequent calls are no-ops. */
|
|
17
|
+
async function ensureLoaded() {
|
|
18
|
+
if (loaded.value) return
|
|
19
|
+
models.value = await api.getModels()
|
|
20
|
+
loaded.value = true
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const byId = computed(() => {
|
|
24
|
+
const map = new Map<string, ModelOption>()
|
|
25
|
+
for (const m of models.value) map.set(m.id, m)
|
|
26
|
+
return map
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
function getModel(id: string | undefined) {
|
|
30
|
+
return id ? byId.value.get(id) : undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Friendly label for a recorded `provider:model` identifier (as carried on a
|
|
35
|
+
* pipeline step). Matches it against the catalog's effective refs; falls back
|
|
36
|
+
* to the bare model id for anything not in the catalog (e.g. a pinned override).
|
|
37
|
+
*/
|
|
38
|
+
function labelForRef(ref: string | undefined): string | undefined {
|
|
39
|
+
if (!ref) return undefined
|
|
40
|
+
const idx = ref.indexOf(':')
|
|
41
|
+
const provider = idx === -1 ? ref : ref.slice(0, idx)
|
|
42
|
+
const model = idx === -1 ? '' : ref.slice(idx + 1)
|
|
43
|
+
const hit = models.value.find((m) => m.provider === provider && m.model === model)
|
|
44
|
+
return hit ? `${hit.label} · ${hit.providerLabel}` : model || ref
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { models, loaded, ensureLoaded, byId, getModel, labelForRef }
|
|
48
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { AgentKind, Pipeline } from '~/types/domain'
|
|
4
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Saved, reusable pipelines (the pipeline palette) plus the in-progress draft
|
|
8
|
+
* being assembled in the pipeline builder. Saved pipelines live on the backend;
|
|
9
|
+
* the draft is transient client state.
|
|
10
|
+
*/
|
|
11
|
+
export const usePipelinesStore = defineStore('pipelines', () => {
|
|
12
|
+
const api = useApi()
|
|
13
|
+
const pipelines = ref<Pipeline[]>([])
|
|
14
|
+
|
|
15
|
+
/** The chain currently being assembled in the builder. */
|
|
16
|
+
const draft = ref<AgentKind[]>([])
|
|
17
|
+
const draftName = ref('New pipeline')
|
|
18
|
+
|
|
19
|
+
/** Replace the cached pipelines with a server snapshot. */
|
|
20
|
+
function hydrate(next: Pipeline[]) {
|
|
21
|
+
pipelines.value = next
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getPipeline(id: string) {
|
|
25
|
+
return pipelines.value.find((p) => p.id === id)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function addToDraft(kind: AgentKind) {
|
|
29
|
+
draft.value.push(kind)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function removeFromDraft(index: number) {
|
|
33
|
+
draft.value.splice(index, 1)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function moveInDraft(from: number, to: number) {
|
|
37
|
+
if (to < 0 || to >= draft.value.length) return
|
|
38
|
+
const [item] = draft.value.splice(from, 1)
|
|
39
|
+
if (item) draft.value.splice(to, 0, item)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function clearDraft() {
|
|
43
|
+
draft.value = []
|
|
44
|
+
draftName.value = 'New pipeline'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Persist the draft as a new pipeline on the backend. */
|
|
48
|
+
async function saveDraft(): Promise<Pipeline | null> {
|
|
49
|
+
if (draft.value.length === 0) return null
|
|
50
|
+
const pipeline = await api.createPipeline(useWorkspaceStore().requireId(), {
|
|
51
|
+
name: draftName.value.trim() || 'Untitled pipeline',
|
|
52
|
+
agentKinds: [...draft.value],
|
|
53
|
+
})
|
|
54
|
+
pipelines.value.push(pipeline)
|
|
55
|
+
clearDraft()
|
|
56
|
+
return pipeline
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function removePipeline(id: string) {
|
|
60
|
+
await api.removePipeline(useWorkspaceStore().requireId(), id)
|
|
61
|
+
pipelines.value = pipelines.value.filter((p) => p.id !== id)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
pipelines,
|
|
66
|
+
draft,
|
|
67
|
+
draftName,
|
|
68
|
+
hydrate,
|
|
69
|
+
getPipeline,
|
|
70
|
+
addToDraft,
|
|
71
|
+
removeFromDraft,
|
|
72
|
+
moveInDraft,
|
|
73
|
+
clearDraft,
|
|
74
|
+
saveDraft,
|
|
75
|
+
removePipeline,
|
|
76
|
+
}
|
|
77
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { RequirementReview, ReviewItemStatus } from '~/types/requirements'
|
|
4
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
|
+
import { useBoardStore } from '~/stores/board'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Requirements-review state: the stateless reviewer agent's findings per block.
|
|
9
|
+
* A review is generated synchronously (the LLM runs inline server-side and the
|
|
10
|
+
* items come back in the response), so — unlike executions/bootstraps — there is
|
|
11
|
+
* no real-time stream; every mutation returns the updated review and we patch the
|
|
12
|
+
* local cache from it. `available` mirrors the backend's opt-in gate: a 503 from
|
|
13
|
+
* the review probe means the feature is off and the UI hides its entry points.
|
|
14
|
+
* Per-workspace; nothing is persisted client-side.
|
|
15
|
+
*/
|
|
16
|
+
export const useRequirementsStore = defineStore('requirements', () => {
|
|
17
|
+
const api = useApi()
|
|
18
|
+
const workspace = useWorkspaceStore()
|
|
19
|
+
const board = useBoardStore()
|
|
20
|
+
|
|
21
|
+
/** null = unknown (not probed), true/false = feature on/off. */
|
|
22
|
+
const available = ref<boolean | null>(null)
|
|
23
|
+
/** The current review per block id (null = fetched, none exists). */
|
|
24
|
+
const reviews = ref<Record<string, RequirementReview | null>>({})
|
|
25
|
+
/** Block ids whose review is being (re)generated. */
|
|
26
|
+
const reviewing = ref<Set<string>>(new Set())
|
|
27
|
+
/** Review ids currently incorporating their answers. */
|
|
28
|
+
const incorporating = ref<Set<string>>(new Set())
|
|
29
|
+
|
|
30
|
+
function reviewFor(blockId: string): RequirementReview | null {
|
|
31
|
+
return reviews.value[blockId] ?? null
|
|
32
|
+
}
|
|
33
|
+
function isReviewing(blockId: string): boolean {
|
|
34
|
+
return reviewing.value.has(blockId)
|
|
35
|
+
}
|
|
36
|
+
function isIncorporating(reviewId: string): boolean {
|
|
37
|
+
return incorporating.value.has(reviewId)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Open items still needing a human (everything not resolved/dismissed). */
|
|
41
|
+
function openCount(review: RequirementReview): number {
|
|
42
|
+
return review.items.filter((i) => i.status !== 'resolved' && i.status !== 'dismissed').length
|
|
43
|
+
}
|
|
44
|
+
/** Whether every item is settled, so the answers can be incorporated. */
|
|
45
|
+
function allSettled(review: RequirementReview): boolean {
|
|
46
|
+
return review.items.length > 0 && openCount(review) === 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function store(review: RequirementReview) {
|
|
50
|
+
reviews.value = { ...reviews.value, [review.blockId]: review }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function withFlag(set: typeof reviewing, key: string, on: boolean) {
|
|
54
|
+
const next = new Set(set.value)
|
|
55
|
+
if (on) next.add(key)
|
|
56
|
+
else next.delete(key)
|
|
57
|
+
set.value = next
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Fetch the current review for a block (probing the feature's availability). */
|
|
61
|
+
async function load(blockId: string) {
|
|
62
|
+
if (!workspace.workspaceId) return
|
|
63
|
+
try {
|
|
64
|
+
const review = await api.getRequirementReview(workspace.requireId(), blockId)
|
|
65
|
+
available.value = true
|
|
66
|
+
reviews.value = { ...reviews.value, [blockId]: review }
|
|
67
|
+
} catch {
|
|
68
|
+
// 503 (feature off) or any error → hide the UI entry points.
|
|
69
|
+
available.value = false
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Run a fresh review of a block's collected requirements. */
|
|
74
|
+
async function review(blockId: string): Promise<RequirementReview> {
|
|
75
|
+
withFlag(reviewing, blockId, true)
|
|
76
|
+
try {
|
|
77
|
+
const result = await api.reviewRequirements(workspace.requireId(), blockId)
|
|
78
|
+
available.value = true
|
|
79
|
+
store(result)
|
|
80
|
+
return result
|
|
81
|
+
} finally {
|
|
82
|
+
withFlag(reviewing, blockId, false)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Record a human's answer to one item. */
|
|
87
|
+
async function reply(review: RequirementReview, itemId: string, text: string) {
|
|
88
|
+
store(await api.replyRequirementItem(workspace.requireId(), review.id, itemId, text))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Set an item's status (resolve / dismiss / reopen). */
|
|
92
|
+
async function setItemStatus(
|
|
93
|
+
review: RequirementReview,
|
|
94
|
+
itemId: string,
|
|
95
|
+
status: ReviewItemStatus,
|
|
96
|
+
) {
|
|
97
|
+
store(await api.setRequirementItemStatus(workspace.requireId(), review.id, itemId, status))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Fold the answers back into the block's requirements. Patches the board with
|
|
102
|
+
* the returned (rewritten) block so the inspector/description reflect it.
|
|
103
|
+
*/
|
|
104
|
+
async function incorporate(review: RequirementReview) {
|
|
105
|
+
withFlag(incorporating, review.id, true)
|
|
106
|
+
try {
|
|
107
|
+
const { review: updated, block } = await api.incorporateRequirements(
|
|
108
|
+
workspace.requireId(),
|
|
109
|
+
review.id,
|
|
110
|
+
)
|
|
111
|
+
store(updated)
|
|
112
|
+
board.upsert(block)
|
|
113
|
+
return { review: updated, block }
|
|
114
|
+
} finally {
|
|
115
|
+
withFlag(incorporating, review.id, false)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
available,
|
|
121
|
+
reviews,
|
|
122
|
+
reviewFor,
|
|
123
|
+
isReviewing,
|
|
124
|
+
isIncorporating,
|
|
125
|
+
openCount,
|
|
126
|
+
allSettled,
|
|
127
|
+
load,
|
|
128
|
+
review,
|
|
129
|
+
reply,
|
|
130
|
+
setItemStatus,
|
|
131
|
+
incorporate,
|
|
132
|
+
}
|
|
133
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { useScenariosStore } from '~/stores/scenarios'
|
|
3
|
+
|
|
4
|
+
describe('scenarios store', () => {
|
|
5
|
+
let store: ReturnType<typeof useScenariosStore>
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
store = useScenariosStore()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('drafts the standard set of scenarios for a feature', () => {
|
|
11
|
+
const created = store.generateForFeature('Login')
|
|
12
|
+
expect(created).toHaveLength(3)
|
|
13
|
+
expect(store.scenariosForFeature('Login')).toHaveLength(3)
|
|
14
|
+
// Each is a Given/When/Then with the feature folded in.
|
|
15
|
+
const happy = created[0]!
|
|
16
|
+
expect(happy.feature).toBe('Login')
|
|
17
|
+
expect(happy.when.join(' ')).toContain('Login')
|
|
18
|
+
expect(happy.then.length).toBeGreaterThan(0)
|
|
19
|
+
expect(happy.source).toBe('generated')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('is additive: re-drafting only fills gaps and never duplicates', () => {
|
|
23
|
+
store.generateForFeature('Login')
|
|
24
|
+
const again = store.generateForFeature('Login')
|
|
25
|
+
expect(again).toHaveLength(0)
|
|
26
|
+
expect(store.scenariosForFeature('Login')).toHaveLength(3)
|
|
27
|
+
|
|
28
|
+
// A removed scenario is re-created on the next draft, the rest are kept.
|
|
29
|
+
const removed = store.scenariosForFeature('Login')[1]!
|
|
30
|
+
store.removeScenario(removed.id)
|
|
31
|
+
const refilled = store.generateForFeature('Login')
|
|
32
|
+
expect(refilled).toHaveLength(1)
|
|
33
|
+
expect(store.scenariosForFeature('Login')).toHaveLength(3)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('matches features case- and whitespace-insensitively', () => {
|
|
37
|
+
store.generateForFeature('User Login')
|
|
38
|
+
expect(store.hasScenarios('user login')).toBe(true)
|
|
39
|
+
expect(store.scenariosForFeature('USER LOGIN')).toHaveLength(3)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('folds linked requirements into the generated Given', () => {
|
|
43
|
+
const [happy] = store.generateForFeature('Checkout', { requirements: ['Payments PRD'] })
|
|
44
|
+
expect(happy!.given.join(' ')).toContain('Payments PRD')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('collects scenarios across all of a block features', () => {
|
|
48
|
+
store.generateForFeature('Login')
|
|
49
|
+
store.generateForFeature('Logout')
|
|
50
|
+
const forBlock = store.scenariosForBlock({ features: ['Login', 'Logout'] })
|
|
51
|
+
expect(forBlock).toHaveLength(6)
|
|
52
|
+
expect(store.scenariosForBlock({ features: [] })).toHaveLength(0)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('generates Playwright tests only for scenarios that lack one (idempotent)', () => {
|
|
56
|
+
store.generateForFeature('Login')
|
|
57
|
+
expect(store.untested('Login')).toBe(3)
|
|
58
|
+
|
|
59
|
+
const first = store.generatePlaywrightTests('Login')
|
|
60
|
+
expect(first).toHaveLength(3)
|
|
61
|
+
expect(store.untested('Login')).toBe(0)
|
|
62
|
+
expect(store.scenariosForFeature('Login').every((s) => s.hasPlaywrightTest)).toBe(true)
|
|
63
|
+
|
|
64
|
+
// Re-running creates nothing new...
|
|
65
|
+
expect(store.generatePlaywrightTests('Login')).toHaveLength(0)
|
|
66
|
+
|
|
67
|
+
// ...but a freshly added scenario does get a test on the next run.
|
|
68
|
+
store.addScenario({ feature: 'Login', title: 'Login: remember me' })
|
|
69
|
+
expect(store.untested('Login')).toBe(1)
|
|
70
|
+
expect(store.generatePlaywrightTests('Login')).toHaveLength(1)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('edits and removes scenarios', () => {
|
|
74
|
+
const scenario = store.addScenario({ feature: 'Login', title: 'Draft' })
|
|
75
|
+
store.updateScenario(scenario.id, { title: 'Renamed', status: 'approved' })
|
|
76
|
+
expect(store.scenariosForFeature('Login')[0]!.title).toBe('Renamed')
|
|
77
|
+
expect(store.scenariosForFeature('Login')[0]!.status).toBe('approved')
|
|
78
|
+
|
|
79
|
+
store.removeScenario(scenario.id)
|
|
80
|
+
expect(store.scenariosForFeature('Login')).toHaveLength(0)
|
|
81
|
+
})
|
|
82
|
+
})
|