@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,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
+ })