@cat-factory/app 0.36.0 → 0.37.1

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 (88) 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/layout/IntegrationBackTitle.vue +12 -7
  5. package/app/components/layout/IntegrationsHub.vue +191 -43
  6. package/app/components/layout/PersonalSetupModal.vue +141 -0
  7. package/app/components/pipeline/PipelineBuilder.vue +1 -1
  8. package/app/components/providers/VendorCredentialsModal.vue +7 -2
  9. package/app/composables/api/accounts.ts +36 -51
  10. package/app/composables/api/auth.ts +20 -19
  11. package/app/composables/api/board.ts +60 -40
  12. package/app/composables/api/bootstrap.ts +25 -22
  13. package/app/composables/api/client.ts +102 -0
  14. package/app/composables/api/context.ts +25 -6
  15. package/app/composables/api/documents.ts +36 -34
  16. package/app/composables/api/execution.ts +65 -48
  17. package/app/composables/api/followUps.ts +26 -26
  18. package/app/composables/api/fragments.ts +47 -34
  19. package/app/composables/api/github.ts +65 -45
  20. package/app/composables/api/humanReview.ts +7 -6
  21. package/app/composables/api/humanTest.ts +15 -11
  22. package/app/composables/api/kaizen.ts +8 -6
  23. package/app/composables/api/localSettings.ts +5 -4
  24. package/app/composables/api/models.ts +58 -51
  25. package/app/composables/api/notifications.ts +13 -7
  26. package/app/composables/api/presets.ts +34 -28
  27. package/app/composables/api/providerConnections.ts +68 -26
  28. package/app/composables/api/recurring.ts +40 -30
  29. package/app/composables/api/releaseHealth.ts +28 -26
  30. package/app/composables/api/reviews.ts +136 -114
  31. package/app/composables/api/sandbox.ts +52 -34
  32. package/app/composables/api/slack.ts +22 -25
  33. package/app/composables/api/spec.ts +3 -3
  34. package/app/composables/api/tasks.ts +42 -41
  35. package/app/composables/api/userSecrets.ts +12 -17
  36. package/app/composables/api/workspaces.ts +21 -15
  37. package/app/composables/useApi.ts +9 -1
  38. package/app/composables/useIntegrationBack.ts +9 -3
  39. package/app/composables/useSourceIntegration.ts +107 -0
  40. package/app/composables/useUpsertList.spec.ts +60 -0
  41. package/app/composables/useUpsertList.ts +57 -0
  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/documents.ts +27 -62
  48. package/app/stores/execution.ts +3 -2
  49. package/app/stores/github.ts +1 -2
  50. package/app/stores/mergePresets.ts +2 -6
  51. package/app/stores/notifications.ts +9 -6
  52. package/app/stores/pipelines.ts +1 -1
  53. package/app/stores/recurringPipelines.ts +2 -7
  54. package/app/stores/sandbox.ts +1 -2
  55. package/app/stores/tasks.ts +25 -76
  56. package/app/stores/ui.ts +62 -19
  57. package/app/types/accountSettings.ts +11 -36
  58. package/app/types/accounts.ts +16 -71
  59. package/app/types/bootstrap.ts +13 -75
  60. package/app/types/brainstorm.ts +13 -38
  61. package/app/types/clarity.ts +12 -43
  62. package/app/types/consensus.ts +16 -89
  63. package/app/types/documents.ts +19 -94
  64. package/app/types/domain.ts +54 -586
  65. package/app/types/execution.ts +48 -515
  66. package/app/types/fragments.ts +15 -83
  67. package/app/types/github.ts +25 -161
  68. package/app/types/incidentEnrichment.ts +10 -25
  69. package/app/types/localModels.ts +11 -61
  70. package/app/types/localSettings.ts +9 -26
  71. package/app/types/merge.ts +10 -68
  72. package/app/types/model-presets.ts +7 -28
  73. package/app/types/models.ts +16 -164
  74. package/app/types/notifications.ts +18 -77
  75. package/app/types/openrouter.ts +8 -34
  76. package/app/types/providerConnections.ts +21 -41
  77. package/app/types/provisioningLogs.ts +9 -29
  78. package/app/types/recurring.ts +10 -63
  79. package/app/types/releaseHealth.ts +15 -39
  80. package/app/types/requirements.ts +14 -84
  81. package/app/types/sandbox.ts +45 -161
  82. package/app/types/services.ts +3 -22
  83. package/app/types/slack.ts +10 -47
  84. package/app/types/spec.ts +15 -68
  85. package/app/types/tasks.ts +15 -111
  86. package/app/types/tracker.ts +9 -24
  87. package/app/types/userSecrets.ts +12 -47
  88. package/package.json +9 -2
@@ -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
  }
@@ -0,0 +1,102 @@
1
+ import type {
2
+ ApiContract,
3
+ ClientRequestParams,
4
+ DefaultStreaming,
5
+ InferNonSseClientResponse,
6
+ SuccessfulHttpStatusCode,
7
+ } from '@toad-contracts/core'
8
+ import {
9
+ type ContractRequestOptions,
10
+ sendByApiContract,
11
+ type WretchInstance,
12
+ } from '@toad-contracts/frontend-http-client'
13
+ import wretch from 'wretch'
14
+
15
+ /**
16
+ * The validated success-response body inferred from a route contract (every REST
17
+ * endpoint here is non-SSE). This is what {@link ApiSend} resolves to.
18
+ */
19
+ export type SuccessBodyOf<T extends ApiContract> = Extract<
20
+ InferNonSseClientResponse<T>,
21
+ { statusCode: SuccessfulHttpStatusCode }
22
+ >['body']
23
+
24
+ /** The request params a contract requires (pathParams/body/queryParams/headers), per the contract. */
25
+ export type SendParams<T extends ApiContract> = ClientRequestParams<
26
+ T,
27
+ DefaultStreaming<T['responsesByStatusCode']>
28
+ > &
29
+ ContractRequestOptions<true>
30
+
31
+ /**
32
+ * A bound, throw-on-error sender: validates the response against the contract and
33
+ * returns the success body, or throws the typed error (an `UnexpectedResponseError`
34
+ * or a declared non-2xx response). Preserves the throwing ergonomics the Pinia
35
+ * stores already expect from the old `$fetch` client.
36
+ */
37
+ export type ApiSend = <T extends ApiContract>(
38
+ contract: T,
39
+ params: SendParams<T>,
40
+ ) => Promise<SuccessBodyOf<T>>
41
+
42
+ /**
43
+ * Build the authed wretch client. Ports the concerns the old `$fetch` client had:
44
+ * base URL from runtime config, a lazily-read bearer token (so a fresh token applies
45
+ * without rebuilding the client), and a 401 → re-gate.
46
+ */
47
+ export function createApiClient(): WretchInstance {
48
+ const apiBase = useRuntimeConfig().public.apiBase
49
+ return wretch(apiBase).middlewares([
50
+ (next) => async (url, opts) => {
51
+ const token = useAuthStore().token
52
+ if (token) {
53
+ opts.headers = {
54
+ ...(opts.headers as Record<string, string> | undefined),
55
+ Authorization: `Bearer ${token}`,
56
+ }
57
+ }
58
+ const response = await next(url, opts)
59
+ // A 401 means our token lapsed or was revoked — drop it so the UI re-gates.
60
+ if (response.status === 401) useAuthStore().handleUnauthorized()
61
+ return response
62
+ },
63
+ ])
64
+ }
65
+
66
+ /**
67
+ * Send a contract request and unwrap to the success body (or throw the typed error).
68
+ * The public signature preserves per-contract inference for callers; inside,
69
+ * sendByApiContract's deeply-conditional result type can't be proven equal to
70
+ * SuccessBodyOf<T> generically, so the success body is asserted at this single boundary.
71
+ */
72
+ export async function sendContract<T extends ApiContract>(
73
+ client: WretchInstance,
74
+ contract: T,
75
+ params: SendParams<T>,
76
+ ): Promise<SuccessBodyOf<T>> {
77
+ const outcome = await sendByApiContract(client, contract, params)
78
+ if (outcome.error) throw outcome.error
79
+ return outcome.result!.body as SuccessBodyOf<T>
80
+ }
81
+
82
+ /** Curry {@link sendContract} over a client into the throw-on-error {@link ApiSend}. */
83
+ export function createSend(client: WretchInstance): ApiSend {
84
+ return (contract, params) => sendContract(client, contract, params)
85
+ }
86
+
87
+ /**
88
+ * Like {@link ApiSend} but augments the request with ambient headers not modelled by the
89
+ * contract (the personal-subscription unlock password, mirroring how the bearer token
90
+ * rides outside the wire body). `undefined` headers send unchanged.
91
+ */
92
+ export type ApiSendWith = <T extends ApiContract>(
93
+ extraHeaders: Record<string, string> | undefined,
94
+ contract: T,
95
+ params: SendParams<T>,
96
+ ) => Promise<SuccessBodyOf<T>>
97
+
98
+ /** Curry {@link sendContract} with per-call ambient headers (see {@link ApiSendWith}). */
99
+ export function createSendWith(client: WretchInstance): ApiSendWith {
100
+ return (extraHeaders, contract, params) =>
101
+ sendContract(extraHeaders ? client.headers(extraHeaders) : client, contract, params)
102
+ }
@@ -1,19 +1,38 @@
1
+ import type { WretchInstance } from '@toad-contracts/frontend-http-client'
1
2
  import type { FragmentOwnerKind } from '~/types/domain'
3
+ import type { ApiSend, ApiSendWith } from './client'
2
4
 
3
5
  /** The authed `$fetch` instance type — Nuxt's augmented client, as returned by `$fetch.create`. */
4
6
  export type ApiHttp = ReturnType<typeof $fetch.create>
5
7
 
6
8
  /**
7
9
  * Shared plumbing handed to every grouped API module. `useApi()` builds one of
8
- * these (the authed `$fetch` client + the path/header helpers) and passes it to
9
- * each `*Api(ctx)` factory; the factories return the endpoint methods that
10
- * `useApi()` spreads into its single flat client object. Splitting the client
11
- * this way keeps call sites unchanged (`useApi().someMethod(...)`) while the
12
- * ~100 endpoints live in cohesive per-domain files.
10
+ * these (the authed wretch client, the contract `send` helper + the path/header
11
+ * helpers) and passes it to each `*Api(ctx)` factory; the factories return the
12
+ * endpoint methods that `useApi()` spreads into its single flat client object.
13
+ * Splitting the client this way keeps call sites unchanged
14
+ * (`useApi().someMethod(...)`) while the ~100 endpoints live in cohesive
15
+ * per-domain files.
13
16
  */
14
17
  export interface ApiContext {
15
- /** The authed `$fetch` instance (bearer token + 401 handling pre-wired). */
18
+ /**
19
+ * The authed `$fetch` instance (bearer token + 401 handling pre-wired).
20
+ * Transitional: API groups not yet migrated to contract `send` still use it.
21
+ */
16
22
  http: ApiHttp
23
+ /** The authed wretch client (bearer token + 401 handling pre-wired). */
24
+ client: WretchInstance
25
+ /**
26
+ * Contract sender: validates the response against the route contract and returns
27
+ * the success body, or throws the typed error. The single source of truth for
28
+ * path + method + request + response is the contract in `@cat-factory/contracts`.
29
+ */
30
+ send: ApiSend
31
+ /**
32
+ * Like {@link send} but attaches ambient headers the contract doesn't model — today the
33
+ * personal-subscription unlock password on gated run calls (individual-usage vendors).
34
+ */
35
+ sendWith: ApiSendWith
17
36
  /** `/workspaces/:id` path prefix (id encoded). */
18
37
  ws: (workspaceId: string) => string
19
38
  /** Prompt-fragment library prefix, resolved from the owner scope (ADR 0006 §8). */