@cat-factory/app 0.6.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 (189) 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 +143 -0
  8. package/app/components/auth/UserMenu.vue +39 -0
  9. package/app/components/board/AddTaskModal.vue +444 -0
  10. package/app/components/board/AgentFailureCard.vue +97 -0
  11. package/app/components/board/AgentStopButton.vue +61 -0
  12. package/app/components/board/BoardCanvas.vue +183 -0
  13. package/app/components/board/ContextPicker.vue +367 -0
  14. package/app/components/board/RecurringPipelineModal.vue +219 -0
  15. package/app/components/board/TaskDependencyEdges.vue +132 -0
  16. package/app/components/board/nodes/AgentChip.vue +59 -0
  17. package/app/components/board/nodes/BlockNode.vue +433 -0
  18. package/app/components/board/nodes/DecisionBadge.vue +27 -0
  19. package/app/components/board/nodes/DraggableTask.vue +48 -0
  20. package/app/components/board/nodes/ModuleFrame.vue +97 -0
  21. package/app/components/board/nodes/TaskCard.vue +359 -0
  22. package/app/components/board/nodes/TaskPipelineMini.vue +159 -0
  23. package/app/components/bootstrap/BootstrapModal.vue +665 -0
  24. package/app/components/clarity/ClarityReviewWindow.vue +611 -0
  25. package/app/components/consensus/ConsensusSessionWindow.vue +210 -0
  26. package/app/components/documents/DocumentImportModal.vue +161 -0
  27. package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
  28. package/app/components/documents/SpawnPreviewModal.vue +161 -0
  29. package/app/components/documents/TaskContextDocs.vue +83 -0
  30. package/app/components/focus/BlockFocusView.vue +171 -0
  31. package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
  32. package/app/components/gates/GateResultView.vue +282 -0
  33. package/app/components/github/AddServiceFromRepoModal.vue +354 -0
  34. package/app/components/github/GitHubConnect.vue +183 -0
  35. package/app/components/github/GitHubOnboarding.vue +45 -0
  36. package/app/components/github/GitHubPanel.vue +584 -0
  37. package/app/components/github/RepoTreeBrowser.vue +171 -0
  38. package/app/components/layout/AccountTeamSettings.vue +237 -0
  39. package/app/components/layout/BoardSwitcher.vue +280 -0
  40. package/app/components/layout/BoardToolbar.vue +156 -0
  41. package/app/components/layout/CommandBar.vue +336 -0
  42. package/app/components/layout/GitHubPatBanner.vue +73 -0
  43. package/app/components/layout/NotificationsInbox.vue +175 -0
  44. package/app/components/layout/SideBar.vue +314 -0
  45. package/app/components/layout/SpendWarningBanner.vue +107 -0
  46. package/app/components/observability/StepMetricsBar.vue +102 -0
  47. package/app/components/palettes/AgentPalette.vue +86 -0
  48. package/app/components/panels/AgentStepDetail.vue +737 -0
  49. package/app/components/panels/DecisionModal.vue +71 -0
  50. package/app/components/panels/InspectorPanel.vue +465 -0
  51. package/app/components/panels/ObservabilityPanel.vue +351 -0
  52. package/app/components/panels/StepMetadataCard.vue +253 -0
  53. package/app/components/panels/StepRestartControl.vue +90 -0
  54. package/app/components/panels/StepResultViewHost.vue +40 -0
  55. package/app/components/panels/StepTestReport.vue +84 -0
  56. package/app/components/panels/inspector/ContainerSummary.vue +74 -0
  57. package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -0
  58. package/app/components/panels/inspector/ServiceFragments.vue +82 -0
  59. package/app/components/panels/inspector/ServiceTestConfig.vue +198 -0
  60. package/app/components/panels/inspector/TaskAgentConfig.vue +81 -0
  61. package/app/components/panels/inspector/TaskDependencies.vue +70 -0
  62. package/app/components/panels/inspector/TaskEstimateBadge.vue +56 -0
  63. package/app/components/panels/inspector/TaskExecution.vue +364 -0
  64. package/app/components/panels/inspector/TaskRunSettings.vue +187 -0
  65. package/app/components/panels/inspector/TaskStructure.vue +96 -0
  66. package/app/components/pipeline/AgentKindIcon.vue +30 -0
  67. package/app/components/pipeline/IterationCapPrompt.vue +70 -0
  68. package/app/components/pipeline/PipelineBuilder.vue +817 -0
  69. package/app/components/pipeline/PipelineProgress.vue +484 -0
  70. package/app/components/providers/ApiKeysSection.vue +273 -0
  71. package/app/components/providers/PersonalCredentialModal.vue +128 -0
  72. package/app/components/providers/PersonalSubscriptionSection.vue +225 -0
  73. package/app/components/providers/VendorCredentialsModal.vue +197 -0
  74. package/app/components/recurring/RecurrenceEditor.vue +124 -0
  75. package/app/components/requirements/RequirementsReviewWindow.vue +620 -0
  76. package/app/components/settings/DatadogPanel.vue +213 -0
  77. package/app/components/settings/LocalModelEndpointsPanel.vue +286 -0
  78. package/app/components/settings/MergeThresholdsPanel.vue +378 -0
  79. package/app/components/settings/ModelDefaultsPanel.vue +250 -0
  80. package/app/components/settings/ServiceFragmentDefaultsPanel.vue +124 -0
  81. package/app/components/settings/WorkspaceSettingsPanel.vue +142 -0
  82. package/app/components/slack/SlackPanel.vue +299 -0
  83. package/app/components/tasks/TaskContextIssues.vue +88 -0
  84. package/app/components/tasks/TaskImportModal.vue +207 -0
  85. package/app/components/tasks/TaskSourceConnectModal.vue +133 -0
  86. package/app/components/testing/TestReportWindow.vue +404 -0
  87. package/app/composables/api/accounts.ts +81 -0
  88. package/app/composables/api/auth.ts +45 -0
  89. package/app/composables/api/board.ts +101 -0
  90. package/app/composables/api/bootstrap.ts +62 -0
  91. package/app/composables/api/context.ts +25 -0
  92. package/app/composables/api/documents.ts +74 -0
  93. package/app/composables/api/execution.ts +127 -0
  94. package/app/composables/api/fragments.ts +71 -0
  95. package/app/composables/api/github.ts +131 -0
  96. package/app/composables/api/models.ts +127 -0
  97. package/app/composables/api/notifications.ts +23 -0
  98. package/app/composables/api/presets.ts +29 -0
  99. package/app/composables/api/recurring.ts +68 -0
  100. package/app/composables/api/releaseHealth.ts +43 -0
  101. package/app/composables/api/reviews.ts +146 -0
  102. package/app/composables/api/slack.ts +54 -0
  103. package/app/composables/api/tasks.ts +72 -0
  104. package/app/composables/api/workspaces.ts +36 -0
  105. package/app/composables/useApi.ts +89 -0
  106. package/app/composables/useBlockDrag.ts +90 -0
  107. package/app/composables/useBlockQueries.ts +154 -0
  108. package/app/composables/useBoardFlow.ts +11 -0
  109. package/app/composables/useContextLinking.ts +65 -0
  110. package/app/composables/useDepLabels.ts +26 -0
  111. package/app/composables/useFrameResize.ts +54 -0
  112. package/app/composables/useResultView.ts +48 -0
  113. package/app/composables/useReviewStage.ts +40 -0
  114. package/app/composables/useSemanticZoom.ts +31 -0
  115. package/app/composables/useStepApproval.ts +233 -0
  116. package/app/composables/useStepProse.ts +78 -0
  117. package/app/composables/useStepTimer.ts +63 -0
  118. package/app/composables/useTaskExpansion.ts +92 -0
  119. package/app/composables/useWorkspaceStream.ts +155 -0
  120. package/app/docs/architecture.md +31 -0
  121. package/app/pages/index.vue +141 -0
  122. package/app/stores/accounts.ts +152 -0
  123. package/app/stores/agentConfig.ts +35 -0
  124. package/app/stores/agentRuns.ts +122 -0
  125. package/app/stores/agents.ts +40 -0
  126. package/app/stores/apiKeys.ts +108 -0
  127. package/app/stores/auth.ts +166 -0
  128. package/app/stores/board.spec.ts +205 -0
  129. package/app/stores/board.ts +286 -0
  130. package/app/stores/bootstrap.ts +97 -0
  131. package/app/stores/clarity.ts +196 -0
  132. package/app/stores/consensus.ts +60 -0
  133. package/app/stores/documents.ts +176 -0
  134. package/app/stores/execution.ts +273 -0
  135. package/app/stores/fragmentLibrary.ts +147 -0
  136. package/app/stores/fragments.ts +40 -0
  137. package/app/stores/github.ts +305 -0
  138. package/app/stores/localModels.ts +51 -0
  139. package/app/stores/mergePresets.ts +58 -0
  140. package/app/stores/modelDefaults.ts +76 -0
  141. package/app/stores/models.ts +134 -0
  142. package/app/stores/notifications.ts +70 -0
  143. package/app/stores/observability.ts +144 -0
  144. package/app/stores/personalSubscriptions.ts +215 -0
  145. package/app/stores/pipelines.ts +327 -0
  146. package/app/stores/recurringPipelines.ts +112 -0
  147. package/app/stores/releaseHealth.ts +75 -0
  148. package/app/stores/requirements.spec.ts +94 -0
  149. package/app/stores/requirements.ts +208 -0
  150. package/app/stores/serviceFragmentDefaults.ts +29 -0
  151. package/app/stores/services.ts +87 -0
  152. package/app/stores/slack.ts +142 -0
  153. package/app/stores/taskExpansion.ts +36 -0
  154. package/app/stores/tasks.spec.ts +71 -0
  155. package/app/stores/tasks.ts +176 -0
  156. package/app/stores/tracker.ts +27 -0
  157. package/app/stores/ui.ts +434 -0
  158. package/app/stores/vendorCredentials.ts +54 -0
  159. package/app/stores/workspace.ts +215 -0
  160. package/app/stores/workspaceSettings.ts +36 -0
  161. package/app/types/accounts.ts +77 -0
  162. package/app/types/bootstrap.ts +83 -0
  163. package/app/types/clarity.ts +59 -0
  164. package/app/types/consensus.ts +91 -0
  165. package/app/types/documents.ts +104 -0
  166. package/app/types/domain.ts +495 -0
  167. package/app/types/execution.ts +383 -0
  168. package/app/types/fragments.ts +72 -0
  169. package/app/types/github.ts +173 -0
  170. package/app/types/localModels.ts +73 -0
  171. package/app/types/merge.ts +71 -0
  172. package/app/types/models.ts +157 -0
  173. package/app/types/notifications.ts +74 -0
  174. package/app/types/recurring.ts +69 -0
  175. package/app/types/releaseHealth.ts +31 -0
  176. package/app/types/requirements.ts +61 -0
  177. package/app/types/services.ts +27 -0
  178. package/app/types/slack.ts +57 -0
  179. package/app/types/tasks.ts +82 -0
  180. package/app/types/tracker.ts +18 -0
  181. package/app/utils/agentOutput.spec.ts +128 -0
  182. package/app/utils/agentOutput.ts +173 -0
  183. package/app/utils/catalog.spec.ts +112 -0
  184. package/app/utils/catalog.ts +455 -0
  185. package/app/utils/dnd.ts +29 -0
  186. package/app/utils/observability.ts +52 -0
  187. package/app/utils/pipelineRender.ts +151 -0
  188. package/nuxt.config.ts +55 -0
  189. package/package.json +45 -0
@@ -0,0 +1,112 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type {
4
+ CreateScheduleInput,
5
+ PipelineSchedule,
6
+ ScheduleRun,
7
+ UpdateScheduleInput,
8
+ } from '~/types/recurring'
9
+ import { useWorkspaceStore } from '~/stores/workspace'
10
+ import { useBoardStore } from '~/stores/board'
11
+
12
+ /**
13
+ * The workspace's recurring pipelines — schedules that re-run a pipeline against a
14
+ * service on a cadence. Hydrated from the workspace snapshot; created from a button
15
+ * on the service frame and managed from the inspector. Run history is fetched
16
+ * lazily per schedule (it is retained only ~1 week so it stays small).
17
+ */
18
+ export const useRecurringPipelinesStore = defineStore('recurringPipelines', () => {
19
+ const api = useApi()
20
+ const toast = useToast()
21
+
22
+ const schedules = ref<PipelineSchedule[]>([])
23
+ /** Lazily-loaded run history, keyed by schedule id. */
24
+ const runsBySchedule = ref<Record<string, ScheduleRun[]>>({})
25
+
26
+ function hydrate(list: PipelineSchedule[]) {
27
+ schedules.value = [...list].sort((a, b) => a.createdAt - b.createdAt)
28
+ }
29
+
30
+ /** Schedules grouped by the service frame they live in (for board badges). */
31
+ const byFrame = computed<Record<string, PipelineSchedule[]>>(() => {
32
+ const map: Record<string, PipelineSchedule[]> = {}
33
+ for (const s of schedules.value) (map[s.frameId] ??= []).push(s)
34
+ return map
35
+ })
36
+
37
+ /** The schedule whose reused block is `blockId`, if any. */
38
+ function byBlock(blockId: string): PipelineSchedule | undefined {
39
+ return schedules.value.find((s) => s.blockId === blockId)
40
+ }
41
+
42
+ async function create(input: CreateScheduleInput) {
43
+ const ws = useWorkspaceStore()
44
+ const created = await api.createRecurringPipeline(ws.requireId(), input)
45
+ await ws.refresh()
46
+ return created
47
+ }
48
+
49
+ async function update(id: string, patch: UpdateScheduleInput) {
50
+ const ws = useWorkspaceStore()
51
+ const updated = await api.updateRecurringPipeline(ws.requireId(), id, patch)
52
+ await ws.refresh()
53
+ return updated
54
+ }
55
+
56
+ /**
57
+ * Delete a recurring pipeline. Deleting the schedule cascades to its reused block
58
+ * + run history server-side, so we hide BOTH immediately (optimistic) and restore
59
+ * them with a toast if the backend rejects the delete.
60
+ */
61
+ async function remove(id: string) {
62
+ const ws = useWorkspaceStore()
63
+ const board = useBoardStore()
64
+ const sched = schedules.value.find((s) => s.id === id)
65
+ const blockSnap = sched ? board.detach(sched.blockId) : null
66
+ const prevSchedules = schedules.value
67
+ schedules.value = schedules.value.filter((s) => s.id !== id)
68
+ try {
69
+ await api.deleteRecurringPipeline(ws.requireId(), id)
70
+ delete runsBySchedule.value[id]
71
+ await ws.refresh()
72
+ } catch (e) {
73
+ schedules.value = prevSchedules
74
+ if (blockSnap) board.reattach(blockSnap)
75
+ toast.add({
76
+ title: 'Could not delete recurring pipeline',
77
+ description: e instanceof Error ? e.message : String(e),
78
+ icon: 'i-lucide-triangle-alert',
79
+ color: 'error',
80
+ })
81
+ }
82
+ }
83
+
84
+ async function runNow(id: string) {
85
+ const ws = useWorkspaceStore()
86
+ const schedule = await api.runScheduleNow(ws.requireId(), id)
87
+ await loadRuns(id)
88
+ await ws.refresh()
89
+ return schedule
90
+ }
91
+
92
+ /** Fetch (and cache) a schedule's run history for the inspector. */
93
+ async function loadRuns(id: string) {
94
+ const ws = useWorkspaceStore()
95
+ const runs = await api.listScheduleRuns(ws.requireId(), id)
96
+ runsBySchedule.value = { ...runsBySchedule.value, [id]: runs }
97
+ return runs
98
+ }
99
+
100
+ return {
101
+ schedules,
102
+ runsBySchedule,
103
+ byFrame,
104
+ byBlock,
105
+ hydrate,
106
+ create,
107
+ update,
108
+ remove,
109
+ runNow,
110
+ loadRuns,
111
+ }
112
+ })
@@ -0,0 +1,75 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type {
4
+ DatadogConnectionView,
5
+ ReleaseHealthConfig,
6
+ UpsertDatadogConnectionInput,
7
+ UpsertReleaseHealthConfigInput,
8
+ } from '~/types/releaseHealth'
9
+ import { useWorkspaceStore } from '~/stores/workspace'
10
+
11
+ /**
12
+ * The workspace's Datadog post-release-health settings: the (single) connection — keys
13
+ * are write-only, never read back — and the per-block monitor/SLO mappings the
14
+ * `post-release-health` gate reads. Loaded on demand (the settings panel), not from the
15
+ * snapshot, since the secrets never leave the server.
16
+ */
17
+ export const useReleaseHealthStore = defineStore('releaseHealth', () => {
18
+ const api = useApi()
19
+
20
+ const connection = ref<DatadogConnectionView>({ connected: false, site: null })
21
+ const configs = ref<ReleaseHealthConfig[]>([])
22
+ const loading = ref(false)
23
+
24
+ async function load() {
25
+ const ws = useWorkspaceStore()
26
+ loading.value = true
27
+ try {
28
+ const [conn, list] = await Promise.all([
29
+ api.getDatadogConnection(ws.requireId()),
30
+ api.listReleaseHealthConfigs(ws.requireId()),
31
+ ])
32
+ connection.value = conn
33
+ configs.value = list
34
+ } finally {
35
+ loading.value = false
36
+ }
37
+ }
38
+
39
+ async function saveConnection(input: UpsertDatadogConnectionInput) {
40
+ const ws = useWorkspaceStore()
41
+ connection.value = await api.setDatadogConnection(ws.requireId(), input)
42
+ }
43
+
44
+ async function removeConnection() {
45
+ const ws = useWorkspaceStore()
46
+ await api.deleteDatadogConnection(ws.requireId())
47
+ connection.value = { connected: false, site: null }
48
+ }
49
+
50
+ async function saveConfig(blockId: string, input: UpsertReleaseHealthConfigInput) {
51
+ const ws = useWorkspaceStore()
52
+ const saved = await api.upsertReleaseHealthConfig(ws.requireId(), blockId, input)
53
+ const idx = configs.value.findIndex((c) => c.blockId === blockId)
54
+ if (idx >= 0) configs.value[idx] = saved
55
+ else configs.value.push(saved)
56
+ return saved
57
+ }
58
+
59
+ async function removeConfig(blockId: string) {
60
+ const ws = useWorkspaceStore()
61
+ await api.deleteReleaseHealthConfig(ws.requireId(), blockId)
62
+ configs.value = configs.value.filter((c) => c.blockId !== blockId)
63
+ }
64
+
65
+ return {
66
+ connection,
67
+ configs,
68
+ loading,
69
+ load,
70
+ saveConnection,
71
+ removeConnection,
72
+ saveConfig,
73
+ removeConfig,
74
+ }
75
+ })
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import type { RequirementReview } from '~/types/requirements'
3
+ import { useRequirementsStore } from '~/stores/requirements'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+
6
+ /** Minimal review factory — only the fields the store getters touch. */
7
+ function review(over: Partial<RequirementReview> = {}): RequirementReview {
8
+ return {
9
+ id: 'rr1',
10
+ blockId: 'b1',
11
+ status: 'ready',
12
+ iteration: 1,
13
+ maxIterations: 3,
14
+ items: [],
15
+ incorporatedRequirements: null,
16
+ model: null,
17
+ ...over,
18
+ } as RequirementReview
19
+ }
20
+
21
+ describe('requirements store load() loading flag', () => {
22
+ beforeEach(() => {
23
+ // The store resolves its workspace id from the workspace store at call time.
24
+ useWorkspaceStore().workspaceId = 'ws1'
25
+ })
26
+
27
+ it('flags the block as loading while the fetch is in flight, then clears it', async () => {
28
+ // A deferred fetch so we can observe the in-flight window before it resolves —
29
+ // this is the race the spinner state guards against (review null + not loading
30
+ // would otherwise render the "no review yet" empty state on first open).
31
+ let resolveFetch!: (r: RequirementReview) => void
32
+ const pending = new Promise<RequirementReview>((res) => {
33
+ resolveFetch = res
34
+ })
35
+ vi.stubGlobal('useApi', () => ({ getRequirementReview: () => pending }))
36
+
37
+ const store = useRequirementsStore()
38
+ expect(store.isLoading('b1')).toBe(false)
39
+
40
+ const loadPromise = store.load('b1')
41
+ // In flight: no review cached yet, but the block is flagged loading.
42
+ expect(store.reviewFor('b1')).toBeNull()
43
+ expect(store.isLoading('b1')).toBe(true)
44
+
45
+ resolveFetch(review())
46
+ await loadPromise
47
+
48
+ expect(store.isLoading('b1')).toBe(false)
49
+ expect(store.reviewFor('b1')?.id).toBe('rr1')
50
+ })
51
+
52
+ it('clears the loading flag even when the fetch rejects', async () => {
53
+ vi.stubGlobal('useApi', () => ({
54
+ getRequirementReview: () => Promise.reject(new Error('503')),
55
+ }))
56
+
57
+ const store = useRequirementsStore()
58
+ await store.load('b1')
59
+
60
+ expect(store.isLoading('b1')).toBe(false)
61
+ expect(store.available).toBe(false)
62
+ expect(store.reviewFor('b1')).toBeNull()
63
+ })
64
+
65
+ it('coalesces concurrent load() calls for the same block into one request', async () => {
66
+ // Two callers open at once (the inspector badge watch + the review window). They must
67
+ // share a single in-flight request, not each fetch their own.
68
+ let calls = 0
69
+ let resolveFetch!: (r: RequirementReview) => void
70
+ const pending = new Promise<RequirementReview>((res) => {
71
+ resolveFetch = res
72
+ })
73
+ vi.stubGlobal('useApi', () => ({
74
+ getRequirementReview: () => {
75
+ calls++
76
+ return pending
77
+ },
78
+ }))
79
+
80
+ const store = useRequirementsStore()
81
+ const first = store.load('b1')
82
+ const second = store.load('b1')
83
+ expect(calls).toBe(1)
84
+
85
+ resolveFetch(review())
86
+ await Promise.all([first, second])
87
+ expect(calls).toBe(1)
88
+ expect(store.reviewFor('b1')?.id).toBe('rr1')
89
+
90
+ // Once the in-flight request settles, a later load fetches fresh.
91
+ void store.load('b1')
92
+ expect(calls).toBe(2)
93
+ })
94
+ })
@@ -0,0 +1,208 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type {
4
+ RequirementReview,
5
+ ResolveRequirementsExceededChoice,
6
+ ReviewItemStatus,
7
+ } from '~/types/requirements'
8
+ import { useWorkspaceStore } from '~/stores/workspace'
9
+
10
+ /**
11
+ * Requirements-review state. On the pipeline path the reviewer runs as the first gate
12
+ * step: the run parks while the human answers/dismisses findings, then asks to incorporate.
13
+ * Incorporation + the re-review run ASYNCHRONOUSLY in the durable driver — the call returns
14
+ * at once (status `incorporating`) and the user goes back to the board; they are summoned
15
+ * again (a notification) only if the re-review yields findings or hits the cap. The store is
16
+ * patched both from call responses and from live `requirements` stream events (see
17
+ * `upsert`). `available` mirrors the backend's opt-in gate (a 503 hides the UI).
18
+ * Per-workspace; nothing is persisted client-side.
19
+ */
20
+ export const useRequirementsStore = defineStore('requirements', () => {
21
+ const api = useApi()
22
+ const workspace = useWorkspaceStore()
23
+
24
+ /** null = unknown (not probed), true/false = feature on/off. */
25
+ const available = ref<boolean | null>(null)
26
+ /** The current review per block id (null = fetched, none exists). */
27
+ const reviews = ref<Record<string, RequirementReview | null>>({})
28
+ /** Block ids whose reviewer is currently running (review / re-review). */
29
+ const reviewing = ref<Set<string>>(new Set())
30
+ /** Review ids currently incorporating their answers. */
31
+ const incorporating = ref<Set<string>>(new Set())
32
+ /** Block ids whose current review is being fetched (the initial `load`). */
33
+ const loadingByBlock = ref<Set<string>>(new Set())
34
+ /**
35
+ * In-flight `load()` promises keyed by block id, so concurrent callers for the same
36
+ * block (the inspector's badge watch + the review window opening together) share ONE
37
+ * request instead of each firing its own. Plain Map — internal bookkeeping, not
38
+ * reactive. Cleared once the request settles.
39
+ */
40
+ const inFlight = new Map<string, Promise<void>>()
41
+
42
+ function reviewFor(blockId: string): RequirementReview | null {
43
+ return reviews.value[blockId] ?? null
44
+ }
45
+ /**
46
+ * The async background stage a block's review is in, or null. While the driver folds the
47
+ * answers (`incorporating`) then re-reviews the document (`reviewing`), NO human action is
48
+ * needed — so the board suppresses the "Approval needed" gate and shows this working state
49
+ * instead, with copy that names which of the two stages is running.
50
+ */
51
+ function backgroundStage(blockId: string): 'incorporating' | 'reviewing' | null {
52
+ const status = reviews.value[blockId]?.status
53
+ return status === 'incorporating' || status === 'reviewing' ? status : null
54
+ }
55
+ function isReviewing(blockId: string): boolean {
56
+ return reviewing.value.has(blockId)
57
+ }
58
+ function isLoading(blockId: string): boolean {
59
+ return loadingByBlock.value.has(blockId)
60
+ }
61
+ function isIncorporating(reviewId: string): boolean {
62
+ return incorporating.value.has(reviewId)
63
+ }
64
+
65
+ /** Findings still needing a human (status `open`). */
66
+ function openCount(review: RequirementReview): number {
67
+ return review.items.filter((i) => i.status === 'open').length
68
+ }
69
+ /** Findings the human answered (a reply recorded), which the companion folds in. */
70
+ function answeredCount(review: RequirementReview): number {
71
+ return review.items.filter((i) => i.status === 'answered' || i.status === 'resolved').length
72
+ }
73
+ /** Every finding is settled (answered or dismissed) — none still open. */
74
+ function allSettled(review: RequirementReview): boolean {
75
+ return openCount(review) === 0
76
+ }
77
+ /** Incorporation is possible: all findings settled AND at least one was answered. */
78
+ function canIncorporate(review: RequirementReview): boolean {
79
+ return allSettled(review) && answeredCount(review) > 0
80
+ }
81
+ /** Proceed (skip the companion) is possible: all findings settled but none answered. */
82
+ function canProceed(review: RequirementReview): boolean {
83
+ return allSettled(review) && answeredCount(review) === 0
84
+ }
85
+
86
+ function store(review: RequirementReview) {
87
+ reviews.value = { ...reviews.value, [review.blockId]: review }
88
+ }
89
+
90
+ function withFlag(set: typeof reviewing, key: string, on: boolean) {
91
+ const next = new Set(set.value)
92
+ if (on) next.add(key)
93
+ else next.delete(key)
94
+ set.value = next
95
+ }
96
+
97
+ /** Fetch the current review for a block (probing the feature's availability). */
98
+ async function load(blockId: string) {
99
+ if (!workspace.workspaceId) return
100
+ // Coalesce overlapping loads of the same block onto a single request.
101
+ const pending = inFlight.get(blockId)
102
+ if (pending) return pending
103
+ const promise = (async () => {
104
+ withFlag(loadingByBlock, blockId, true)
105
+ try {
106
+ const review = await api.getRequirementReview(workspace.requireId(), blockId)
107
+ available.value = true
108
+ reviews.value = { ...reviews.value, [blockId]: review }
109
+ } catch {
110
+ // 503 (feature off) or any error → hide the UI entry points.
111
+ available.value = false
112
+ } finally {
113
+ withFlag(loadingByBlock, blockId, false)
114
+ inFlight.delete(blockId)
115
+ }
116
+ })()
117
+ inFlight.set(blockId, promise)
118
+ return promise
119
+ }
120
+
121
+ /** Record a human's answer to one item. */
122
+ async function reply(review: RequirementReview, itemId: string, text: string) {
123
+ store(await api.replyRequirementItem(workspace.requireId(), review.id, itemId, text))
124
+ }
125
+
126
+ /** Set an item's status (dismiss / reopen). */
127
+ async function setItemStatus(
128
+ review: RequirementReview,
129
+ itemId: string,
130
+ status: ReviewItemStatus,
131
+ ) {
132
+ store(await api.setRequirementItemStatus(workspace.requireId(), review.id, itemId, status))
133
+ }
134
+
135
+ /**
136
+ * Ask the driver to incorporate the answers ASYNCHRONOUSLY. Optional `feedback` is the "do
137
+ * it differently" direction when redoing a merge. Returns at once with the `incorporating`
138
+ * review (the fold + re-review run in the background); the caller returns the user to the
139
+ * board. A live `requirements` event / a notification reflects the outcome later.
140
+ */
141
+ async function incorporate(review: RequirementReview, feedback?: string) {
142
+ withFlag(incorporating, review.id, true)
143
+ try {
144
+ const updated = await api.incorporateRequirements(
145
+ workspace.requireId(),
146
+ review.blockId,
147
+ feedback,
148
+ )
149
+ store(updated)
150
+ return updated
151
+ } finally {
152
+ withFlag(incorporating, review.id, false)
153
+ }
154
+ }
155
+
156
+ /** Re-review the incorporated document (one more reviewer pass; may converge/advance). */
157
+ async function reReview(blockId: string): Promise<RequirementReview> {
158
+ withFlag(reviewing, blockId, true)
159
+ try {
160
+ const updated = await api.reReviewRequirements(workspace.requireId(), blockId)
161
+ store(updated)
162
+ return updated
163
+ } finally {
164
+ withFlag(reviewing, blockId, false)
165
+ }
166
+ }
167
+
168
+ /** Proceed: settle the requirements and advance the parked run. */
169
+ async function proceed(blockId: string): Promise<RequirementReview> {
170
+ const updated = await api.proceedRequirements(workspace.requireId(), blockId)
171
+ store(updated)
172
+ return updated
173
+ }
174
+
175
+ /** Resolve a capped review: extra-round / proceed / stop-reset. */
176
+ async function resolveExceeded(
177
+ blockId: string,
178
+ choice: ResolveRequirementsExceededChoice,
179
+ ): Promise<RequirementReview> {
180
+ const updated = await api.resolveRequirementsExceeded(workspace.requireId(), blockId, choice)
181
+ store(updated)
182
+ return updated
183
+ }
184
+
185
+ return {
186
+ available,
187
+ reviews,
188
+ reviewFor,
189
+ backgroundStage,
190
+ isReviewing,
191
+ isLoading,
192
+ isIncorporating,
193
+ openCount,
194
+ answeredCount,
195
+ allSettled,
196
+ canIncorporate,
197
+ canProceed,
198
+ load,
199
+ reply,
200
+ setItemStatus,
201
+ incorporate,
202
+ reReview,
203
+ proceed,
204
+ resolveExceeded,
205
+ // Patch the cache from a live `requirements` stream event.
206
+ upsert: store,
207
+ }
208
+ })
@@ -0,0 +1,29 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+
5
+ /**
6
+ * The workspace's default service-fragment selection — the best-practice fragment ids
7
+ * a NEW service inherits onto its `serviceFragmentIds`. Hydrated from the workspace
8
+ * snapshot; edited via the Default-service-fragments settings panel, which replaces
9
+ * the whole list on save. Changing it does not retroactively change existing services.
10
+ */
11
+ export const useServiceFragmentDefaultsStore = defineStore('serviceFragmentDefaults', () => {
12
+ const api = useApi()
13
+
14
+ /** The default fragment ids new services inherit. */
15
+ const fragmentIds = ref<string[]>([])
16
+
17
+ function hydrate(ids: string[] | undefined) {
18
+ fragmentIds.value = [...(ids ?? [])]
19
+ }
20
+
21
+ /** Replace the whole default list and persist it. */
22
+ async function set(ids: string[]) {
23
+ const ws = useWorkspaceStore()
24
+ const saved = await api.setServiceFragmentDefaults(ws.requireId(), ids)
25
+ fragmentIds.value = [...saved.fragmentIds]
26
+ }
27
+
28
+ return { fragmentIds, hydrate, set }
29
+ })
@@ -0,0 +1,87 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type { Service, WorkspaceMount } from '~/types/services'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+
6
+ /**
7
+ * In-org shared services. A `Service` is account-owned (a service frame + its subtree + repo)
8
+ * and can be mounted onto several teams' boards; a `WorkspaceMount` places one onto THIS
9
+ * board with its own frame layout. Hydrated from the workspace snapshot:
10
+ * - `mounts` — the services this board mounts (drives the per-board frame layout),
11
+ * - `catalog` — the org's services this board can mount from (each with a `mountCount`).
12
+ */
13
+ export const useServicesStore = defineStore('services', () => {
14
+ const api = useApi()
15
+
16
+ const mounts = ref<WorkspaceMount[]>([])
17
+ const catalog = ref<Service[]>([])
18
+
19
+ function hydrate(nextMounts: WorkspaceMount[], nextCatalog: Service[]) {
20
+ mounts.value = [...nextMounts]
21
+ catalog.value = [...nextCatalog]
22
+ }
23
+
24
+ /** Mount row keyed by service id. */
25
+ const byServiceId = computed<Record<string, WorkspaceMount>>(() => {
26
+ const map: Record<string, WorkspaceMount> = {}
27
+ for (const m of mounts.value) map[m.serviceId] = m
28
+ return map
29
+ })
30
+
31
+ /** Catalog service keyed by its frame block id (resolve a frame → its service). */
32
+ const serviceByFrameBlock = computed<Record<string, Service>>(() => {
33
+ const map: Record<string, Service> = {}
34
+ for (const s of catalog.value) map[s.frameBlockId] = s
35
+ return map
36
+ })
37
+
38
+ /** Org services NOT yet mounted on this board (the "add existing service" picker's options). */
39
+ const mountable = computed<Service[]>(() => {
40
+ const mounted = new Set(mounts.value.map((m) => m.serviceId))
41
+ return catalog.value.filter((s) => !mounted.has(s.id))
42
+ })
43
+
44
+ /** A frame is "shared" when its service is mounted on more than one board. */
45
+ function isSharedFrame(frameBlockId: string): boolean {
46
+ return (serviceByFrameBlock.value[frameBlockId]?.mountCount ?? 0) > 1
47
+ }
48
+
49
+ async function mount(serviceId: string, position?: { x: number; y: number }) {
50
+ const ws = useWorkspaceStore()
51
+ const created = await api.mountService(ws.requireId(), serviceId, position ? { position } : {})
52
+ await ws.refresh()
53
+ return created
54
+ }
55
+
56
+ async function unmount(serviceId: string) {
57
+ const ws = useWorkspaceStore()
58
+ await api.unmountService(ws.requireId(), serviceId)
59
+ await ws.refresh()
60
+ }
61
+
62
+ /** Persist a mounted frame's per-board layout (called on frame drag/resize end). */
63
+ async function updateLayout(
64
+ serviceId: string,
65
+ position?: { x: number; y: number },
66
+ size?: { w: number; h: number } | null,
67
+ ) {
68
+ const ws = useWorkspaceStore()
69
+ const updated = await api.updateMountLayout(ws.requireId(), serviceId, { position, size })
70
+ const local = mounts.value.find((m) => m.serviceId === serviceId)
71
+ if (local) Object.assign(local, updated)
72
+ return updated
73
+ }
74
+
75
+ return {
76
+ mounts,
77
+ catalog,
78
+ byServiceId,
79
+ serviceByFrameBlock,
80
+ mountable,
81
+ isSharedFrame,
82
+ hydrate,
83
+ mount,
84
+ unmount,
85
+ updateLayout,
86
+ }
87
+ })