@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,152 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type {
4
+ Account,
5
+ AccountInvitation,
6
+ AccountMember,
7
+ AccountRole,
8
+ CloudProvider,
9
+ EmailConnection,
10
+ } from '~/types/domain'
11
+
12
+ /**
13
+ * Account tenancy on the client: the accounts the signed-in user can switch
14
+ * between (their personal account plus any orgs they belong to) and which one is
15
+ * active. The active account scopes the board switcher and stamps new boards, so
16
+ * a team can keep org boards separate from personal ones.
17
+ *
18
+ * Empty when auth is disabled (the backend returns no accounts in dev), in which
19
+ * case the UI simply hides the account switcher and boards stay unscoped.
20
+ */
21
+ export const useAccountsStore = defineStore(
22
+ 'accounts',
23
+ () => {
24
+ const api = useApi()
25
+
26
+ const accounts = ref<Account[]>([])
27
+ /** Active account id (persisted so a reload keeps the same context). */
28
+ const activeAccountId = ref<string | null>(null)
29
+ const ready = ref(false)
30
+
31
+ const activeAccount = computed(
32
+ () => accounts.value.find((a) => a.id === activeAccountId.value) ?? null,
33
+ )
34
+ /** Whether accounts exist (auth on); gates the switcher UI. */
35
+ const enabled = computed(() => accounts.value.length > 0)
36
+
37
+ /** Load the user's accounts and resolve the active one (persisted or first). */
38
+ async function load() {
39
+ accounts.value = await api.listAccounts()
40
+ if (!activeAccountId.value || !accounts.value.some((a) => a.id === activeAccountId.value)) {
41
+ activeAccountId.value = accounts.value[0]?.id ?? null
42
+ }
43
+ ready.value = true
44
+ }
45
+
46
+ /** Create a shared org account and make it active. */
47
+ async function createOrg(name: string) {
48
+ const account = await api.createAccount({ name })
49
+ accounts.value.push(account)
50
+ activeAccountId.value = account.id
51
+ return account
52
+ }
53
+
54
+ /** Switch the active account (the caller re-scopes the board list). */
55
+ function switchTo(id: string) {
56
+ activeAccountId.value = id
57
+ }
58
+
59
+ /**
60
+ * Set an account's default cloud provider (the provider new services inherit).
61
+ * Owner-only on the backend; patches the loaded account in place on success.
62
+ */
63
+ async function setDefaultCloudProvider(id: string, provider: CloudProvider) {
64
+ const updated = await api.updateAccount(id, { defaultCloudProvider: provider })
65
+ const i = accounts.value.findIndex((a) => a.id === id)
66
+ if (i >= 0) accounts.value[i] = updated
67
+ return updated
68
+ }
69
+
70
+ // ---- members + invitations -------------------------------------------
71
+
72
+ const members = ref<AccountMember[]>([])
73
+ const invitations = ref<AccountInvitation[]>([])
74
+
75
+ /** Load the active account's member roster + pending invitations. */
76
+ async function loadRoster(accountId: string) {
77
+ const [m, inv] = await Promise.all([
78
+ api.listAccountMembers(accountId),
79
+ api.listInvitations(accountId),
80
+ ])
81
+ members.value = m
82
+ invitations.value = inv
83
+ }
84
+
85
+ /** Invite a teammate by email; returns the accept link (for manual sharing). */
86
+ async function invite(accountId: string, email: string, roles: AccountRole[] = ['developer']) {
87
+ const { invitation, acceptUrl } = await api.createInvitation(accountId, { email, roles })
88
+ invitations.value = [invitation, ...invitations.value]
89
+ return acceptUrl
90
+ }
91
+
92
+ async function revokeInvite(accountId: string, invitationId: string) {
93
+ await api.revokeInvitation(accountId, invitationId)
94
+ invitations.value = invitations.value.filter((i) => i.id !== invitationId)
95
+ }
96
+
97
+ /** Set a member's role set (admin-only); patches the loaded roster in place. */
98
+ async function setMemberRoles(accountId: string, userId: string, roles: AccountRole[]) {
99
+ const updated = await api.setMemberRoles(accountId, userId, roles)
100
+ const i = members.value.findIndex((m) => m.userId === userId)
101
+ if (i >= 0) members.value[i] = updated
102
+ return updated
103
+ }
104
+
105
+ // ---- email sender connection -----------------------------------------
106
+
107
+ const emailConnection = ref<EmailConnection | null>(null)
108
+ const emailConfigured = ref(false)
109
+
110
+ async function loadEmailConnection(accountId: string) {
111
+ const res = await api.getEmailConnection(accountId)
112
+ emailConnection.value = res.connection
113
+ emailConfigured.value = res.configured
114
+ }
115
+
116
+ async function connectEmail(
117
+ accountId: string,
118
+ body: { provider: 'sendgrid' | 'resend'; apiKey: string; fromAddress: string },
119
+ ) {
120
+ emailConnection.value = await api.connectEmail(accountId, body)
121
+ }
122
+
123
+ async function disconnectEmail(accountId: string) {
124
+ await api.disconnectEmail(accountId)
125
+ emailConnection.value = null
126
+ }
127
+
128
+ return {
129
+ accounts,
130
+ activeAccountId,
131
+ activeAccount,
132
+ enabled,
133
+ ready,
134
+ members,
135
+ invitations,
136
+ emailConnection,
137
+ emailConfigured,
138
+ load,
139
+ createOrg,
140
+ switchTo,
141
+ setDefaultCloudProvider,
142
+ loadRoster,
143
+ invite,
144
+ revokeInvite,
145
+ setMemberRoles,
146
+ loadEmailConnection,
147
+ connectEmail,
148
+ disconnectEmail,
149
+ }
150
+ },
151
+ { persist: { pick: ['activeAccountId'] } },
152
+ )
@@ -0,0 +1,35 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type { AgentConfigDescriptor } from '~/types/domain'
4
+ import { usePipelinesStore } from '~/stores/pipelines'
5
+
6
+ /**
7
+ * The agent config-contribution catalog: the task-level parameters the registered
8
+ * agent kinds surface (e.g. the Tester's environment). Static metadata hydrated from
9
+ * the workspace snapshot. The task-creation form and inspector render the subset
10
+ * whose owning agent kind appears in the task's selected pipeline.
11
+ */
12
+ export const useAgentConfigStore = defineStore('agentConfig', () => {
13
+ const descriptors = ref<AgentConfigDescriptor[]>([])
14
+
15
+ function hydrate(list: AgentConfigDescriptor[]) {
16
+ descriptors.value = [...list]
17
+ }
18
+
19
+ /** The descriptors contributed by the agent kinds of the given pipeline (by id). */
20
+ function forPipeline(pipelineId: string | undefined): AgentConfigDescriptor[] {
21
+ if (!pipelineId) return []
22
+ const pipeline = usePipelinesStore().getPipeline(pipelineId)
23
+ if (!pipeline) return []
24
+ const kinds = new Set<string>(pipeline.agentKinds)
25
+ return descriptors.value.filter((d) => kinds.has(d.agentKind))
26
+ }
27
+
28
+ /** The descriptors contributed across a set of agent kinds (by id). */
29
+ function forKinds(kinds: Iterable<string>): AgentConfigDescriptor[] {
30
+ const set = new Set(kinds)
31
+ return descriptors.value.filter((d) => set.has(d.agentKind))
32
+ }
33
+
34
+ return { descriptors, hydrate, forPipeline, forKinds }
35
+ })
@@ -0,0 +1,122 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type { AgentFailure, AgentRunKind, BootstrapJob, StepSubtasks } from '~/types/domain'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+ import { useExecutionStore } from '~/stores/execution'
6
+
7
+ /**
8
+ * A coarse, per-block view of the current "agent run" against a block, regardless
9
+ * of which flow produced it — enough for the board to render a failure banner +
10
+ * retry and a "working…" progress badge uniformly. The rich step-level UI still
11
+ * reads the full {@link ExecutionInstance} from the execution store.
12
+ */
13
+ export interface AgentRunSummary {
14
+ blockId: string
15
+ kind: AgentRunKind
16
+ /** The run's own status: execution running|blocked|done|paused|failed,
17
+ * bootstrap pending|running|succeeded|failed. */
18
+ status: string
19
+ /** Id of the run, for the unified retry endpoint. */
20
+ runId: string
21
+ /** Structured failure when `status` is `failed`; null otherwise. */
22
+ failure: AgentFailure | null
23
+ /** Latest subtask counts for a live progress bar (null until reported). */
24
+ subtasks: StepSubtasks | null
25
+ }
26
+
27
+ /**
28
+ * Unified failure/retry surface over BOTH agent flows. Bootstrap runs are held
29
+ * here (they have no other home on the client); executions are read from the
30
+ * execution store so they're never duplicated. `byBlock` merges the two so a
31
+ * board card / inspector can look itself up and show the same failure banner +
32
+ * retry whether the block was made by a bootstrap or is running a task pipeline.
33
+ *
34
+ * This replaces the old bootstrap-only `bootstrap.byBlock`/`retry`, whose retry
35
+ * could silently vanish when the separate jobs projection failed to resolve.
36
+ */
37
+ export const useAgentRunsStore = defineStore('agentRuns', () => {
38
+ const api = useApi()
39
+ const execution = useExecutionStore()
40
+
41
+ /** Bootstrap runs for this workspace, newest-first. */
42
+ const bootstrapJobs = ref<BootstrapJob[]>([])
43
+
44
+ /** Replace the cached bootstrap runs with a server snapshot. */
45
+ function hydrate(jobs: BootstrapJob[]) {
46
+ bootstrapJobs.value = [...jobs].sort((a, b) => b.createdAt - a.createdAt)
47
+ }
48
+
49
+ /**
50
+ * Patch a bootstrap run from a real-time `bootstrap` event (or after launching
51
+ * one): replace it in place by id, else prepend it. Keeps the service card
52
+ * reactive to live progress without a refetch.
53
+ */
54
+ function upsertBootstrap(job: BootstrapJob) {
55
+ const i = bootstrapJobs.value.findIndex((j) => j.id === job.id)
56
+ if (i >= 0) bootstrapJobs.value[i] = job
57
+ else bootstrapJobs.value.unshift(job)
58
+ }
59
+
60
+ /**
61
+ * The current run summary per block, merged from execution instances (task
62
+ * runs) and bootstrap runs (service frames). Executions take a block first;
63
+ * a frame block has no execution, so the newest bootstrap run wins there.
64
+ */
65
+ const byBlock = computed<Record<string, AgentRunSummary>>(() => {
66
+ const map: Record<string, AgentRunSummary> = {}
67
+ for (const e of execution.instances) {
68
+ map[e.blockId] = {
69
+ blockId: e.blockId,
70
+ kind: 'execution',
71
+ status: e.status,
72
+ runId: e.id,
73
+ failure: e.failure ?? null,
74
+ subtasks: e.steps[e.currentStep]?.subtasks ?? null,
75
+ }
76
+ }
77
+ // `bootstrapJobs` is newest-first; keep the first (newest) seen per block.
78
+ for (const job of bootstrapJobs.value) {
79
+ if (!job.blockId || map[job.blockId]) continue
80
+ map[job.blockId] = {
81
+ blockId: job.blockId,
82
+ kind: 'bootstrap',
83
+ status: job.status,
84
+ runId: job.id,
85
+ failure: job.failure,
86
+ subtasks: job.subtasks,
87
+ }
88
+ }
89
+ return map
90
+ })
91
+
92
+ /**
93
+ * Retry a failed run (bootstrap or execution) via the unified endpoint, then
94
+ * refresh the snapshot so both stores rehydrate — the card flips from failed
95
+ * back to "working…" as a fresh run is dispatched server-side.
96
+ */
97
+ async function retry(runId: string) {
98
+ const ws = useWorkspaceStore()
99
+ const personal = usePersonalSubscriptionsStore()
100
+ // A failed run on a Claude-pinned block needs the retrying user's personal password;
101
+ // supplied from cache and prompted (then retried) on a 428, exactly like start.
102
+ await personal.withCredential(async (password) => {
103
+ await api.retryAgentRun(ws.requireId(), runId, password)
104
+ await ws.refresh()
105
+ })
106
+ }
107
+
108
+ /**
109
+ * Explicitly stop a running run (bootstrap or execution) via the unified endpoint:
110
+ * the backend kills the per-run container + tears down the durable driver, then
111
+ * marks the run cancelled. Refresh so both stores rehydrate and the card flips out
112
+ * of its "running" state. Returns the resolved kind so the caller can word a toast.
113
+ */
114
+ async function stop(runId: string): Promise<AgentRunKind> {
115
+ const ws = useWorkspaceStore()
116
+ const { kind } = await api.stopAgentRun(ws.requireId(), runId)
117
+ await ws.refresh()
118
+ return kind
119
+ }
120
+
121
+ return { bootstrapJobs, hydrate, upsertBootstrap, byBlock, retry, stop }
122
+ })
@@ -0,0 +1,40 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import { AGENT_ARCHETYPES, AGENT_BY_KIND, uid } from '~/utils/catalog'
4
+ import type { AgentArchetype, AgentKind } from '~/types/domain'
5
+
6
+ /**
7
+ * The agent palette. Seeded from the static catalog, but custom agents can be
8
+ * added at runtime (they show up in the pipeline builder). Newly created agents
9
+ * are also registered into AGENT_BY_KIND so the many components that look an
10
+ * agent up by kind keep rendering it correctly.
11
+ */
12
+ export const useAgentsStore = defineStore('agents', () => {
13
+ const archetypes = ref<AgentArchetype[]>([...AGENT_ARCHETYPES])
14
+
15
+ function get(kind: AgentKind) {
16
+ return AGENT_BY_KIND[kind]
17
+ }
18
+
19
+ function addAgent(input: {
20
+ label: string
21
+ description?: string
22
+ icon?: string
23
+ color?: string
24
+ }): AgentArchetype {
25
+ const archetype: AgentArchetype = {
26
+ // custom kinds are free-form ids; cast keeps the existing AgentKind typing happy
27
+ kind: uid('agent') as AgentKind,
28
+ label: input.label.trim() || 'Custom Agent',
29
+ description: input.description?.trim() || 'Custom agent.',
30
+ icon: input.icon || 'i-lucide-sparkles',
31
+ color: input.color || '#22d3ee',
32
+ }
33
+ // register for kind-based lookups across the app, then surface in the palette
34
+ AGENT_BY_KIND[archetype.kind] = archetype
35
+ archetypes.value.push(archetype)
36
+ return archetype
37
+ }
38
+
39
+ return { archetypes, get, addAgent }
40
+ })
@@ -0,0 +1,108 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type { AddApiKeyInput, ApiKey, ApiKeyProvider } from '~/types/domain'
4
+
5
+ /**
6
+ * The direct-provider API keys reachable from a workspace: the workspace's own keys
7
+ * plus the signed-in user's personal keys. Account-scoped keys (shared by every
8
+ * workspace in the account, admin-managed) are loaded separately via `loadAccountKeys`
9
+ * and surfaced in account/team settings. Onboarded via UI, stored encrypted + pooled by
10
+ * the backend; keys are write-only so only metadata + rolling-window usage is ever
11
+ * returned. `configuredProviders` drives the model picker (a direct model is selectable
12
+ * once a key for its provider is connected at any reachable scope).
13
+ */
14
+ export const useApiKeysStore = defineStore('apiKeys', () => {
15
+ const api = useApi()
16
+ const workspaceKeys = ref<ApiKey[]>([])
17
+ const userKeys = ref<ApiKey[]>([])
18
+ const accountKeys = ref<ApiKey[]>([])
19
+ const accountId = ref<string | null>(null)
20
+ const workspaceId = ref<string | null>(null)
21
+ const loading = ref(false)
22
+
23
+ async function load(ws: string) {
24
+ workspaceId.value = ws
25
+ loading.value = true
26
+ try {
27
+ const [wsRes, meRes] = await Promise.all([
28
+ api.listWorkspaceApiKeys(ws),
29
+ api.listMyApiKeys().catch(() => ({ keys: [] as ApiKey[] })),
30
+ ])
31
+ workspaceKeys.value = wsRes.keys
32
+ userKeys.value = meRes.keys
33
+ } finally {
34
+ loading.value = false
35
+ }
36
+ }
37
+
38
+ async function addWorkspaceKey(input: AddApiKeyInput) {
39
+ if (!workspaceId.value) return
40
+ const created = await api.addWorkspaceApiKey(workspaceId.value, input)
41
+ workspaceKeys.value = [...workspaceKeys.value, created]
42
+ }
43
+
44
+ async function removeWorkspaceKey(id: string) {
45
+ if (!workspaceId.value) return
46
+ await api.removeWorkspaceApiKey(workspaceId.value, id)
47
+ workspaceKeys.value = workspaceKeys.value.filter((k) => k.id !== id)
48
+ }
49
+
50
+ async function addUserKey(input: AddApiKeyInput) {
51
+ const created = await api.addMyApiKey(input)
52
+ userKeys.value = [...userKeys.value, created]
53
+ }
54
+
55
+ async function removeUserKey(id: string) {
56
+ await api.removeMyApiKey(id)
57
+ userKeys.value = userKeys.value.filter((k) => k.id !== id)
58
+ }
59
+
60
+ // ---- Account-scoped keys (admin-managed, shared by the account's workspaces) ----
61
+
62
+ async function loadAccountKeys(acc: string) {
63
+ accountId.value = acc
64
+ accountKeys.value = (await api.listAccountApiKeys(acc)).keys
65
+ }
66
+
67
+ async function addAccountKey(input: AddApiKeyInput) {
68
+ if (!accountId.value) return
69
+ const created = await api.addAccountApiKey(accountId.value, input)
70
+ accountKeys.value = [...accountKeys.value, created]
71
+ }
72
+
73
+ async function removeAccountKey(id: string) {
74
+ if (!accountId.value) return
75
+ await api.removeAccountApiKey(accountId.value, id)
76
+ accountKeys.value = accountKeys.value.filter((k) => k.id !== id)
77
+ }
78
+
79
+ /** Every key reachable from the workspace (workspace + user scopes), newest first. */
80
+ const allKeys = computed<ApiKey[]>(() => [...workspaceKeys.value, ...userKeys.value])
81
+
82
+ /** Providers with at least one reachable connected key. */
83
+ const configuredProviders = computed(
84
+ () => new Set<ApiKeyProvider>(allKeys.value.map((k) => k.provider)),
85
+ )
86
+
87
+ function hasProvider(provider: ApiKeyProvider | undefined): boolean {
88
+ return provider ? configuredProviders.value.has(provider) : false
89
+ }
90
+
91
+ return {
92
+ workspaceKeys,
93
+ userKeys,
94
+ accountKeys,
95
+ allKeys,
96
+ loading,
97
+ load,
98
+ addWorkspaceKey,
99
+ removeWorkspaceKey,
100
+ addUserKey,
101
+ removeUserKey,
102
+ loadAccountKeys,
103
+ addAccountKey,
104
+ removeAccountKey,
105
+ configuredProviders,
106
+ hasProvider,
107
+ }
108
+ })
@@ -0,0 +1,166 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type { AuthUser } from '~/types/domain'
4
+
5
+ /**
6
+ * "Login with GitHub" session state. The backend mints a signed session token
7
+ * and hands it back via a URL fragment after the OAuth round-trip; we persist
8
+ * just that token and replay it as a bearer header on every API call (see
9
+ * `useApi`). Auth is opt-in on the backend, so `required` gates whether the UI
10
+ * shows a login screen at all — when the backend has auth disabled the app runs
11
+ * exactly as before.
12
+ */
13
+ export const useAuthStore = defineStore(
14
+ 'auth',
15
+ () => {
16
+ const api = useApi()
17
+ const apiBase = useRuntimeConfig().public.apiBase
18
+
19
+ /** Signed session token (persisted), or null when signed out. */
20
+ const token = ref<string | null>(null)
21
+ /** The signed-in user, resolved from the token on boot. */
22
+ const user = ref<AuthUser | null>(null)
23
+ /** Whether the backend requires authentication. */
24
+ const required = ref(false)
25
+ /** Which login providers the backend offers (drives the login UI). */
26
+ const providers = ref({ github: false, password: false, google: false })
27
+ /**
28
+ * Local-mode signals from the backend. Present only when running the local facade;
29
+ * `githubPatSetupUrl` is set when local mode has no GitHub PAT configured (drives the
30
+ * setup banner). Null on every other facade.
31
+ */
32
+ const localMode = ref<{ enabled: boolean; githubPatSetupUrl?: string } | null>(null)
33
+ /** True once the initial auth handshake has settled. */
34
+ const ready = ref(false)
35
+
36
+ /** May the app render? True when auth is off, or on with a known user. */
37
+ const isAuthenticated = computed(() => !required.value || user.value !== null)
38
+
39
+ /** Pull a token handed back in the post-login URL fragment (#token=…). */
40
+ function consumeRedirectToken() {
41
+ if (typeof window === 'undefined') return
42
+ const match = /(?:^#|[#&])token=([^&]+)/.exec(window.location.hash)
43
+ if (!match) return
44
+ token.value = decodeURIComponent(match[1]!)
45
+ // Strip the token from the URL so it isn't left in history or shared.
46
+ history.replaceState(null, '', window.location.pathname + window.location.search)
47
+ }
48
+
49
+ /** Resolve auth state: capture any redirect token, then check the backend. */
50
+ async function bootstrap() {
51
+ consumeRedirectToken()
52
+ try {
53
+ const config = await api.getAuthConfig()
54
+ required.value = config.enabled
55
+ if (config.providers) providers.value = config.providers
56
+ localMode.value = config.localMode ?? null
57
+ } catch {
58
+ // Backend unreachable — let the board's own error UI handle it.
59
+ required.value = false
60
+ ready.value = true
61
+ return
62
+ }
63
+
64
+ if (required.value && token.value) {
65
+ try {
66
+ user.value = (await api.getMe()).user
67
+ } catch {
68
+ user.value = null
69
+ }
70
+ if (!user.value) token.value = null
71
+ }
72
+ // An already-signed-in user who followed an invite link redeems it here (a
73
+ // brand-new user redeems it server-side during signup/OAuth instead).
74
+ if (user.value) await maybeAcceptInvite()
75
+ ready.value = true
76
+ }
77
+
78
+ /** Redeem an `?invite=` token in the URL for the signed-in user, then clean the URL. */
79
+ async function maybeAcceptInvite() {
80
+ if (typeof window === 'undefined') return
81
+ const params = new URLSearchParams(window.location.search)
82
+ const inviteToken = params.get('invite')
83
+ if (!inviteToken) return
84
+ try {
85
+ await api.acceptInvite(inviteToken)
86
+ } catch {
87
+ // Stale/already-accepted invite — ignore and let the app load normally.
88
+ }
89
+ params.delete('invite')
90
+ const qs = params.toString()
91
+ history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : ''))
92
+ }
93
+
94
+ /** Build a post-login redirect back to the current page, with an optional invite. */
95
+ function redirectTarget(invite?: string): string {
96
+ const here = window.location.origin + window.location.pathname
97
+ const params = new URLSearchParams({ redirect: here })
98
+ if (invite) params.set('invite', invite)
99
+ return params.toString()
100
+ }
101
+
102
+ /** Send the browser to the backend's GitHub login, returning here after. */
103
+ function login(invite?: string) {
104
+ if (typeof window === 'undefined') return
105
+ window.location.href = `${apiBase}/auth/login?${redirectTarget(invite)}`
106
+ }
107
+
108
+ /** Send the browser to the backend's Google login, returning here after. */
109
+ function loginWithGoogle(invite?: string) {
110
+ if (typeof window === 'undefined') return
111
+ window.location.href = `${apiBase}/auth/google/login?${redirectTarget(invite)}`
112
+ }
113
+
114
+ /** Apply a freshly-minted token + user (from password signup/login). */
115
+ function applySession(result: { token: string; user: AuthUser }) {
116
+ token.value = result.token
117
+ user.value = result.user
118
+ }
119
+
120
+ /** Register a new email/password user (optionally redeeming an invite). */
121
+ async function signup(body: {
122
+ email: string
123
+ password: string
124
+ name?: string
125
+ invite?: string
126
+ }) {
127
+ applySession(await api.signup(body))
128
+ }
129
+
130
+ /** Sign in with email/password. */
131
+ async function passwordLogin(body: { email: string; password: string }) {
132
+ applySession(await api.passwordLogin(body))
133
+ }
134
+
135
+ /** Drop the local session (sessions are stateless server-side). */
136
+ function logout() {
137
+ api.logout().catch(() => {})
138
+ token.value = null
139
+ user.value = null
140
+ }
141
+
142
+ /** Called by the API client when a request comes back 401. */
143
+ function handleUnauthorized() {
144
+ token.value = null
145
+ user.value = null
146
+ }
147
+
148
+ return {
149
+ token,
150
+ user,
151
+ required,
152
+ providers,
153
+ localMode,
154
+ ready,
155
+ isAuthenticated,
156
+ bootstrap,
157
+ login,
158
+ loginWithGoogle,
159
+ signup,
160
+ passwordLogin,
161
+ logout,
162
+ handleUnauthorized,
163
+ }
164
+ },
165
+ { persist: { pick: ['token'] } },
166
+ )