@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,144 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type { LlmCallActivity, LlmCallMetric } from '~/types/execution'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+
6
+ /**
7
+ * LLM observability state: the full per-call model activity for a run (prompts,
8
+ * responses, token usage, output-limit headroom, the transport-vs-execution
9
+ * latency split). Loaded on demand when the drill-down panel opens, then kept live:
10
+ * the proxy pushes a compact `llmCall` event per model call over the workspace
11
+ * stream, which `appendCall` folds in so an open panel updates in real time even
12
+ * while the durable driver is evicted. Live-appended rows carry no prompt/response
13
+ * bodies (the event stays small); the panel lazy-loads those for an expanded row
14
+ * from the persisted metrics endpoint. Per-workspace; nothing persisted.
15
+ */
16
+ export const useObservabilityStore = defineStore('observability', () => {
17
+ const api = useApi()
18
+ const workspace = useWorkspaceStore()
19
+
20
+ /** Per-execution-id call list (newest first). */
21
+ const callsByExecution = ref<Record<string, LlmCallMetric[]>>({})
22
+ /** Execution ids currently loading. */
23
+ const loading = ref<Set<string>>(new Set())
24
+ /** Execution ids currently exporting. */
25
+ const exporting = ref<Set<string>>(new Set())
26
+ /** Last load error message per execution id, or null. */
27
+ const errors = ref<Record<string, string | null>>({})
28
+
29
+ function callsFor(executionId: string): LlmCallMetric[] {
30
+ return callsByExecution.value[executionId] ?? []
31
+ }
32
+ function isLoading(executionId: string): boolean {
33
+ return loading.value.has(executionId)
34
+ }
35
+ function isExporting(executionId: string): boolean {
36
+ return exporting.value.has(executionId)
37
+ }
38
+
39
+ function withFlag(set: typeof loading, key: string, on: boolean) {
40
+ const next = new Set(set.value)
41
+ if (on) next.add(key)
42
+ else next.delete(key)
43
+ set.value = next
44
+ }
45
+
46
+ /** Load (or refresh) the per-call detail for a run. */
47
+ async function load(executionId: string) {
48
+ if (!workspace.workspaceId) return
49
+ withFlag(loading, executionId, true)
50
+ errors.value = { ...errors.value, [executionId]: null }
51
+ // Seed the key up front so this run counts as "opened": `appendCall` only folds
52
+ // live events into already-opened runs, so seeding here both captures calls that
53
+ // arrive DURING the fetch and lets the merge below preserve them.
54
+ if (!callsByExecution.value[executionId]) {
55
+ callsByExecution.value = { ...callsByExecution.value, [executionId]: [] }
56
+ }
57
+ try {
58
+ const { calls } = await api.getLlmMetrics(workspace.requireId(), executionId)
59
+ // Preserve live-streamed rows the persisted store hasn't caught up with yet: the
60
+ // proxy emits the live `llmCall` event and writes the metric on INDEPENDENT paths,
61
+ // so a just-observed call can reach the panel before its row is queryable here.
62
+ // Server rows win (they carry the full bodies); the body-less live-only rows stay
63
+ // newest-first ahead of them so a wholesale replace can't drop them mid-run.
64
+ const fetchedIds = new Set(calls.map((c) => c.id))
65
+ const liveOnly = (callsByExecution.value[executionId] ?? []).filter(
66
+ (c) => !fetchedIds.has(c.id),
67
+ )
68
+ callsByExecution.value = {
69
+ ...callsByExecution.value,
70
+ [executionId]: [...liveOnly, ...calls],
71
+ }
72
+ } catch (err) {
73
+ errors.value = {
74
+ ...errors.value,
75
+ [executionId]: err instanceof Error ? err.message : 'Failed to load metrics',
76
+ }
77
+ } finally {
78
+ withFlag(loading, executionId, false)
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Fold a live `llmCall` activity event into the cached call list for its run, so an
84
+ * open panel updates in real time. The compact event carries no prompt/response
85
+ * bodies, so we materialise a {@link LlmCallMetric} with empty bodies + zeroed delta
86
+ * fields; the panel lazy-loads the real bodies (by id) when the row is expanded.
87
+ * Prepended (newest-first, matching `load`'s order) and deduped by id so a later
88
+ * `load` that already includes the call, or a duplicate event, can't double it up.
89
+ *
90
+ * Gated to runs whose panel has been opened (`load` seeds the key): otherwise EVERY
91
+ * model call in the workspace would accumulate here for runs the user never opens,
92
+ * growing this store unbounded for the session's lifetime. An open panel still gets
93
+ * its live updates because it loaded on open.
94
+ */
95
+ function appendCall(activity: LlmCallActivity) {
96
+ const executionId = activity.executionId
97
+ if (!executionId) return
98
+ const existing = callsByExecution.value[executionId]
99
+ if (!existing) return
100
+ if (existing.some((c) => c.id === activity.id)) return
101
+ const row: LlmCallMetric = {
102
+ ...activity,
103
+ promptText: '',
104
+ promptPrefixCount: 0,
105
+ promptHash: '',
106
+ responseText: '',
107
+ reasoningText: '',
108
+ }
109
+ callsByExecution.value = { ...callsByExecution.value, [executionId]: [row, ...existing] }
110
+ }
111
+
112
+ /**
113
+ * Fetch the LLM-friendly export bundle and trigger a client-side download. The
114
+ * events socket auths via a Bearer header (a plain `<a download>` can't), so we
115
+ * fetch the JSON through the API client and save it from a Blob.
116
+ */
117
+ async function downloadExport(executionId: string) {
118
+ if (!workspace.workspaceId) return
119
+ withFlag(exporting, executionId, true)
120
+ try {
121
+ const bundle = await api.exportLlmMetrics(workspace.requireId(), executionId)
122
+ const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' })
123
+ const url = URL.createObjectURL(blob)
124
+ const a = document.createElement('a')
125
+ a.href = url
126
+ a.download = `llm-metrics-${executionId}.json`
127
+ a.click()
128
+ URL.revokeObjectURL(url)
129
+ } finally {
130
+ withFlag(exporting, executionId, false)
131
+ }
132
+ }
133
+
134
+ return {
135
+ callsByExecution,
136
+ callsFor,
137
+ isLoading,
138
+ isExporting,
139
+ errors,
140
+ load,
141
+ appendCall,
142
+ downloadExport,
143
+ }
144
+ })
@@ -0,0 +1,215 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type {
4
+ PersonalSubscriptionStatus,
5
+ StorePersonalSubscriptionInput,
6
+ SubscriptionVendor,
7
+ } from '~/types/domain'
8
+
9
+ // The signed-in user's personal (individual-usage) subscriptions — Claude / GLM / Codex,
10
+ // which are licensed per individual and so stored per-user (not pooled) and unlocked with
11
+ // a personal PASSWORD. The token itself is double-encrypted server-side and never returned;
12
+ // this store only carries metadata + the renewal warning.
13
+ //
14
+ // To keep the password mostly transparent, it is cached CLIENT-SIDE in localStorage under a
15
+ // SINGLE key with a TTL: the backend gate applies one password to ALL of a run's
16
+ // individual-usage vendors, so there's no point keying the cache per vendor. A task
17
+ // start/retry (and each interaction with a live run) rides along with the cached password —
18
+ // sent as the `X-Personal-Password` header — and the user is only re-prompted once it
19
+ // expires (or is wrong).
20
+ //
21
+ // Caching here is a DELIBERATE convenience choice, not a security weakness. The password
22
+ // layer exists to prevent ACCIDENTAL misuse (a credential can't be silently pooled); the
23
+ // real at-rest protection is the server's system encryption, which the cache doesn't touch.
24
+ // Re-prompting an engineer on every run would buy nobody anything (it wouldn't change the
25
+ // threat model), so we don't. The server never stores the password, the raw token is never
26
+ // returned to the client, and an external attacker still needs the system key — so the cache
27
+ // only ever helps on the very device the user is already signed in on. (See
28
+ // backend/docs/individual-subscription-usage.md §3.)
29
+
30
+ /** How long a typed password stays cached before the user is re-prompted (40h). */
31
+ const PASSWORD_TTL_MS = 40 * 60 * 60 * 1000
32
+ const CACHE_KEY = 'cf.personal-pw'
33
+
34
+ /** A credential prompt the UI must satisfy (set when the server replies 428). */
35
+ export interface PendingCredential {
36
+ vendor: SubscriptionVendor
37
+ reason: 'no_subscription' | 'password_required' | 'wrong_password' | 'subscription_expired'
38
+ /** Re-run the gated action with the supplied password (start/retry). */
39
+ retry: (password: string) => Promise<void>
40
+ /** Abandon the prompt (Cancel / connect-instead): the gated action does NOT run. */
41
+ cancel: () => void
42
+ }
43
+
44
+ /** Pull `{ vendor, reason }` out of a 428 credential_required error, else null. */
45
+ export function parseCredentialError(
46
+ error: unknown,
47
+ ): { vendor: SubscriptionVendor; reason: PendingCredential['reason'] } | null {
48
+ const data = (error as { data?: { error?: { code?: string; details?: unknown } } })?.data?.error
49
+ if (data?.code !== 'credential_required') return null
50
+ const details = data.details as {
51
+ vendor?: SubscriptionVendor
52
+ reason?: PendingCredential['reason']
53
+ }
54
+ if (!details?.vendor || !details?.reason) return null
55
+ return { vendor: details.vendor, reason: details.reason }
56
+ }
57
+
58
+ export const usePersonalSubscriptionsStore = defineStore('personalSubscriptions', () => {
59
+ const api = useApi()
60
+ const subscriptions = ref<PersonalSubscriptionStatus[]>([])
61
+ const loading = ref(false)
62
+ /** When set, a credential modal should open to satisfy the pending prompt. */
63
+ const pending = ref<PendingCredential | null>(null)
64
+
65
+ async function load() {
66
+ loading.value = true
67
+ try {
68
+ const { subscriptions: list } = await api.listPersonalSubscriptions()
69
+ subscriptions.value = list
70
+ } catch {
71
+ // Auth disabled / not signed in / feature off → no personal subscriptions surface.
72
+ subscriptions.value = []
73
+ } finally {
74
+ loading.value = false
75
+ }
76
+ }
77
+
78
+ async function store(input: StorePersonalSubscriptionInput) {
79
+ const status = await api.storePersonalSubscription(input)
80
+ subscriptions.value = [...subscriptions.value.filter((s) => s.vendor !== status.vendor), status]
81
+ // Cache the freshly-entered password so the next run rides along transparently.
82
+ setCachedPassword(input.password)
83
+ return status
84
+ }
85
+
86
+ async function remove(vendor: SubscriptionVendor) {
87
+ await api.removePersonalSubscription(vendor)
88
+ subscriptions.value = subscriptions.value.filter((s) => s.vendor !== vendor)
89
+ // Don't clear the shared password cache — other connected vendors may still use it.
90
+ }
91
+
92
+ function has(vendor: SubscriptionVendor): boolean {
93
+ return subscriptions.value.some((s) => s.vendor === vendor)
94
+ }
95
+
96
+ /** Subscriptions whose expiry is near or past — surfaced as a renewal nudge. */
97
+ const renewals = computed(() => subscriptions.value.filter((s) => s.renewSoon))
98
+
99
+ // --- client-side password cache (single localStorage key + TTL) ------------
100
+ function getCachedPassword(): string | undefined {
101
+ if (typeof localStorage === 'undefined') return undefined
102
+ try {
103
+ const raw = localStorage.getItem(CACHE_KEY)
104
+ if (!raw) return undefined
105
+ const { password, expiresAt } = JSON.parse(raw) as { password: string; expiresAt: number }
106
+ if (Date.now() > expiresAt) {
107
+ localStorage.removeItem(CACHE_KEY)
108
+ return undefined
109
+ }
110
+ return password
111
+ } catch {
112
+ return undefined
113
+ }
114
+ }
115
+
116
+ function setCachedPassword(password: string) {
117
+ if (typeof localStorage === 'undefined') return
118
+ try {
119
+ localStorage.setItem(
120
+ CACHE_KEY,
121
+ JSON.stringify({ password, expiresAt: Date.now() + PASSWORD_TTL_MS }),
122
+ )
123
+ } catch {
124
+ // best-effort
125
+ }
126
+ }
127
+
128
+ function clearCachedPassword() {
129
+ if (typeof localStorage === 'undefined') return
130
+ try {
131
+ localStorage.removeItem(CACHE_KEY)
132
+ } catch {
133
+ // best-effort
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Run a gated action (start/retry) that may require a personal credential. Supplies the
139
+ * cached password on the first attempt; if the server replies 428, opens the credential
140
+ * modal and AWAITS the user satisfying or cancelling it (transparently retrying via the
141
+ * `pending.retry` closure). The caller's `action(password?)` performs the API call.
142
+ *
143
+ * Resolves `true` once the action actually ran (first try, or a successful retry), and
144
+ * `false` when the user cancels the prompt — so a caller showing an optimistic spinner
145
+ * can revert it instead of spinning forever. Still rejects for non-credential errors.
146
+ */
147
+ async function withCredential(action: (password?: string) => Promise<void>): Promise<boolean> {
148
+ // First attempt may carry no password (non-individual runs) or the single cached one;
149
+ // the server only consults it when the block needs it.
150
+ try {
151
+ await action(getCachedPassword())
152
+ return true
153
+ } catch (error) {
154
+ const credential = parseCredentialError(error)
155
+ if (!credential) throw error
156
+ // A stale/wrong cached password — drop it so the modal is the source of truth.
157
+ if (credential.reason === 'wrong_password') clearCachedPassword()
158
+ return await new Promise<boolean>((resolve) => {
159
+ const arm = (c: { vendor: SubscriptionVendor; reason: PendingCredential['reason'] }) => {
160
+ pending.value = {
161
+ ...c,
162
+ retry: async (password: string) => {
163
+ try {
164
+ await action(password)
165
+ setCachedPassword(password)
166
+ pending.value = null
167
+ resolve(true)
168
+ } catch (retryError) {
169
+ const again = parseCredentialError(retryError)
170
+ if (again) {
171
+ // Still needs a credential (e.g. still-wrong password): keep the modal
172
+ // open with the fresh reason and let it surface its own toast.
173
+ if (again.reason === 'wrong_password') clearCachedPassword()
174
+ arm(again)
175
+ throw retryError
176
+ }
177
+ // A non-credential failure ends the flow: close the modal, revert the
178
+ // caller's optimistic state, and surface the error to the modal's toast.
179
+ pending.value = null
180
+ resolve(false)
181
+ throw retryError
182
+ }
183
+ },
184
+ cancel: () => {
185
+ pending.value = null
186
+ resolve(false)
187
+ },
188
+ }
189
+ }
190
+ arm(credential)
191
+ })
192
+ }
193
+ }
194
+
195
+ function dismissPending() {
196
+ // Settle the awaiting `withCredential` (the gated action never ran) before clearing.
197
+ const current = pending.value
198
+ pending.value = null
199
+ current?.cancel()
200
+ }
201
+
202
+ return {
203
+ subscriptions,
204
+ loading,
205
+ pending,
206
+ renewals,
207
+ load,
208
+ store,
209
+ remove,
210
+ has,
211
+ getCachedPassword,
212
+ withCredential,
213
+ dismissPending,
214
+ }
215
+ })
@@ -0,0 +1,327 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type { AgentKind, Pipeline } from '~/types/domain'
4
+ import type { ConsensusStepConfig, StepGating } from '~/types/consensus'
5
+ import { companionForProducer, uid } from '~/utils/catalog'
6
+ import { useWorkspaceStore } from '~/stores/workspace'
7
+
8
+ /** A sensible default config when a step is first flipped to consensus in the builder. */
9
+ function defaultConsensusConfig(): ConsensusStepConfig {
10
+ return {
11
+ enabled: true,
12
+ strategy: 'specialist-panel',
13
+ participants: [
14
+ { id: uid('cp'), role: 'Pragmatist', systemFraming: 'Favour the simplest viable approach.' },
15
+ {
16
+ id: uid('cp'),
17
+ role: 'Skeptic',
18
+ systemFraming: 'Probe risks, edge cases and failure modes.',
19
+ },
20
+ ],
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Saved, reusable pipelines (the pipeline palette) plus the in-progress draft
26
+ * being assembled in the pipeline builder. Saved pipelines live on the backend;
27
+ * the draft is transient client state. The draft doubles as the EDIT surface: a
28
+ * custom pipeline can be loaded into it (`loadForEdit`) and saved back in place,
29
+ * while a built-in is cloned first (`clonePipeline`) into an editable copy.
30
+ */
31
+ export const usePipelinesStore = defineStore('pipelines', () => {
32
+ const api = useApi()
33
+ const pipelines = ref<Pipeline[]>([])
34
+
35
+ /** The chain currently being assembled in the builder. */
36
+ const draft = ref<AgentKind[]>([])
37
+ /** Per-step approval gates, kept index-aligned with `draft`. */
38
+ const draftGates = ref<boolean[]>([])
39
+ /** Per-step enable flags, kept index-aligned with `draft` (false ⇒ skipped at run). */
40
+ const draftEnabled = ref<boolean[]>([])
41
+ /**
42
+ * Per-step companion thresholds, kept index-aligned with `draft`. Not editable in the
43
+ * builder UI today, but carried through edits so an existing pipeline's thresholds
44
+ * survive a save.
45
+ */
46
+ const draftThresholds = ref<(number | null)[]>([])
47
+ /** Per-step consensus configs, kept index-aligned with `draft` (null ⇒ standard agent). */
48
+ const draftConsensus = ref<(ConsensusStepConfig | null)[]>([])
49
+ /** Per-step estimate gating, kept index-aligned with `draft` (null ⇒ always run). */
50
+ const draftGating = ref<(StepGating | null)[]>([])
51
+ /** Organizational labels for the pipeline being assembled/edited. */
52
+ const draftLabels = ref<string[]>([])
53
+ const draftName = ref('New pipeline')
54
+ /** The id of the pipeline being edited, or null when assembling a brand-new one. */
55
+ const editingId = ref<string | null>(null)
56
+
57
+ /** Replace the cached pipelines with a server snapshot. */
58
+ function hydrate(next: Pipeline[]) {
59
+ pipelines.value = next
60
+ }
61
+
62
+ function getPipeline(id: string) {
63
+ return pipelines.value.find((p) => p.id === id)
64
+ }
65
+
66
+ /** Insert a step (with its default per-step config) at `index`, keeping arrays aligned. */
67
+ function insertAt(index: number, kind: AgentKind) {
68
+ draft.value.splice(index, 0, kind)
69
+ draftGates.value.splice(index, 0, false)
70
+ draftEnabled.value.splice(index, 0, true)
71
+ draftThresholds.value.splice(index, 0, null)
72
+ draftConsensus.value.splice(index, 0, null)
73
+ draftGating.value.splice(index, 0, null)
74
+ }
75
+
76
+ function addToDraft(kind: AgentKind) {
77
+ insertAt(draft.value.length, kind)
78
+ }
79
+
80
+ function removeFromDraft(index: number) {
81
+ draft.value.splice(index, 1)
82
+ draftGates.value.splice(index, 1)
83
+ draftEnabled.value.splice(index, 1)
84
+ draftThresholds.value.splice(index, 1)
85
+ draftConsensus.value.splice(index, 1)
86
+ draftGating.value.splice(index, 1)
87
+ }
88
+
89
+ function moveInDraft(from: number, to: number) {
90
+ if (to < 0 || to >= draft.value.length) return
91
+ const [item] = draft.value.splice(from, 1)
92
+ if (item) draft.value.splice(to, 0, item)
93
+ const [gate] = draftGates.value.splice(from, 1)
94
+ draftGates.value.splice(to, 0, gate ?? false)
95
+ const [on] = draftEnabled.value.splice(from, 1)
96
+ draftEnabled.value.splice(to, 0, on ?? true)
97
+ const [th] = draftThresholds.value.splice(from, 1)
98
+ draftThresholds.value.splice(to, 0, th ?? null)
99
+ const [cons] = draftConsensus.value.splice(from, 1)
100
+ draftConsensus.value.splice(to, 0, cons ?? null)
101
+ const [gat] = draftGating.value.splice(from, 1)
102
+ draftGating.value.splice(to, 0, gat ?? null)
103
+ }
104
+
105
+ /** Whether the producer step at `index` currently has its companion attached after it. */
106
+ function hasCompanion(index: number): boolean {
107
+ const companion = companionForProducer(draft.value[index] ?? '')
108
+ return companion !== undefined && draft.value[index + 1] === companion
109
+ }
110
+
111
+ /**
112
+ * Toggle the dependent companion on the producer step at `index`: insert it immediately
113
+ * after (turn on) or remove it (turn off). A no-op for a kind that has no companion.
114
+ */
115
+ function toggleCompanion(index: number) {
116
+ const companion = companionForProducer(draft.value[index] ?? '')
117
+ if (!companion) return
118
+ if (draft.value[index + 1] === companion) removeFromDraft(index + 1)
119
+ else insertAt(index + 1, companion)
120
+ }
121
+
122
+ /** Toggle estimate gating on/off for the (companion) step at `index`. */
123
+ function toggleDraftGating(index: number) {
124
+ draftGating.value[index] = draftGating.value[index]?.enabled
125
+ ? null
126
+ : { enabled: true, minRisk: 0.5, minImpact: 0.5 }
127
+ }
128
+
129
+ /**
130
+ * The draft as a list of "units" for rendering: each step is one unit, EXCEPT a companion
131
+ * that sits immediately after its producer — that companion is folded into the producer's
132
+ * unit (`companionIndex`) and surfaced as a toggle on it, not a standalone row. The backend
133
+ * now REJECTS a companion that is not immediately after its producer (strict adjacency in
134
+ * `validatePipelineShape`), so a saved pipeline never has one — but a stray companion that
135
+ * still shows up in the draft (e.g. a pre-existing pipeline saved before adjacency was
136
+ * enforced) is emitted as its own standalone unit so it stays visible and removable/
137
+ * reorderable into a valid shape rather than being silently dropped — and, crucially, so
138
+ * every `draft` index belongs to exactly one unit, which is what lets {@link moveUnit}
139
+ * reorder by unit boundaries without ever dropping a step.
140
+ * `index`/`companionIndex` are positions in the raw `draft` arrays.
141
+ */
142
+ const units = computed(() => {
143
+ const out: { index: number; kind: AgentKind; companionIndex: number | null }[] = []
144
+ let folded = -1 // draft index already consumed as the previous unit's adjacent companion
145
+ for (let i = 0; i < draft.value.length; i++) {
146
+ const kind = draft.value[i]
147
+ if (kind === undefined || i === folded) continue
148
+ const companion = companionForProducer(kind)
149
+ const companionIndex = companion && draft.value[i + 1] === companion ? i + 1 : null
150
+ if (companionIndex !== null) folded = companionIndex
151
+ out.push({ index: i, kind, companionIndex })
152
+ }
153
+ return out
154
+ })
155
+
156
+ /**
157
+ * Move the unit at visible position `from` to `to`, carrying its attached companion. Rebuilds
158
+ * every parallel array by the SAME unit boundaries so they stay index-aligned.
159
+ */
160
+ function moveUnit(from: number, to: number) {
161
+ const u = units.value
162
+ if (to < 0 || to >= u.length || from === to) return
163
+ const reorder = <T>(arr: T[]): T[] => {
164
+ const chunks = u.map((unit) =>
165
+ arr.slice(unit.index, unit.index + (unit.companionIndex !== null ? 2 : 1)),
166
+ )
167
+ const [moved] = chunks.splice(from, 1)
168
+ if (moved) chunks.splice(to, 0, moved)
169
+ return chunks.flat()
170
+ }
171
+ draft.value = reorder(draft.value)
172
+ draftGates.value = reorder(draftGates.value)
173
+ draftEnabled.value = reorder(draftEnabled.value)
174
+ draftThresholds.value = reorder(draftThresholds.value)
175
+ draftConsensus.value = reorder(draftConsensus.value)
176
+ draftGating.value = reorder(draftGating.value)
177
+ }
178
+
179
+ /** Toggle the consensus mechanism on the draft step at `index` (default config / off). */
180
+ function toggleDraftConsensus(index: number) {
181
+ draftConsensus.value[index] = draftConsensus.value[index] ? null : defaultConsensusConfig()
182
+ }
183
+
184
+ /** Replace the consensus config of the draft step at `index` (builder editor edits). */
185
+ function setDraftConsensus(index: number, config: ConsensusStepConfig | null) {
186
+ draftConsensus.value[index] = config
187
+ }
188
+
189
+ /** Toggle the approval gate on the draft step at `index`. */
190
+ function toggleDraftGate(index: number) {
191
+ draftGates.value[index] = !draftGates.value[index]
192
+ }
193
+
194
+ /** Enable/disable the draft step at `index` without removing it. */
195
+ function toggleDraftEnabled(index: number) {
196
+ draftEnabled.value[index] = draftEnabled.value[index] === false
197
+ }
198
+
199
+ function clearDraft() {
200
+ draft.value = []
201
+ draftGates.value = []
202
+ draftEnabled.value = []
203
+ draftThresholds.value = []
204
+ draftConsensus.value = []
205
+ draftGating.value = []
206
+ draftLabels.value = []
207
+ draftName.value = 'New pipeline'
208
+ editingId.value = null
209
+ }
210
+
211
+ /** Load an existing (custom) pipeline into the draft so it can be edited in place. */
212
+ function loadForEdit(pipeline: Pipeline) {
213
+ draft.value = [...pipeline.agentKinds]
214
+ draftGates.value = pipeline.agentKinds.map((_, i) => pipeline.gates?.[i] ?? false)
215
+ draftEnabled.value = pipeline.agentKinds.map((_, i) => pipeline.enabled?.[i] ?? true)
216
+ draftThresholds.value = pipeline.agentKinds.map((_, i) => pipeline.thresholds?.[i] ?? null)
217
+ draftConsensus.value = pipeline.agentKinds.map((_, i) => pipeline.consensus?.[i] ?? null)
218
+ draftGating.value = pipeline.agentKinds.map((_, i) => pipeline.gating?.[i] ?? null)
219
+ draftLabels.value = [...(pipeline.labels ?? [])]
220
+ draftName.value = pipeline.name
221
+ editingId.value = pipeline.id
222
+ }
223
+
224
+ /** The optional arrays to send, omitting the ones that are at their defaults. */
225
+ function draftPayload() {
226
+ return {
227
+ name: draftName.value.trim() || 'Untitled pipeline',
228
+ agentKinds: [...draft.value],
229
+ // Only send gates when at least one step is gated.
230
+ ...(draftGates.value.some(Boolean) ? { gates: [...draftGates.value] } : {}),
231
+ // Only send enabled when at least one step is disabled (default is all-on).
232
+ ...(draftEnabled.value.some((e) => e === false) ? { enabled: [...draftEnabled.value] } : {}),
233
+ // Only send thresholds when at least one step pins an explicit value.
234
+ ...(draftThresholds.value.some((t) => t != null)
235
+ ? { thresholds: [...draftThresholds.value] }
236
+ : {}),
237
+ // Only send consensus when at least one step is consensus-enabled.
238
+ ...(draftConsensus.value.some((c) => c?.enabled)
239
+ ? { consensus: [...draftConsensus.value] }
240
+ : {}),
241
+ // Only send gating when at least one step has gating enabled.
242
+ ...(draftGating.value.some((g) => g?.enabled) ? { gating: [...draftGating.value] } : {}),
243
+ // Only send labels when there are any.
244
+ ...(draftLabels.value.length ? { labels: [...draftLabels.value] } : {}),
245
+ }
246
+ }
247
+
248
+ /** Persist the draft: update the pipeline being edited, else create a new one. */
249
+ async function saveDraft(): Promise<Pipeline | null> {
250
+ if (draft.value.length === 0) return null
251
+ const wsId = useWorkspaceStore().requireId()
252
+ const payload = draftPayload()
253
+ if (editingId.value) {
254
+ const updated = await api.updatePipeline(wsId, editingId.value, payload)
255
+ const i = pipelines.value.findIndex((p) => p.id === updated.id)
256
+ if (i >= 0) pipelines.value[i] = updated
257
+ clearDraft()
258
+ return updated
259
+ }
260
+ const pipeline = await api.createPipeline(wsId, payload)
261
+ pipelines.value.push(pipeline)
262
+ clearDraft()
263
+ return pipeline
264
+ }
265
+
266
+ /** Clone any pipeline (built-in or custom) into an editable copy, ready to edit. */
267
+ async function clonePipeline(id: string): Promise<Pipeline> {
268
+ const clone = await api.clonePipeline(useWorkspaceStore().requireId(), id)
269
+ pipelines.value.push(clone)
270
+ loadForEdit(clone)
271
+ return clone
272
+ }
273
+
274
+ async function removePipeline(id: string) {
275
+ await api.removePipeline(useWorkspaceStore().requireId(), id)
276
+ pipelines.value = pipelines.value.filter((p) => p.id !== id)
277
+ if (editingId.value === id) clearDraft()
278
+ }
279
+
280
+ /** Set a pipeline's organizational metadata (labels / archive). Works on built-ins too. */
281
+ async function organize(id: string, body: { labels?: string[]; archived?: boolean }) {
282
+ const updated = await api.organizePipeline(useWorkspaceStore().requireId(), id, body)
283
+ const i = pipelines.value.findIndex((p) => p.id === updated.id)
284
+ if (i >= 0) pipelines.value[i] = updated
285
+ return updated
286
+ }
287
+
288
+ const archive = (id: string) => organize(id, { archived: true })
289
+ const unarchive = (id: string) => organize(id, { archived: false })
290
+ const setLabels = (id: string, labels: string[]) => organize(id, { labels })
291
+
292
+ return {
293
+ pipelines,
294
+ draft,
295
+ draftGates,
296
+ draftEnabled,
297
+ draftThresholds,
298
+ draftConsensus,
299
+ draftGating,
300
+ draftLabels,
301
+ draftName,
302
+ editingId,
303
+ units,
304
+ hydrate,
305
+ getPipeline,
306
+ addToDraft,
307
+ removeFromDraft,
308
+ moveInDraft,
309
+ moveUnit,
310
+ hasCompanion,
311
+ toggleCompanion,
312
+ toggleDraftGating,
313
+ toggleDraftGate,
314
+ toggleDraftEnabled,
315
+ toggleDraftConsensus,
316
+ setDraftConsensus,
317
+ clearDraft,
318
+ loadForEdit,
319
+ saveDraft,
320
+ clonePipeline,
321
+ removePipeline,
322
+ organize,
323
+ archive,
324
+ unarchive,
325
+ setLabels,
326
+ }
327
+ })