@cat-factory/app 0.35.0 → 0.37.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 (87) hide show
  1. package/app/components/auth/UserMenu.vue +11 -1
  2. package/app/components/brainstorm/BrainstormWindow.vue +2 -1
  3. package/app/components/clarity/ClarityReviewWindow.vue +2 -1
  4. package/app/components/gates/GateResultView.vue +107 -12
  5. package/app/components/layout/IntegrationBackTitle.vue +12 -7
  6. package/app/components/layout/IntegrationsHub.vue +191 -43
  7. package/app/components/layout/NotificationsInbox.vue +16 -0
  8. package/app/components/layout/PersonalSetupModal.vue +141 -0
  9. package/app/components/pipeline/PipelineBuilder.vue +1 -1
  10. package/app/components/providers/VendorCredentialsModal.vue +7 -2
  11. package/app/components/slack/SlackPanel.vue +1 -0
  12. package/app/composables/api/accounts.ts +36 -51
  13. package/app/composables/api/auth.ts +20 -19
  14. package/app/composables/api/board.ts +60 -40
  15. package/app/composables/api/bootstrap.ts +25 -22
  16. package/app/composables/api/client.ts +102 -0
  17. package/app/composables/api/context.ts +25 -6
  18. package/app/composables/api/documents.ts +36 -34
  19. package/app/composables/api/execution.ts +65 -48
  20. package/app/composables/api/followUps.ts +26 -26
  21. package/app/composables/api/fragments.ts +47 -34
  22. package/app/composables/api/github.ts +65 -45
  23. package/app/composables/api/humanReview.ts +19 -0
  24. package/app/composables/api/humanTest.ts +15 -11
  25. package/app/composables/api/kaizen.ts +8 -6
  26. package/app/composables/api/localSettings.ts +5 -4
  27. package/app/composables/api/models.ts +58 -51
  28. package/app/composables/api/notifications.ts +13 -7
  29. package/app/composables/api/presets.ts +34 -28
  30. package/app/composables/api/providerConnections.ts +68 -26
  31. package/app/composables/api/recurring.ts +40 -30
  32. package/app/composables/api/releaseHealth.ts +28 -26
  33. package/app/composables/api/reviews.ts +136 -114
  34. package/app/composables/api/sandbox.ts +52 -34
  35. package/app/composables/api/slack.ts +22 -25
  36. package/app/composables/api/spec.ts +3 -3
  37. package/app/composables/api/tasks.ts +42 -41
  38. package/app/composables/api/userSecrets.ts +12 -17
  39. package/app/composables/api/workspaces.ts +21 -15
  40. package/app/composables/useApi.ts +11 -1
  41. package/app/composables/useIntegrationBack.ts +9 -3
  42. package/app/pages/index.vue +2 -0
  43. package/app/stores/auth.ts +2 -1
  44. package/app/stores/board.ts +2 -1
  45. package/app/stores/brainstorm.ts +2 -2
  46. package/app/stores/clarity.ts +6 -2
  47. package/app/stores/execution.ts +3 -2
  48. package/app/stores/github.ts +1 -2
  49. package/app/stores/humanReview.ts +41 -0
  50. package/app/stores/mergePresets.ts +2 -6
  51. package/app/stores/pipelines.ts +1 -1
  52. package/app/stores/recurringPipelines.ts +2 -7
  53. package/app/stores/sandbox.ts +1 -2
  54. package/app/stores/ui.ts +62 -19
  55. package/app/types/accountSettings.ts +11 -36
  56. package/app/types/accounts.ts +16 -71
  57. package/app/types/bootstrap.ts +13 -75
  58. package/app/types/brainstorm.ts +13 -38
  59. package/app/types/clarity.ts +12 -43
  60. package/app/types/consensus.ts +16 -89
  61. package/app/types/documents.ts +19 -94
  62. package/app/types/domain.ts +54 -582
  63. package/app/types/execution.ts +48 -499
  64. package/app/types/fragments.ts +15 -83
  65. package/app/types/github.ts +25 -161
  66. package/app/types/incidentEnrichment.ts +10 -25
  67. package/app/types/localModels.ts +11 -61
  68. package/app/types/localSettings.ts +9 -26
  69. package/app/types/merge.ts +10 -68
  70. package/app/types/model-presets.ts +7 -28
  71. package/app/types/models.ts +16 -164
  72. package/app/types/notifications.ts +18 -76
  73. package/app/types/openrouter.ts +8 -34
  74. package/app/types/providerConnections.ts +21 -41
  75. package/app/types/provisioningLogs.ts +9 -29
  76. package/app/types/recurring.ts +10 -63
  77. package/app/types/releaseHealth.ts +15 -39
  78. package/app/types/requirements.ts +14 -84
  79. package/app/types/sandbox.ts +45 -161
  80. package/app/types/services.ts +3 -22
  81. package/app/types/slack.ts +10 -47
  82. package/app/types/spec.ts +15 -68
  83. package/app/types/tasks.ts +15 -111
  84. package/app/types/tracker.ts +9 -24
  85. package/app/types/userSecrets.ts +12 -47
  86. package/app/utils/catalog.ts +12 -0
  87. package/package.json +9 -2
@@ -0,0 +1,141 @@
1
+ <script setup lang="ts">
2
+ // "My setup": the user-scoped sibling of the Integrations hub. It lists the connections
3
+ // that belong to the SIGNED-IN USER rather than the workspace — a personal GitHub token,
4
+ // own-machine local model runners, and individual-usage (personal) subscriptions — which
5
+ // previously sat, confusingly, among the workspace-wide integrations. Each row reuses the
6
+ // existing per-panel handlers via `ui.openFromPersonal(...)`, so opening one closes this
7
+ // hub, reveals that panel, and gives it a "Back to My setup" control (IntegrationBackTitle).
8
+ const ui = useUiStore()
9
+ const userSecrets = useUserSecretsStore()
10
+ const localModels = useLocalModelsStore()
11
+ const personalSubs = usePersonalSubscriptionsStore()
12
+
13
+ // Load the cheap user-scoped status whenever the hub opens, so each row's badge is accurate.
14
+ watch(
15
+ () => ui.personalSetupOpen,
16
+ (isOpen) => {
17
+ if (!isOpen) return
18
+ void userSecrets.load().catch(() => {})
19
+ void localModels.load().catch(() => {})
20
+ void personalSubs.load().catch(() => {})
21
+ },
22
+ )
23
+
24
+ const open = computed({
25
+ get: () => ui.personalSetupOpen,
26
+ set: (v: boolean) => (v ? ui.openPersonalSetup() : ui.closePersonalSetup()),
27
+ })
28
+
29
+ // One row. `connected` drives the badge; `status` is the connected-state line (a count or
30
+ // "Connected"). Mirrors the Integrations hub's row shape so the two hubs look identical.
31
+ interface PersonalItem {
32
+ key: string
33
+ icon: string
34
+ label: string
35
+ description: string
36
+ status?: string
37
+ connected: boolean
38
+ onClick: () => void
39
+ }
40
+
41
+ interface PersonalGroup {
42
+ title: string
43
+ items: PersonalItem[]
44
+ }
45
+
46
+ // Open a user-scoped panel from this hub (sets the "came from My setup" marker).
47
+ function go(fn: () => void) {
48
+ ui.openFromPersonal(fn)
49
+ }
50
+
51
+ const groups = computed<PersonalGroup[]>(() => {
52
+ const out: PersonalGroup[] = []
53
+
54
+ // --- Source control --------------------------------------------------------
55
+ const pat = !!userSecrets.statusFor('github_pat')
56
+ out.push({
57
+ title: 'Source control',
58
+ items: [
59
+ {
60
+ key: 'github-pat',
61
+ icon: 'i-lucide-key-round',
62
+ label: 'My GitHub token',
63
+ description: 'A personal access token used for runs you start (pushes, PRs, CI, merge).',
64
+ status: pat ? 'Connected' : undefined,
65
+ connected: pat,
66
+ onClick: () => go(ui.openUserSecrets),
67
+ },
68
+ ],
69
+ })
70
+
71
+ // --- Models ----------------------------------------------------------------
72
+ const runnerCount = localModels.endpoints.length
73
+ const subCount = personalSubs.subscriptions.length
74
+ out.push({
75
+ title: 'Models',
76
+ items: [
77
+ {
78
+ key: 'local-runners',
79
+ icon: 'i-lucide-server',
80
+ label: 'My local runners',
81
+ description: 'Your own-machine model runners (Ollama, LM Studio, vLLM…).',
82
+ status: runnerCount ? `${runnerCount} connected` : undefined,
83
+ connected: runnerCount > 0,
84
+ onClick: () => go(ui.openLocalModels),
85
+ },
86
+ {
87
+ key: 'personal-subs',
88
+ icon: 'i-lucide-user',
89
+ label: 'My subscriptions',
90
+ description:
91
+ 'Individual-usage plans you unlock with a personal password (Claude, GLM, Codex).',
92
+ status: subCount ? `${subCount} connected` : undefined,
93
+ connected: subCount > 0,
94
+ onClick: () => go(() => ui.openVendorCredentials('personal')),
95
+ },
96
+ ],
97
+ })
98
+
99
+ return out
100
+ })
101
+ </script>
102
+
103
+ <template>
104
+ <UModal v-model:open="open" title="My setup" :ui="{ content: 'max-w-xl' }">
105
+ <template #body>
106
+ <div class="space-y-5">
107
+ <p class="text-xs text-slate-400">
108
+ Your personal connections — used for runs you start, and visible only to you.
109
+ </p>
110
+
111
+ <section v-for="group in groups" :key="group.title">
112
+ <h3 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
113
+ {{ group.title }}
114
+ </h3>
115
+ <div class="space-y-1.5">
116
+ <button
117
+ v-for="item in group.items"
118
+ :key="item.key"
119
+ type="button"
120
+ class="flex w-full items-center gap-3 rounded-lg border border-slate-800 bg-slate-900/50 px-3 py-2.5 text-left transition hover:border-slate-700 hover:bg-slate-900"
121
+ @click="item.onClick()"
122
+ >
123
+ <UIcon :name="item.icon" class="h-5 w-5 shrink-0 text-slate-300" />
124
+ <div class="min-w-0 flex-1">
125
+ <div class="flex items-center gap-2">
126
+ <span class="truncate text-sm font-medium text-slate-100">{{ item.label }}</span>
127
+ <UBadge v-if="item.connected" color="success" variant="subtle" size="sm">
128
+ {{ item.status || 'Connected' }}
129
+ </UBadge>
130
+ <span v-else class="text-[11px] text-slate-500">Not connected</span>
131
+ </div>
132
+ <p class="truncate text-xs text-slate-400">{{ item.description }}</p>
133
+ </div>
134
+ <UIcon name="i-lucide-chevron-right" class="h-4 w-4 shrink-0 text-slate-500" />
135
+ </button>
136
+ </div>
137
+ </section>
138
+ </div>
139
+ </template>
140
+ </UModal>
141
+ </template>
@@ -31,7 +31,7 @@ function toggleGating(i: number) {
31
31
  if (!cfg) return
32
32
  cfg.gating = cfg.gating?.enabled
33
33
  ? { ...cfg.gating, enabled: false }
34
- : { enabled: true, minRisk: 0.6, minImpact: 0.6 }
34
+ : { enabled: true, minRisk: 0.6, minImpact: 0.6, onMissingEstimate: 'consensus' }
35
35
  }
36
36
  const agents = useAgentsStore()
37
37
  const ui = useUiStore()
@@ -22,7 +22,9 @@ const back = useIntegrationBack(open)
22
22
 
23
23
  // Horizontal tabs replace the old long vertical scroll: each credential kind is its own
24
24
  // section (pooled subscriptions, direct vendor keys, proxy/gateway keys, personal subs).
25
- const activeTab = ref('pool')
25
+ // Initialised from the ui store so a caller can deep-link to a tab — the user-scoped
26
+ // "My subscriptions" entry opens straight onto the `personal` tab.
27
+ const activeTab = ref(ui.vendorCredentialsTab)
26
28
  const tabs = [
27
29
  { value: 'pool', label: 'Workspace pool', icon: 'i-lucide-users', slot: 'pool' },
28
30
  { value: 'direct', label: 'Direct providers', icon: 'i-lucide-key-round', slot: 'direct' },
@@ -51,7 +53,10 @@ const token = ref('')
51
53
  const busy = ref(false)
52
54
 
53
55
  watch(open, (isOpen) => {
54
- if (isOpen && workspace.workspaceId) void creds.load(workspace.workspaceId)
56
+ if (!isOpen) return
57
+ // Honour a deep-linked tab each time the modal opens (e.g. "My subscriptions" → personal).
58
+ activeTab.value = ui.vendorCredentialsTab
59
+ if (workspace.workspaceId) void creds.load(workspace.workspaceId)
55
60
  })
56
61
 
57
62
  /** Step-by-step instructions for the selected vendor. */
@@ -45,6 +45,7 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
45
45
  // In-app only (not in ROUTABLE), but the map is exhaustive over the type.
46
46
  decision_required: { enabled: false, channel: '' },
47
47
  human_test_ready: { enabled: false, channel: '' },
48
+ human_review: { enabled: false, channel: '' },
48
49
  followup_pending: { enabled: false, channel: '' },
49
50
  })
50
51
  const mentionsEnabled = ref(false)
@@ -1,93 +1,78 @@
1
- import type {
2
- Account,
3
- AccountInvitation,
4
- AccountMember,
5
- AccountRole,
6
- AddMemberInput,
7
- EmailConnection,
8
- UpdateAccountInput,
9
- } from '~/types/domain'
10
- import type { AccountSettingsView, UpdateAccountSettingsInput } from '~/types/accountSettings'
1
+ import {
2
+ addAccountMemberContract,
3
+ connectEmailContract,
4
+ createAccountContract,
5
+ createInvitationContract,
6
+ disconnectEmailContract,
7
+ getAccountSettingsContract,
8
+ getEmailConnectionContract,
9
+ listAccountMembersContract,
10
+ listAccountsContract,
11
+ listInvitationsContract,
12
+ revokeInvitationContract,
13
+ setMemberRolesContract,
14
+ testEmailContract,
15
+ updateAccountContract,
16
+ updateAccountSettingsContract,
17
+ } from '@cat-factory/contracts'
18
+ import type { AccountRole, UpdateAccountInput } from '~/types/domain'
19
+ import type { UpdateAccountSettingsInput } from '~/types/accountSettings'
11
20
  import type { ApiContext } from './context'
12
21
 
13
22
  /** Account (tenancy) management: orgs, members, invitations + the email sender. */
14
- export function accountsApi({ http }: ApiContext) {
23
+ export function accountsApi({ send }: ApiContext) {
15
24
  return {
16
25
  // ---- accounts (tenancy) -----------------------------------------------
17
26
  // The accounts the user can switch between (personal + orgs), org creation
18
27
  // and membership management. Empty when auth is disabled (dev).
19
- listAccounts: () => http<Account[]>('/accounts'),
28
+ listAccounts: () => send(listAccountsContract, {}),
20
29
 
21
30
  createAccount: (body: { name: string; githubAccountLogin?: string }) =>
22
- http<Account>('/accounts', { method: 'POST', body }),
31
+ send(createAccountContract, { body }),
23
32
 
24
33
  updateAccount: (accountId: string, body: UpdateAccountInput) =>
25
- http<Account>(`/accounts/${encodeURIComponent(accountId)}`, { method: 'PATCH', body }),
34
+ send(updateAccountContract, { pathParams: { accountId }, body }),
26
35
 
27
36
  listAccountMembers: (accountId: string) =>
28
- http<AccountMember[]>(`/accounts/${encodeURIComponent(accountId)}/members`),
37
+ send(listAccountMembersContract, { pathParams: { accountId } }),
29
38
 
30
- addAccountMember: (accountId: string, body: AddMemberInput) =>
31
- http<AccountMember>(`/accounts/${encodeURIComponent(accountId)}/members`, {
32
- method: 'POST',
33
- body,
34
- }),
39
+ addAccountMember: (accountId: string, body: { userId: string; roles?: AccountRole[] }) =>
40
+ send(addAccountMemberContract, { pathParams: { accountId }, body }),
35
41
 
36
42
  setMemberRoles: (accountId: string, userId: string, roles: AccountRole[]) =>
37
- http<AccountMember>(
38
- `/accounts/${encodeURIComponent(accountId)}/members/${encodeURIComponent(userId)}/roles`,
39
- { method: 'PATCH', body: { roles } },
40
- ),
43
+ send(setMemberRolesContract, { pathParams: { accountId, userId }, body: { roles } }),
41
44
 
42
45
  // Invitations: invite teammates by email into an org account.
43
46
  listInvitations: (accountId: string) =>
44
- http<AccountInvitation[]>(`/accounts/${encodeURIComponent(accountId)}/invitations`),
47
+ send(listInvitationsContract, { pathParams: { accountId } }),
45
48
 
46
49
  createInvitation: (accountId: string, body: { email: string; roles?: AccountRole[] }) =>
47
- http<{ invitation: AccountInvitation; acceptUrl: string | null }>(
48
- `/accounts/${encodeURIComponent(accountId)}/invitations`,
49
- { method: 'POST', body },
50
- ),
50
+ send(createInvitationContract, { pathParams: { accountId }, body }),
51
51
 
52
52
  revokeInvitation: (accountId: string, invitationId: string) =>
53
- http(
54
- `/accounts/${encodeURIComponent(accountId)}/invitations/${encodeURIComponent(invitationId)}`,
55
- { method: 'DELETE' },
56
- ),
53
+ send(revokeInvitationContract, { pathParams: { accountId, invitationId } }),
57
54
 
58
55
  // Per-account email sender (UI-onboarded): connect/inspect/disconnect/test.
59
56
  getEmailConnection: (accountId: string) =>
60
- http<{ connection: EmailConnection | null; configured: boolean }>(
61
- `/accounts/${encodeURIComponent(accountId)}/email-connection`,
62
- ),
57
+ send(getEmailConnectionContract, { pathParams: { accountId } }),
63
58
 
64
59
  connectEmail: (
65
60
  accountId: string,
66
61
  body: { provider: 'sendgrid' | 'resend'; apiKey: string; fromAddress: string },
67
- ) =>
68
- http<EmailConnection>(`/accounts/${encodeURIComponent(accountId)}/email-connection`, {
69
- method: 'POST',
70
- body,
71
- }),
62
+ ) => send(connectEmailContract, { pathParams: { accountId }, body }),
72
63
 
73
64
  disconnectEmail: (accountId: string) =>
74
- http(`/accounts/${encodeURIComponent(accountId)}/email-connection`, { method: 'DELETE' }),
65
+ send(disconnectEmailContract, { pathParams: { accountId } }),
75
66
 
76
67
  testEmail: (accountId: string, to: string) =>
77
- http<{ ok: boolean }>(`/accounts/${encodeURIComponent(accountId)}/email-connection/test`, {
78
- method: 'POST',
79
- body: { to },
80
- }),
68
+ send(testEmailContract, { pathParams: { accountId }, body: { to } }),
81
69
 
82
70
  // Per-account deployment settings (admin only): integration secrets (Slack OAuth +
83
71
  // web-search keys), sealed at rest. Read returns config + non-secret summary only.
84
72
  getAccountSettings: (accountId: string) =>
85
- http<AccountSettingsView>(`/accounts/${encodeURIComponent(accountId)}/settings`),
73
+ send(getAccountSettingsContract, { pathParams: { accountId } }),
86
74
 
87
75
  updateAccountSettings: (accountId: string, body: UpdateAccountSettingsInput) =>
88
- http<AccountSettingsView>(`/accounts/${encodeURIComponent(accountId)}/settings`, {
89
- method: 'PUT',
90
- body,
91
- }),
76
+ send(updateAccountSettingsContract, { pathParams: { accountId }, body }),
92
77
  }
93
78
  }
@@ -1,42 +1,43 @@
1
- import type { AuthUser } from '~/types/domain'
1
+ import {
2
+ acceptInvitationContract,
3
+ authConfigContract,
4
+ logoutContract,
5
+ meContract,
6
+ passwordLoginContract,
7
+ peekInvitationContract,
8
+ signupContract,
9
+ } from '@cat-factory/contracts'
2
10
  import type { ApiContext } from './context'
3
11
 
4
12
  /** Auth/session endpoints + the events-WebSocket ticket mint. */
5
- export function authApi({ http, ws }: ApiContext) {
13
+ export function authApi({ http, send, ws }: ApiContext) {
6
14
  return {
7
15
  // ---- auth -------------------------------------------------------------
8
- getAuthConfig: () =>
9
- http<{
10
- enabled: boolean
11
- providers?: { github: boolean; password: boolean; google: boolean }
12
- /** Local-mode signals; present only when the backend is the local facade. */
13
- localMode?: { enabled: boolean; githubPatSetupUrl?: string }
14
- }>('/auth/config'),
16
+ // The `/auth/*` JSON endpoints are mounted under the `/auth` prefix; their
17
+ // contract paths are relative to it.
18
+ getAuthConfig: () => send(authConfigContract, { pathPrefix: '/auth' }),
15
19
 
16
- getMe: () => http<{ user: AuthUser | null; enabled: boolean }>('/auth/me'),
20
+ getMe: () => send(meContract, { pathPrefix: '/auth' }),
17
21
 
18
22
  signup: (body: { email: string; password: string; name?: string; invite?: string }) =>
19
- http<{ token: string; user: AuthUser }>('/auth/signup', { method: 'POST', body }),
23
+ send(signupContract, { pathPrefix: '/auth', body }),
20
24
 
21
25
  passwordLogin: (body: { email: string; password: string }) =>
22
- http<{ token: string; user: AuthUser }>('/auth/password-login', { method: 'POST', body }),
26
+ send(passwordLoginContract, { pathPrefix: '/auth', body }),
23
27
 
24
28
  peekInvite: (token: string) =>
25
- http<{ valid: boolean; email?: string; accountName?: string | null }>(
26
- `/auth/invitations/${encodeURIComponent(token)}`,
27
- ),
29
+ send(peekInvitationContract, { pathPrefix: '/auth', pathParams: { token } }),
28
30
 
29
31
  acceptInvite: (token: string) =>
30
- http<{ accountId: string }>(`/auth/invitations/${encodeURIComponent(token)}/accept`, {
31
- method: 'POST',
32
- }),
32
+ send(acceptInvitationContract, { pathPrefix: '/auth', pathParams: { token } }),
33
33
 
34
- logout: () => http('/auth/logout', { method: 'POST' }),
34
+ logout: () => send(logoutContract, { pathPrefix: '/auth' }),
35
35
 
36
36
  // Mint a short-lived, workspace-scoped ticket for the events WebSocket. A
37
37
  // browser can't set Authorization on a WS handshake, so the socket auths from
38
38
  // this `?ticket=` instead of the long-lived session token. Empty string when
39
39
  // auth is disabled (dev) — the handshake is open in that case.
40
+ // No route contract exists for this endpoint, so it stays on the raw `http` client.
40
41
  mintEventsTicket: (workspaceId: string) =>
41
42
  http<{ ticket: string; expiresInMs?: number }>(`${ws(workspaceId)}/events/ticket`, {
42
43
  method: 'POST',
@@ -1,34 +1,42 @@
1
- import type { Block, BlockType, CreateTaskType, Pipeline, TaskTypeFields } from '~/types/domain'
2
- import type { ConsensusStepConfig, StepGating } from '~/types/consensus'
1
+ import {
2
+ addEpicContract,
3
+ addFrameContract,
4
+ addModuleContract,
5
+ addServiceFromRepoContract,
6
+ addTaskContract,
7
+ assignEpicContract,
8
+ clonePipelineContract,
9
+ createPipelineContract,
10
+ deletePipelineContract,
11
+ listPipelinesContract,
12
+ moveBlockContract,
13
+ organizePipelineContract,
14
+ removeBlockContract,
15
+ reparentBlockContract,
16
+ toggleDependencyContract,
17
+ updateBlockContract,
18
+ updatePipelineContract,
19
+ } from '@cat-factory/contracts'
20
+ import type {
21
+ CreatePipelineInput,
22
+ UpdateBlockInput,
23
+ UpdatePipelineInput,
24
+ } from '@cat-factory/contracts'
25
+ import type { BlockType, CreateTaskType, TaskTypeFields } from '~/types/domain'
3
26
  import type { ApiContext, Position } from './context'
4
27
 
5
- /**
6
- * Create/update body for a pipeline. `name`+`agentKinds` required on create, all optional on
7
- * update; the parallel arrays are aligned to `agentKinds` and persisted only when non-default.
8
- */
9
- interface PipelineWriteBody {
10
- name?: string
11
- agentKinds?: string[]
12
- gates?: boolean[]
13
- thresholds?: (number | null)[]
14
- enabled?: boolean[]
15
- consensus?: (ConsensusStepConfig | null)[]
16
- gating?: (StepGating | null)[]
17
- labels?: string[]
18
- }
19
-
20
28
  /** Board structure: block (frame/module/task) mutations + the pipeline library. */
21
- export function boardApi({ http, ws }: ApiContext) {
29
+ export function boardApi({ send, ws }: ApiContext) {
22
30
  return {
23
31
  // ---- blocks -----------------------------------------------------------
24
32
  addFrame: (workspaceId: string, body: { type: BlockType; position: Position }) =>
25
- http<Block>(`${ws(workspaceId)}/blocks`, { method: 'POST', body }),
33
+ send(addFrameContract, { pathPrefix: ws(workspaceId), body }),
26
34
 
27
35
  // Import an existing GitHub repo as a service frame (no bootstrap run).
28
36
  addServiceFromRepo: (
29
37
  workspaceId: string,
30
38
  body: { repoGithubId: number; position?: Position; directory?: string; isMonorepo?: boolean },
31
- ) => http<Block>(`${ws(workspaceId)}/blocks/from-repo`, { method: 'POST', body }),
39
+ ) => send(addServiceFromRepoContract, { pathPrefix: ws(workspaceId), body }),
32
40
 
33
41
  addTask: (
34
42
  workspaceId: string,
@@ -44,54 +52,65 @@ export function boardApi({ http, ws }: ApiContext) {
44
52
  agentConfig?: Record<string, string>
45
53
  technical?: boolean
46
54
  },
47
- ) => http<Block>(`${ws(workspaceId)}/blocks/${blockId}/tasks`, { method: 'POST', body }),
55
+ ) => send(addTaskContract, { pathPrefix: ws(workspaceId), pathParams: { blockId }, body }),
48
56
 
49
57
  addModule: (
50
58
  workspaceId: string,
51
59
  blockId: string,
52
60
  body: { name: string; position?: Position },
53
- ) => http<Block>(`${ws(workspaceId)}/blocks/${blockId}/modules`, { method: 'POST', body }),
61
+ ) => send(addModuleContract, { pathPrefix: ws(workspaceId), pathParams: { blockId }, body }),
54
62
 
55
63
  // Create an epic grouping node (optionally placed under a service/module).
56
64
  addEpic: (
57
65
  workspaceId: string,
58
66
  body: { title: string; description?: string; position: Position; parentId?: string },
59
- ) => http<Block>(`${ws(workspaceId)}/epics`, { method: 'POST', body }),
67
+ ) => send(addEpicContract, { pathPrefix: ws(workspaceId), body }),
60
68
 
61
69
  // Assign a task to an epic, or detach it (epicId: null).
62
70
  assignToEpic: (workspaceId: string, blockId: string, body: { epicId: string | null }) =>
63
- http<Block>(`${ws(workspaceId)}/blocks/${blockId}/epic`, { method: 'POST', body }),
71
+ send(assignEpicContract, { pathPrefix: ws(workspaceId), pathParams: { blockId }, body }),
64
72
 
65
- updateBlock: (workspaceId: string, blockId: string, body: Partial<Block>) =>
66
- http<Block>(`${ws(workspaceId)}/blocks/${blockId}`, { method: 'PATCH', body }),
73
+ updateBlock: (workspaceId: string, blockId: string, body: UpdateBlockInput) =>
74
+ send(updateBlockContract, { pathPrefix: ws(workspaceId), pathParams: { blockId }, body }),
67
75
 
68
76
  moveBlock: (workspaceId: string, blockId: string, body: { position: Position }) =>
69
- http<Block>(`${ws(workspaceId)}/blocks/${blockId}/move`, { method: 'POST', body }),
77
+ send(moveBlockContract, { pathPrefix: ws(workspaceId), pathParams: { blockId }, body }),
70
78
 
71
79
  reparentBlock: (
72
80
  workspaceId: string,
73
81
  blockId: string,
74
82
  body: { parentId: string; position: Position },
75
- ) => http<Block>(`${ws(workspaceId)}/blocks/${blockId}/reparent`, { method: 'POST', body }),
83
+ ) =>
84
+ send(reparentBlockContract, { pathPrefix: ws(workspaceId), pathParams: { blockId }, body }),
76
85
 
77
86
  removeBlock: (workspaceId: string, blockId: string) =>
78
- http(`${ws(workspaceId)}/blocks/${blockId}`, { method: 'DELETE' }),
87
+ send(removeBlockContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
79
88
 
80
89
  toggleDependency: (workspaceId: string, blockId: string, body: { sourceId: string }) =>
81
- http<Block>(`${ws(workspaceId)}/blocks/${blockId}/dependencies`, { method: 'POST', body }),
90
+ send(toggleDependencyContract, {
91
+ pathPrefix: ws(workspaceId),
92
+ pathParams: { blockId },
93
+ body,
94
+ }),
82
95
 
83
96
  // ---- pipelines --------------------------------------------------------
84
- listPipelines: (workspaceId: string) => http<Pipeline[]>(`${ws(workspaceId)}/pipelines`),
97
+ listPipelines: (workspaceId: string) =>
98
+ send(listPipelinesContract, { pathPrefix: ws(workspaceId) }),
85
99
 
86
- createPipeline: (workspaceId: string, body: PipelineWriteBody) =>
87
- http<Pipeline>(`${ws(workspaceId)}/pipelines`, { method: 'POST', body }),
100
+ createPipeline: (workspaceId: string, body: CreatePipelineInput) =>
101
+ send(createPipelineContract, { pathPrefix: ws(workspaceId), body }),
88
102
 
89
- updatePipeline: (workspaceId: string, pipelineId: string, body: PipelineWriteBody) =>
90
- http<Pipeline>(`${ws(workspaceId)}/pipelines/${pipelineId}`, { method: 'PATCH', body }),
103
+ updatePipeline: (workspaceId: string, pipelineId: string, body: UpdatePipelineInput) =>
104
+ send(updatePipelineContract, {
105
+ pathPrefix: ws(workspaceId),
106
+ pathParams: { pipelineId },
107
+ body,
108
+ }),
91
109
 
92
110
  clonePipeline: (workspaceId: string, pipelineId: string, body: { name?: string } = {}) =>
93
- http<Pipeline>(`${ws(workspaceId)}/pipelines/${pipelineId}/clone`, {
94
- method: 'POST',
111
+ send(clonePipelineContract, {
112
+ pathPrefix: ws(workspaceId),
113
+ pathParams: { pipelineId },
95
114
  body,
96
115
  }),
97
116
 
@@ -102,12 +121,13 @@ export function boardApi({ http, ws }: ApiContext) {
102
121
  pipelineId: string,
103
122
  body: { labels?: string[]; archived?: boolean },
104
123
  ) =>
105
- http<Pipeline>(`${ws(workspaceId)}/pipelines/${pipelineId}/organize`, {
106
- method: 'PATCH',
124
+ send(organizePipelineContract, {
125
+ pathPrefix: ws(workspaceId),
126
+ pathParams: { pipelineId },
107
127
  body,
108
128
  }),
109
129
 
110
130
  removePipeline: (workspaceId: string, pipelineId: string) =>
111
- http(`${ws(workspaceId)}/pipelines/${pipelineId}`, { method: 'DELETE' }),
131
+ send(deletePipelineContract, { pathPrefix: ws(workspaceId), pathParams: { pipelineId } }),
112
132
  }
113
133
  }
@@ -1,10 +1,15 @@
1
+ import {
2
+ createReferenceArchitectureContract,
3
+ deleteReferenceArchitectureContract,
4
+ listReferenceArchitecturesContract,
5
+ retryAgentRunContract,
6
+ startBootstrapJobContract,
7
+ stopAgentRunContract,
8
+ updateReferenceArchitectureContract,
9
+ } from '@cat-factory/contracts'
1
10
  import type {
2
- AgentRunKind,
3
- BootstrapJob,
4
11
  BootstrapRepoInput,
5
12
  CreateReferenceArchitectureInput,
6
- ExecutionInstance,
7
- ReferenceArchitecture,
8
13
  UpdateReferenceArchitectureInput,
9
14
  } from '~/types/domain'
10
15
  import type { ApiContext } from './context'
@@ -13,50 +18,48 @@ import type { ApiContext } from './context'
13
18
  * Repo bootstrap (reference architectures + bootstrap jobs) and the unified
14
19
  * agent-run failure/retry/stop surface shared by bootstrap + execution runs.
15
20
  */
16
- export function bootstrapApi({ http, ws, pwHeaders }: ApiContext) {
21
+ export function bootstrapApi({ send, sendWith, ws, pwHeaders }: ApiContext) {
17
22
  return {
18
23
  // ---- repo bootstrap ---------------------------------------------------
19
24
  listReferenceArchitectures: (workspaceId: string) =>
20
- http<ReferenceArchitecture[]>(`${ws(workspaceId)}/bootstrap/reference-architectures`),
25
+ send(listReferenceArchitecturesContract, { pathPrefix: ws(workspaceId) }),
21
26
 
22
27
  createReferenceArchitecture: (workspaceId: string, body: CreateReferenceArchitectureInput) =>
23
- http<ReferenceArchitecture>(`${ws(workspaceId)}/bootstrap/reference-architectures`, {
24
- method: 'POST',
25
- body,
26
- }),
28
+ send(createReferenceArchitectureContract, { pathPrefix: ws(workspaceId), body }),
27
29
 
28
30
  updateReferenceArchitecture: (
29
31
  workspaceId: string,
30
32
  id: string,
31
33
  body: UpdateReferenceArchitectureInput,
32
34
  ) =>
33
- http<ReferenceArchitecture>(`${ws(workspaceId)}/bootstrap/reference-architectures/${id}`, {
34
- method: 'PATCH',
35
+ send(updateReferenceArchitectureContract, {
36
+ pathPrefix: ws(workspaceId),
37
+ pathParams: { id },
35
38
  body,
36
39
  }),
37
40
 
38
41
  deleteReferenceArchitecture: (workspaceId: string, id: string) =>
39
- http(`${ws(workspaceId)}/bootstrap/reference-architectures/${id}`, { method: 'DELETE' }),
42
+ send(deleteReferenceArchitectureContract, {
43
+ pathPrefix: ws(workspaceId),
44
+ pathParams: { id },
45
+ }),
40
46
 
41
47
  bootstrapRepo: (workspaceId: string, body: BootstrapRepoInput) =>
42
- http<BootstrapJob>(`${ws(workspaceId)}/bootstrap/jobs`, { method: 'POST', body }),
48
+ send(startBootstrapJobContract, { pathPrefix: ws(workspaceId), body }),
43
49
 
44
50
  // ---- agent runs (unified failure + retry) -----------------------------
45
51
  // Retry any failed run (bootstrap or execution); the backend resolves the
46
52
  // kind from the unified `agent_runs` table and re-drives the right flow.
47
53
  retryAgentRun: (workspaceId: string, runId: string, password?: string) =>
48
- http<{ kind: AgentRunKind; run: ExecutionInstance | BootstrapJob }>(
49
- `${ws(workspaceId)}/agent-runs/${encodeURIComponent(runId)}/retry`,
50
- { method: 'POST', headers: pwHeaders(password) },
51
- ),
54
+ sendWith(pwHeaders(password), retryAgentRunContract, {
55
+ pathPrefix: ws(workspaceId),
56
+ pathParams: { id: runId },
57
+ }),
52
58
 
53
59
  // Explicitly stop a running run (bootstrap or execution): the backend kills the
54
60
  // per-run container and tears down the durable driver, then marks the run
55
61
  // terminally cancelled so the board stops showing it as running.
56
62
  stopAgentRun: (workspaceId: string, runId: string) =>
57
- http<{ kind: AgentRunKind; run: ExecutionInstance | BootstrapJob }>(
58
- `${ws(workspaceId)}/agent-runs/${encodeURIComponent(runId)}/stop`,
59
- { method: 'POST' },
60
- ),
63
+ send(stopAgentRunContract, { pathPrefix: ws(workspaceId), pathParams: { id: runId } }),
61
64
  }
62
65
  }