@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,535 @@
1
+ import type {
2
+ Account,
3
+ AccountMember,
4
+ AddMemberInput,
5
+ AgentRunKind,
6
+ AuthUser,
7
+ Block,
8
+ BlockType,
9
+ BootstrapJob,
10
+ BootstrapRepoInput,
11
+ DocumentBoardPlan,
12
+ DocumentConnection,
13
+ DocumentSourceDescriptor,
14
+ DocumentSourceKind,
15
+ SourceDocument,
16
+ CreateReferenceArchitectureInput,
17
+ ExecutionInstance,
18
+ CommitFilesInput,
19
+ CreateBranchInput,
20
+ CreatedRepo,
21
+ CreateRepoRequest,
22
+ GitHubAvailableRepo,
23
+ GitHubBranch,
24
+ GitHubConnection,
25
+ GitHubInstallationOption,
26
+ GitHubIssue,
27
+ GitHubPullRequest,
28
+ GitHubRepo,
29
+ MergePullRequestInput,
30
+ ModelOption,
31
+ OpenPullRequestInput,
32
+ Pipeline,
33
+ PromptFragment,
34
+ CreatePromptFragmentInput,
35
+ UpdatePromptFragmentInput,
36
+ ResolvedFragment,
37
+ FragmentOwnerKind,
38
+ FragmentSource,
39
+ LinkFragmentSourceInput,
40
+ FragmentSourceStatus,
41
+ FragmentSyncResult,
42
+ ReferenceArchitecture,
43
+ ResyncRequest,
44
+ SpawnResult,
45
+ TaskConnection,
46
+ TaskSourceDescriptor,
47
+ TaskSourceKind,
48
+ SourceTask,
49
+ UpdateReferenceArchitectureInput,
50
+ Workspace,
51
+ WorkspaceSnapshot,
52
+ } from '~/types/domain'
53
+ import type { RequirementReview, ReviewItemStatus } from '~/types/requirements'
54
+
55
+ type Position = { x: number; y: number }
56
+
57
+ /**
58
+ * Thin typed client over the cat-factory backend (a Hono worker). Every method
59
+ * maps to one REST endpoint; the request/response shapes mirror
60
+ * `@cat-factory/contracts`, so responses drop straight into the Pinia stores.
61
+ *
62
+ * The base URL comes from runtime config (`NUXT_PUBLIC_API_BASE`), defaulting to
63
+ * the local wrangler dev server — see `nuxt.config.ts`.
64
+ */
65
+ export function useApi() {
66
+ const apiBase = useRuntimeConfig().public.apiBase
67
+ const http = $fetch.create({
68
+ baseURL: apiBase,
69
+ // Attach the session token (when signed in) so the backend's auth gate lets
70
+ // the request through. Read lazily from the store so a fresh token applies
71
+ // without rebuilding the client.
72
+ onRequest({ options }) {
73
+ const token = useAuthStore().token
74
+ if (!token) return
75
+ const headers = new Headers(options.headers)
76
+ headers.set('Authorization', `Bearer ${token}`)
77
+ options.headers = headers
78
+ },
79
+ // A 401 means our token lapsed or was revoked — drop it so the UI re-gates.
80
+ onResponseError({ response }) {
81
+ if (response?.status === 401) useAuthStore().handleUnauthorized()
82
+ },
83
+ })
84
+
85
+ const ws = (workspaceId: string) => `/workspaces/${encodeURIComponent(workspaceId)}`
86
+ // Prompt-fragment library routes exist at both tiers; resolve the prefix from
87
+ // the owner scope (ADR 0006 §8).
88
+ const scope = (kind: FragmentOwnerKind, id: string) =>
89
+ kind === 'account'
90
+ ? `/accounts/${encodeURIComponent(id)}`
91
+ : `/workspaces/${encodeURIComponent(id)}`
92
+
93
+ return {
94
+ // ---- auth -------------------------------------------------------------
95
+ getAuthConfig: () => http<{ enabled: boolean }>('/auth/config'),
96
+
97
+ getMe: () => http<{ user: AuthUser | null; enabled: boolean }>('/auth/me'),
98
+
99
+ logout: () => http('/auth/logout', { method: 'POST' }),
100
+
101
+ // Mint a short-lived, workspace-scoped ticket for the events WebSocket. A
102
+ // browser can't set Authorization on a WS handshake, so the socket auths from
103
+ // this `?ticket=` instead of the long-lived session token. Empty string when
104
+ // auth is disabled (dev) — the handshake is open in that case.
105
+ mintEventsTicket: (workspaceId: string) =>
106
+ http<{ ticket: string; expiresInMs?: number }>(`${ws(workspaceId)}/events/ticket`, {
107
+ method: 'POST',
108
+ }),
109
+
110
+ // ---- prompt fragments (best-practice catalog) -------------------------
111
+ getPromptFragments: () => http<PromptFragment[]>('/prompt-fragments'),
112
+
113
+ // ---- prompt-fragment library (managed, tenant-scoped; ADR 0006) -------
114
+ // The merged catalog an agent actually sees for a board (builtin∪account∪ws).
115
+ getResolvedFragments: (workspaceId: string) =>
116
+ http<ResolvedFragment[]>(`${ws(workspaceId)}/prompt-fragments/resolved`),
117
+
118
+ // Per-tier management (scope = account or workspace).
119
+ listFragments: (kind: FragmentOwnerKind, id: string) =>
120
+ http<PromptFragment[]>(`${scope(kind, id)}/prompt-fragments`),
121
+
122
+ createFragment: (kind: FragmentOwnerKind, id: string, body: CreatePromptFragmentInput) =>
123
+ http<PromptFragment>(`${scope(kind, id)}/prompt-fragments`, { method: 'POST', body }),
124
+
125
+ updateFragment: (
126
+ kind: FragmentOwnerKind,
127
+ id: string,
128
+ fragmentId: string,
129
+ body: UpdatePromptFragmentInput,
130
+ ) =>
131
+ http<PromptFragment>(
132
+ `${scope(kind, id)}/prompt-fragments/${encodeURIComponent(fragmentId)}`,
133
+ { method: 'PATCH', body },
134
+ ),
135
+
136
+ deleteFragment: (kind: FragmentOwnerKind, id: string, fragmentId: string) =>
137
+ http(`${scope(kind, id)}/prompt-fragments/${encodeURIComponent(fragmentId)}`, {
138
+ method: 'DELETE',
139
+ }),
140
+
141
+ // Repo sources of guideline Markdown.
142
+ listFragmentSources: (kind: FragmentOwnerKind, id: string) =>
143
+ http<FragmentSource[]>(`${scope(kind, id)}/fragment-sources`),
144
+
145
+ linkFragmentSource: (kind: FragmentOwnerKind, id: string, body: LinkFragmentSourceInput) =>
146
+ http<FragmentSource>(`${scope(kind, id)}/fragment-sources`, { method: 'POST', body }),
147
+
148
+ unlinkFragmentSource: (kind: FragmentOwnerKind, id: string, sourceId: string) =>
149
+ http(`${scope(kind, id)}/fragment-sources/${encodeURIComponent(sourceId)}`, {
150
+ method: 'DELETE',
151
+ }),
152
+
153
+ fragmentSourceStatus: (kind: FragmentOwnerKind, id: string, sourceId: string) =>
154
+ http<FragmentSourceStatus>(
155
+ `${scope(kind, id)}/fragment-sources/${encodeURIComponent(sourceId)}/status`,
156
+ ),
157
+
158
+ syncFragmentSource: (kind: FragmentOwnerKind, id: string, sourceId: string) =>
159
+ http<FragmentSyncResult>(
160
+ `${scope(kind, id)}/fragment-sources/${encodeURIComponent(sourceId)}/sync`,
161
+ { method: 'POST' },
162
+ ),
163
+
164
+ // ---- model picker catalog (effective per-deployment flavours) ---------
165
+ getModels: () => http<ModelOption[]>('/models'),
166
+
167
+ // ---- accounts (tenancy) -----------------------------------------------
168
+ // The accounts the user can switch between (personal + orgs), org creation
169
+ // and membership management. Empty when auth is disabled (dev).
170
+ listAccounts: () => http<Account[]>('/accounts'),
171
+
172
+ createAccount: (body: { name: string; githubAccountLogin?: string }) =>
173
+ http<Account>('/accounts', { method: 'POST', body }),
174
+
175
+ listAccountMembers: (accountId: string) =>
176
+ http<AccountMember[]>(`/accounts/${encodeURIComponent(accountId)}/members`),
177
+
178
+ addAccountMember: (accountId: string, body: AddMemberInput) =>
179
+ http<AccountMember>(`/accounts/${encodeURIComponent(accountId)}/members`, {
180
+ method: 'POST',
181
+ body,
182
+ }),
183
+
184
+ // ---- workspaces -------------------------------------------------------
185
+ listWorkspaces: () => http<Workspace[]>('/workspaces'),
186
+
187
+ createWorkspace: (body: { name?: string; seed?: boolean; accountId?: string } = {}) =>
188
+ http<WorkspaceSnapshot>('/workspaces', { method: 'POST', body }),
189
+
190
+ getWorkspace: (workspaceId: string) => http<WorkspaceSnapshot>(ws(workspaceId)),
191
+
192
+ renameWorkspace: (workspaceId: string, name: string) =>
193
+ http<Workspace>(ws(workspaceId), { method: 'PATCH', body: { name } }),
194
+
195
+ deleteWorkspace: (workspaceId: string) => http(ws(workspaceId), { method: 'DELETE' }),
196
+
197
+ // ---- blocks -----------------------------------------------------------
198
+ addFrame: (workspaceId: string, body: { type: BlockType; position: Position }) =>
199
+ http<Block>(`${ws(workspaceId)}/blocks`, { method: 'POST', body }),
200
+
201
+ addTask: (workspaceId: string, blockId: string, body: { title?: string } = {}) =>
202
+ http<Block>(`${ws(workspaceId)}/blocks/${blockId}/tasks`, { method: 'POST', body }),
203
+
204
+ addModule: (
205
+ workspaceId: string,
206
+ blockId: string,
207
+ body: { name: string; position?: Position },
208
+ ) => http<Block>(`${ws(workspaceId)}/blocks/${blockId}/modules`, { method: 'POST', body }),
209
+
210
+ updateBlock: (workspaceId: string, blockId: string, body: Partial<Block>) =>
211
+ http<Block>(`${ws(workspaceId)}/blocks/${blockId}`, { method: 'PATCH', body }),
212
+
213
+ moveBlock: (workspaceId: string, blockId: string, body: { position: Position }) =>
214
+ http<Block>(`${ws(workspaceId)}/blocks/${blockId}/move`, { method: 'POST', body }),
215
+
216
+ reparentBlock: (
217
+ workspaceId: string,
218
+ blockId: string,
219
+ body: { parentId: string; position: Position },
220
+ ) => http<Block>(`${ws(workspaceId)}/blocks/${blockId}/reparent`, { method: 'POST', body }),
221
+
222
+ removeBlock: (workspaceId: string, blockId: string) =>
223
+ http(`${ws(workspaceId)}/blocks/${blockId}`, { method: 'DELETE' }),
224
+
225
+ toggleDependency: (workspaceId: string, blockId: string, body: { sourceId: string }) =>
226
+ http<Block>(`${ws(workspaceId)}/blocks/${blockId}/dependencies`, { method: 'POST', body }),
227
+
228
+ // ---- pipelines --------------------------------------------------------
229
+ listPipelines: (workspaceId: string) => http<Pipeline[]>(`${ws(workspaceId)}/pipelines`),
230
+
231
+ createPipeline: (workspaceId: string, body: { name: string; agentKinds: string[] }) =>
232
+ http<Pipeline>(`${ws(workspaceId)}/pipelines`, { method: 'POST', body }),
233
+
234
+ removePipeline: (workspaceId: string, pipelineId: string) =>
235
+ http(`${ws(workspaceId)}/pipelines/${pipelineId}`, { method: 'DELETE' }),
236
+
237
+ // ---- executions -------------------------------------------------------
238
+ startExecution: (workspaceId: string, blockId: string, body: { pipelineId: string }) =>
239
+ http<ExecutionInstance>(`${ws(workspaceId)}/blocks/${blockId}/executions`, {
240
+ method: 'POST',
241
+ body,
242
+ }),
243
+
244
+ cancelExecution: (workspaceId: string, blockId: string) =>
245
+ http<Block>(`${ws(workspaceId)}/blocks/${blockId}/executions`, { method: 'DELETE' }),
246
+
247
+ mergeBlock: (workspaceId: string, blockId: string) =>
248
+ http<Block>(`${ws(workspaceId)}/blocks/${blockId}/merge`, { method: 'POST' }),
249
+
250
+ resolveDecision: (
251
+ workspaceId: string,
252
+ executionId: string,
253
+ decisionId: string,
254
+ body: { choice: string },
255
+ ) =>
256
+ http<ExecutionInstance>(
257
+ `${ws(workspaceId)}/executions/${executionId}/decisions/${decisionId}`,
258
+ { method: 'POST', body },
259
+ ),
260
+
261
+ // ---- spend safeguard --------------------------------------------------
262
+ resumeSpend: (workspaceId: string) =>
263
+ http<ExecutionInstance[]>(`${ws(workspaceId)}/spend/resume`, { method: 'POST' }),
264
+
265
+ // ---- document sources (Confluence, Notion, …) -------------------------
266
+ // The configured sources + their connect/import metadata. A 503 means the
267
+ // integration is off (the store hides its UI on any error here).
268
+ listDocumentSources: (workspaceId: string) =>
269
+ http<{ sources: DocumentSourceDescriptor[] }>(`${ws(workspaceId)}/document-sources`),
270
+
271
+ listDocumentConnections: (workspaceId: string) =>
272
+ http<{ connections: DocumentConnection[] }>(
273
+ `${ws(workspaceId)}/document-sources/connections`,
274
+ ),
275
+
276
+ connectDocumentSource: (
277
+ workspaceId: string,
278
+ source: DocumentSourceKind,
279
+ credentials: Record<string, string>,
280
+ ) =>
281
+ http<DocumentConnection>(`${ws(workspaceId)}/document-sources/${source}/connect`, {
282
+ method: 'POST',
283
+ body: { credentials },
284
+ }),
285
+
286
+ disconnectDocumentSource: (workspaceId: string, source: DocumentSourceKind) =>
287
+ http(`${ws(workspaceId)}/document-sources/${source}/connection`, { method: 'DELETE' }),
288
+
289
+ listDocuments: (workspaceId: string) => http<SourceDocument[]>(`${ws(workspaceId)}/documents`),
290
+
291
+ importDocument: (workspaceId: string, source: DocumentSourceKind, body: { ref: string }) =>
292
+ http<SourceDocument>(`${ws(workspaceId)}/document-sources/${source}/import`, {
293
+ method: 'POST',
294
+ body,
295
+ }),
296
+
297
+ planDocument: (workspaceId: string, source: DocumentSourceKind, externalId: string) =>
298
+ http<DocumentBoardPlan>(`${ws(workspaceId)}/document-sources/${source}/plan`, {
299
+ method: 'POST',
300
+ body: { externalId },
301
+ }),
302
+
303
+ spawnDocument: (
304
+ workspaceId: string,
305
+ source: DocumentSourceKind,
306
+ body: { externalId: string; frameId?: string },
307
+ ) =>
308
+ http<{ plan: DocumentBoardPlan; result: SpawnResult }>(
309
+ `${ws(workspaceId)}/document-sources/${source}/spawn`,
310
+ { method: 'POST', body },
311
+ ),
312
+
313
+ linkDocument: (
314
+ workspaceId: string,
315
+ body: { source: DocumentSourceKind; externalId: string; blockId: string },
316
+ ) => http<SourceDocument>(`${ws(workspaceId)}/documents/link`, { method: 'POST', body }),
317
+
318
+ // ---- task sources (Jira, …) ------------------------------------------
319
+ // The configured trackers + their connect/import metadata. A 503 means the
320
+ // integration is off (the store hides its UI on any error here).
321
+ listTaskSources: (workspaceId: string) =>
322
+ http<{ sources: TaskSourceDescriptor[] }>(`${ws(workspaceId)}/task-sources`),
323
+
324
+ listTaskConnections: (workspaceId: string) =>
325
+ http<{ connections: TaskConnection[] }>(`${ws(workspaceId)}/task-sources/connections`),
326
+
327
+ connectTaskSource: (
328
+ workspaceId: string,
329
+ source: TaskSourceKind,
330
+ credentials: Record<string, string>,
331
+ ) =>
332
+ http<TaskConnection>(`${ws(workspaceId)}/task-sources/${source}/connect`, {
333
+ method: 'POST',
334
+ body: { credentials },
335
+ }),
336
+
337
+ disconnectTaskSource: (workspaceId: string, source: TaskSourceKind) =>
338
+ http(`${ws(workspaceId)}/task-sources/${source}/connection`, { method: 'DELETE' }),
339
+
340
+ listTasks: (workspaceId: string) => http<SourceTask[]>(`${ws(workspaceId)}/tasks`),
341
+
342
+ importTask: (workspaceId: string, source: TaskSourceKind, body: { ref: string }) =>
343
+ http<SourceTask>(`${ws(workspaceId)}/task-sources/${source}/import`, {
344
+ method: 'POST',
345
+ body,
346
+ }),
347
+
348
+ linkTask: (
349
+ workspaceId: string,
350
+ body: { source: TaskSourceKind; externalId: string; blockId: string },
351
+ ) => http<SourceTask>(`${ws(workspaceId)}/tasks/link`, { method: 'POST', body }),
352
+
353
+ // ---- requirements review (stateless reviewer agent) ------------------
354
+ // The current review for a block (null when none has been run). A 503 means
355
+ // the feature is unconfigured (the panel hides on any error here).
356
+ getRequirementReview: (workspaceId: string, blockId: string) =>
357
+ http<RequirementReview | null>(
358
+ `${ws(workspaceId)}/blocks/${encodeURIComponent(blockId)}/requirement-review`,
359
+ ),
360
+
361
+ // Run a fresh review (synchronous — the LLM runs inline and returns the items).
362
+ reviewRequirements: (workspaceId: string, blockId: string) =>
363
+ http<RequirementReview>(
364
+ `${ws(workspaceId)}/blocks/${encodeURIComponent(blockId)}/requirement-review`,
365
+ { method: 'POST' },
366
+ ),
367
+
368
+ replyRequirementItem: (workspaceId: string, reviewId: string, itemId: string, reply: string) =>
369
+ http<RequirementReview>(
370
+ `${ws(workspaceId)}/requirement-reviews/${encodeURIComponent(reviewId)}/items/${encodeURIComponent(itemId)}/reply`,
371
+ { method: 'POST', body: { reply } },
372
+ ),
373
+
374
+ setRequirementItemStatus: (
375
+ workspaceId: string,
376
+ reviewId: string,
377
+ itemId: string,
378
+ status: ReviewItemStatus,
379
+ ) =>
380
+ http<RequirementReview>(
381
+ `${ws(workspaceId)}/requirement-reviews/${encodeURIComponent(reviewId)}/items/${encodeURIComponent(itemId)}`,
382
+ { method: 'PATCH', body: { status } },
383
+ ),
384
+
385
+ // Fold the answers back into the block's requirements (all items must be settled).
386
+ incorporateRequirements: (workspaceId: string, reviewId: string) =>
387
+ http<{ review: RequirementReview; block: Block }>(
388
+ `${ws(workspaceId)}/requirement-reviews/${encodeURIComponent(reviewId)}/incorporate`,
389
+ { method: 'POST' },
390
+ ),
391
+
392
+ // ---- github integration ----------------------------------------------
393
+ // Connection management, projection reads (served from D1 — fast and
394
+ // rate-limit-free) and repo writes. A 503 from `getGitHubConnection` means
395
+ // the integration is off (the store hides its UI on any error there).
396
+ getGitHubInstallUrl: (workspaceId: string) =>
397
+ http<{ url: string }>(`${ws(workspaceId)}/github/install-url`),
398
+
399
+ getGitHubConnection: (workspaceId: string) =>
400
+ http<{ connection: GitHubConnection | null }>(`${ws(workspaceId)}/github/connection`),
401
+
402
+ listGitHubInstallations: (workspaceId: string) =>
403
+ http<{ installations: GitHubInstallationOption[] }>(
404
+ `${ws(workspaceId)}/github/installations`,
405
+ ),
406
+
407
+ connectGitHub: (workspaceId: string, installationId: number) =>
408
+ http<GitHubConnection>(`${ws(workspaceId)}/github/connect`, {
409
+ method: 'POST',
410
+ body: { installationId },
411
+ }),
412
+
413
+ disconnectGitHub: (workspaceId: string) =>
414
+ http(`${ws(workspaceId)}/github/connection`, { method: 'DELETE' }),
415
+
416
+ resyncGitHub: (workspaceId: string, body: ResyncRequest = {}) =>
417
+ http<{ status: string }>(`${ws(workspaceId)}/github/resync`, { method: 'POST', body }),
418
+
419
+ listGitHubRepos: (workspaceId: string) => http<GitHubRepo[]>(`${ws(workspaceId)}/github/repos`),
420
+
421
+ // Programmatic repo creation (privileged App tier). Only called when the
422
+ // connection reports `canCreateRepos`; otherwise the UI opens GitHub directly.
423
+ createGitHubRepo: (workspaceId: string, body: CreateRepoRequest) =>
424
+ http<CreatedRepo>(`${ws(workspaceId)}/github/repos`, { method: 'POST', body }),
425
+
426
+ // Repos the connected installation can access, annotated with whether this
427
+ // workspace links each (drives the per-workspace repo picker).
428
+ listGitHubAvailableRepos: (workspaceId: string) =>
429
+ http<GitHubAvailableRepo[]>(`${ws(workspaceId)}/github/available-repos`),
430
+
431
+ // Set the exact set of repos this workspace links.
432
+ setGitHubLinkedRepos: (workspaceId: string, repoGithubIds: number[]) =>
433
+ http<GitHubRepo[]>(`${ws(workspaceId)}/github/repos`, {
434
+ method: 'PUT',
435
+ body: { repoGithubIds },
436
+ }),
437
+
438
+ listGitHubBranches: (workspaceId: string, repoGithubId: number) =>
439
+ http<GitHubBranch[]>(`${ws(workspaceId)}/github/repos/${repoGithubId}/branches`),
440
+
441
+ listGitHubPullRequests: (workspaceId: string) =>
442
+ http<GitHubPullRequest[]>(`${ws(workspaceId)}/github/pulls`),
443
+
444
+ listGitHubIssues: (workspaceId: string) =>
445
+ http<GitHubIssue[]>(`${ws(workspaceId)}/github/issues`),
446
+
447
+ createGitHubBranch: (workspaceId: string, repoGithubId: number, body: CreateBranchInput) =>
448
+ http<GitHubBranch>(`${ws(workspaceId)}/github/repos/${repoGithubId}/branches`, {
449
+ method: 'POST',
450
+ body,
451
+ }),
452
+
453
+ commitGitHubFiles: (workspaceId: string, repoGithubId: number, body: CommitFilesInput) =>
454
+ http<{ sha: string }>(`${ws(workspaceId)}/github/repos/${repoGithubId}/commits`, {
455
+ method: 'POST',
456
+ body,
457
+ }),
458
+
459
+ openGitHubPullRequest: (
460
+ workspaceId: string,
461
+ repoGithubId: number,
462
+ body: OpenPullRequestInput,
463
+ ) =>
464
+ http<GitHubPullRequest>(`${ws(workspaceId)}/github/repos/${repoGithubId}/pulls`, {
465
+ method: 'POST',
466
+ body,
467
+ }),
468
+
469
+ mergeGitHubPullRequest: (
470
+ workspaceId: string,
471
+ repoGithubId: number,
472
+ number: number,
473
+ body: MergePullRequestInput = {},
474
+ ) =>
475
+ http(`${ws(workspaceId)}/github/repos/${repoGithubId}/pulls/${number}/merge`, {
476
+ method: 'PUT',
477
+ body,
478
+ }),
479
+
480
+ commentGitHubIssue: (
481
+ workspaceId: string,
482
+ repoGithubId: number,
483
+ number: number,
484
+ bodyText: string,
485
+ ) =>
486
+ http(`${ws(workspaceId)}/github/repos/${repoGithubId}/issues/${number}/comments`, {
487
+ method: 'POST',
488
+ body: { body: bodyText },
489
+ }),
490
+
491
+ // ---- repo bootstrap ---------------------------------------------------
492
+ listReferenceArchitectures: (workspaceId: string) =>
493
+ http<ReferenceArchitecture[]>(`${ws(workspaceId)}/bootstrap/reference-architectures`),
494
+
495
+ createReferenceArchitecture: (workspaceId: string, body: CreateReferenceArchitectureInput) =>
496
+ http<ReferenceArchitecture>(`${ws(workspaceId)}/bootstrap/reference-architectures`, {
497
+ method: 'POST',
498
+ body,
499
+ }),
500
+
501
+ updateReferenceArchitecture: (
502
+ workspaceId: string,
503
+ id: string,
504
+ body: UpdateReferenceArchitectureInput,
505
+ ) =>
506
+ http<ReferenceArchitecture>(`${ws(workspaceId)}/bootstrap/reference-architectures/${id}`, {
507
+ method: 'PATCH',
508
+ body,
509
+ }),
510
+
511
+ deleteReferenceArchitecture: (workspaceId: string, id: string) =>
512
+ http(`${ws(workspaceId)}/bootstrap/reference-architectures/${id}`, { method: 'DELETE' }),
513
+
514
+ bootstrapRepo: (workspaceId: string, body: BootstrapRepoInput) =>
515
+ http<BootstrapJob>(`${ws(workspaceId)}/bootstrap/jobs`, { method: 'POST', body }),
516
+
517
+ // ---- agent runs (unified failure + retry) -----------------------------
518
+ // Retry any failed run (bootstrap or execution); the backend resolves the
519
+ // kind from the unified `agent_runs` table and re-drives the right flow.
520
+ retryAgentRun: (workspaceId: string, runId: string) =>
521
+ http<{ kind: AgentRunKind; run: ExecutionInstance | BootstrapJob }>(
522
+ `${ws(workspaceId)}/agent-runs/${encodeURIComponent(runId)}/retry`,
523
+ { method: 'POST' },
524
+ ),
525
+
526
+ // Explicitly stop a running run (bootstrap or execution): the backend kills the
527
+ // per-run container and tears down the durable driver, then marks the run
528
+ // terminally cancelled so the board stops showing it as running.
529
+ stopAgentRun: (workspaceId: string, runId: string) =>
530
+ http<{ kind: AgentRunKind; run: ExecutionInstance | BootstrapJob }>(
531
+ `${ws(workspaceId)}/agent-runs/${encodeURIComponent(runId)}/stop`,
532
+ { method: 'POST' },
533
+ ),
534
+ }
535
+ }
@@ -0,0 +1,75 @@
1
+ import { ref } from 'vue'
2
+ import type { Block } from '~/types/domain'
3
+
4
+ /**
5
+ * Pointer-driven dragging for blocks positioned inside a container's 2D canvas
6
+ * (tasks inside services/modules, modules inside services). Movement is divided
7
+ * by the board zoom so the block tracks the cursor. When `reparent` is set, the
8
+ * drop point is hit-tested against `[data-drop-zone]` ancestors so a task can be
9
+ * dragged from a service into a module (or back out).
10
+ */
11
+ export function useBlockDrag() {
12
+ const board = useBoardStore()
13
+ const ui = useUiStore()
14
+ const draggingId = ref<string | null>(null)
15
+
16
+ function startDrag(
17
+ block: Block,
18
+ e: PointerEvent,
19
+ opts: { reparent?: boolean; clamp?: boolean } = {},
20
+ ) {
21
+ if (e.button !== 0) return
22
+ e.preventDefault()
23
+ e.stopPropagation()
24
+ const startX = e.clientX
25
+ const startY = e.clientY
26
+ const orig = { ...block.position }
27
+ // Container-local blocks (tasks/modules) are clamped to their parent's origin;
28
+ // frames live in free-floating flow space, so they opt out via `clamp: false`.
29
+ const clamp = opts.clamp ?? true
30
+ draggingId.value = block.id
31
+
32
+ const onMove = (ev: PointerEvent) => {
33
+ const z = ui.zoom || 1
34
+ const nx = orig.x + (ev.clientX - startX) / z
35
+ const ny = orig.y + (ev.clientY - startY) / z
36
+ board.moveBlock(block.id, {
37
+ x: clamp ? Math.max(0, nx) : nx,
38
+ y: clamp ? Math.max(0, ny) : ny,
39
+ })
40
+ }
41
+ const onUp = (ev: PointerEvent) => {
42
+ window.removeEventListener('pointermove', onMove)
43
+ window.removeEventListener('pointerup', onUp)
44
+ if (opts.reparent) reparentAt(block, ev.clientX, ev.clientY)
45
+ draggingId.value = null
46
+ }
47
+ window.addEventListener('pointermove', onMove)
48
+ window.addEventListener('pointerup', onUp)
49
+ }
50
+
51
+ function reparentAt(block: Block, clientX: number, clientY: number) {
52
+ const el = document.querySelector(`[data-block-id="${block.id}"]`) as HTMLElement | null
53
+ if (!el) return
54
+ // hide the dragged element so elementFromPoint sees the zone beneath it
55
+ const prev = el.style.pointerEvents
56
+ el.style.pointerEvents = 'none'
57
+ const under = document.elementFromPoint(clientX, clientY) as HTMLElement | null
58
+ const zoneEl = under?.closest('[data-drop-zone]') as HTMLElement | null
59
+ el.style.pointerEvents = prev
60
+ if (!zoneEl) return
61
+
62
+ const newParent = zoneEl.getAttribute('data-drop-zone')!
63
+ if (newParent === block.parentId) return // same container — keep the new position
64
+
65
+ const z = ui.zoom || 1
66
+ const zr = zoneEl.getBoundingClientRect()
67
+ const er = el.getBoundingClientRect()
68
+ board.reparentBlock(block.id, newParent, {
69
+ x: Math.max(0, (er.left - zr.left) / z),
70
+ y: Math.max(0, (er.top - zr.top) / z),
71
+ })
72
+ }
73
+
74
+ return { draggingId, startDrag }
75
+ }