@shipfox/api-definitions 12.3.0 → 14.0.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 (223) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +45 -0
  3. package/dist/core/entities/index.d.ts +1 -1
  4. package/dist/core/entities/index.d.ts.map +1 -1
  5. package/dist/core/entities/index.js +1 -1
  6. package/dist/core/entities/index.js.map +1 -1
  7. package/dist/core/entities/integration-context.d.ts +4 -0
  8. package/dist/core/entities/integration-context.d.ts.map +1 -1
  9. package/dist/core/entities/integration-context.js.map +1 -1
  10. package/dist/core/entities/sync-state.d.ts +10 -4
  11. package/dist/core/entities/sync-state.d.ts.map +1 -1
  12. package/dist/core/entities/sync-state.js +26 -7
  13. package/dist/core/entities/sync-state.js.map +1 -1
  14. package/dist/core/entities/validation-diagnostic.d.ts +8 -0
  15. package/dist/core/entities/validation-diagnostic.d.ts.map +1 -0
  16. package/dist/core/entities/validation-diagnostic.js +3 -0
  17. package/dist/core/entities/validation-diagnostic.js.map +1 -0
  18. package/dist/core/entities/workflow-definition.d.ts +2 -0
  19. package/dist/core/entities/workflow-definition.d.ts.map +1 -1
  20. package/dist/core/entities/workflow-definition.js.map +1 -1
  21. package/dist/core/errors.d.ts +10 -0
  22. package/dist/core/errors.d.ts.map +1 -1
  23. package/dist/core/errors.js +9 -0
  24. package/dist/core/errors.js.map +1 -1
  25. package/dist/core/index.d.ts +2 -1
  26. package/dist/core/index.d.ts.map +1 -1
  27. package/dist/core/index.js +2 -1
  28. package/dist/core/index.js.map +1 -1
  29. package/dist/core/integrations.d.ts +3 -1
  30. package/dist/core/integrations.d.ts.map +1 -1
  31. package/dist/core/integrations.js +10 -2
  32. package/dist/core/integrations.js.map +1 -1
  33. package/dist/core/parse-definition.d.ts +4 -4
  34. package/dist/core/parse-definition.d.ts.map +1 -1
  35. package/dist/core/parse-definition.js +5 -5
  36. package/dist/core/parse-definition.js.map +1 -1
  37. package/dist/core/resolve-definition-at-ref.d.ts +66 -0
  38. package/dist/core/resolve-definition-at-ref.d.ts.map +1 -0
  39. package/dist/core/resolve-definition-at-ref.js +374 -0
  40. package/dist/core/resolve-definition-at-ref.js.map +1 -0
  41. package/dist/core/sync-definitions.d.ts +2 -2
  42. package/dist/core/sync-definitions.d.ts.map +1 -1
  43. package/dist/core/sync-definitions.js +5 -5
  44. package/dist/core/sync-definitions.js.map +1 -1
  45. package/dist/core/validate-definition.d.ts +3 -3
  46. package/dist/core/validate-definition.d.ts.map +1 -1
  47. package/dist/core/validate-definition.js +17 -12
  48. package/dist/core/validate-definition.js.map +1 -1
  49. package/dist/core/workflow-model/cron-trigger.d.ts +1 -1
  50. package/dist/core/workflow-model/cron-trigger.d.ts.map +1 -1
  51. package/dist/core/workflow-model/cron-trigger.js +9 -5
  52. package/dist/core/workflow-model/cron-trigger.js.map +1 -1
  53. package/dist/core/workflow-model/index.d.ts +1 -1
  54. package/dist/core/workflow-model/index.d.ts.map +1 -1
  55. package/dist/core/workflow-model/index.js.map +1 -1
  56. package/dist/core/workflow-model/invalid-workflow-model-error.d.ts +4 -2
  57. package/dist/core/workflow-model/invalid-workflow-model-error.d.ts.map +1 -1
  58. package/dist/core/workflow-model/invalid-workflow-model-error.js.map +1 -1
  59. package/dist/core/workflow-model/normalize-job-listening.d.ts.map +1 -1
  60. package/dist/core/workflow-model/normalize-job-listening.js +51 -30
  61. package/dist/core/workflow-model/normalize-job-listening.js.map +1 -1
  62. package/dist/core/workflow-model/normalize-triggers.d.ts +1 -1
  63. package/dist/core/workflow-model/normalize-triggers.d.ts.map +1 -1
  64. package/dist/core/workflow-model/normalize-triggers.js +48 -18
  65. package/dist/core/workflow-model/normalize-triggers.js.map +1 -1
  66. package/dist/core/workflow-model/normalize-workflow-document.d.ts +1 -1
  67. package/dist/core/workflow-model/normalize-workflow-document.d.ts.map +1 -1
  68. package/dist/core/workflow-model/normalize-workflow-document.js +3 -3
  69. package/dist/core/workflow-model/normalize-workflow-document.js.map +1 -1
  70. package/dist/core/workflow-model/parse-interpolation-field.d.ts.map +1 -1
  71. package/dist/core/workflow-model/parse-interpolation-field.js +1 -15
  72. package/dist/core/workflow-model/parse-interpolation-field.js.map +1 -1
  73. package/dist/core/workflow-model/validate-predicate-expression.d.ts +3 -1
  74. package/dist/core/workflow-model/validate-predicate-expression.d.ts.map +1 -1
  75. package/dist/core/workflow-model/validate-predicate-expression.js +3 -0
  76. package/dist/core/workflow-model/validate-predicate-expression.js.map +1 -1
  77. package/dist/core/workflow-model/validation-issue.d.ts +2 -1
  78. package/dist/core/workflow-model/validation-issue.d.ts.map +1 -1
  79. package/dist/core/workflow-model/validation-issue.js +12 -15
  80. package/dist/core/workflow-model/validation-issue.js.map +1 -1
  81. package/dist/db/db.d.ts +196 -4
  82. package/dist/db/db.d.ts.map +1 -1
  83. package/dist/db/db.js +3 -1
  84. package/dist/db/db.js.map +1 -1
  85. package/dist/db/definition-triggers.js +3 -1
  86. package/dist/db/definition-triggers.js.map +1 -1
  87. package/dist/db/definitions.d.ts +17 -0
  88. package/dist/db/definitions.d.ts.map +1 -1
  89. package/dist/db/definitions.js +190 -23
  90. package/dist/db/definitions.js.map +1 -1
  91. package/dist/db/index.d.ts +2 -1
  92. package/dist/db/index.d.ts.map +1 -1
  93. package/dist/db/index.js +2 -1
  94. package/dist/db/index.js.map +1 -1
  95. package/dist/db/schema/definitions.d.ts +17 -0
  96. package/dist/db/schema/definitions.d.ts.map +1 -1
  97. package/dist/db/schema/definitions.js +8 -0
  98. package/dist/db/schema/definitions.js.map +1 -1
  99. package/dist/db/schema/sync-states.d.ts +7 -3
  100. package/dist/db/schema/sync-states.d.ts.map +1 -1
  101. package/dist/db/schema/sync-states.js +5 -1
  102. package/dist/db/schema/sync-states.js.map +1 -1
  103. package/dist/db/schema/workflows.d.ts +78 -0
  104. package/dist/db/schema/workflows.d.ts.map +1 -0
  105. package/dist/db/schema/workflows.js +17 -0
  106. package/dist/db/schema/workflows.js.map +1 -0
  107. package/dist/db/sync-states.d.ts +2 -2
  108. package/dist/db/sync-states.d.ts.map +1 -1
  109. package/dist/db/sync-states.js +4 -4
  110. package/dist/db/sync-states.js.map +1 -1
  111. package/dist/index.d.ts.map +1 -1
  112. package/dist/index.js +5 -1
  113. package/dist/index.js.map +1 -1
  114. package/dist/metrics/index.d.ts +2 -0
  115. package/dist/metrics/index.d.ts.map +1 -0
  116. package/dist/metrics/index.js +3 -0
  117. package/dist/metrics/index.js.map +1 -0
  118. package/dist/metrics/instance.d.ts +4 -0
  119. package/dist/metrics/instance.d.ts.map +1 -0
  120. package/dist/metrics/instance.js +19 -0
  121. package/dist/metrics/instance.js.map +1 -0
  122. package/dist/presentation/dto/definition.d.ts.map +1 -1
  123. package/dist/presentation/dto/definition.js +11 -4
  124. package/dist/presentation/dto/definition.js.map +1 -1
  125. package/dist/presentation/inter-module.d.ts +9 -1
  126. package/dist/presentation/inter-module.d.ts.map +1 -1
  127. package/dist/presentation/inter-module.js +66 -2
  128. package/dist/presentation/inter-module.js.map +1 -1
  129. package/dist/presentation/routes/create-definition.d.ts.map +1 -1
  130. package/dist/presentation/routes/create-definition.js +16 -3
  131. package/dist/presentation/routes/create-definition.js.map +1 -1
  132. package/dist/presentation/routes/get-definition.d.ts.map +1 -1
  133. package/dist/presentation/routes/get-definition.js +18 -4
  134. package/dist/presentation/routes/get-definition.js.map +1 -1
  135. package/dist/presentation/routes/project-access.d.ts +1 -0
  136. package/dist/presentation/routes/project-access.d.ts.map +1 -1
  137. package/dist/presentation/routes/validate-definition.js +3 -3
  138. package/dist/presentation/routes/validate-definition.js.map +1 -1
  139. package/dist/temporal/activities/sync-activities.d.ts +3 -3
  140. package/dist/temporal/activities/sync-activities.d.ts.map +1 -1
  141. package/dist/temporal/activities/sync-activities.js +7 -4
  142. package/dist/temporal/activities/sync-activities.js.map +1 -1
  143. package/dist/temporal/workflows/definition-sync.js +1 -1
  144. package/dist/temporal/workflows/definition-sync.js.map +1 -1
  145. package/dist/temporal/workflows/index.bundle.js +50 -22
  146. package/dist/tsconfig.test.tsbuildinfo +1 -1
  147. package/drizzle/0003_nebulous_nemesis.sql +11 -0
  148. package/drizzle/0004_workflow_ref_lookup.sql +1 -0
  149. package/drizzle/meta/0003_snapshot.json +627 -0
  150. package/drizzle/meta/0004_snapshot.json +649 -0
  151. package/drizzle/meta/_journal.json +14 -0
  152. package/drizzle.config.ts +1 -0
  153. package/package.json +7 -7
  154. package/src/core/entities/index.ts +1 -1
  155. package/src/core/entities/integration-context.ts +4 -0
  156. package/src/core/entities/sync-state.test.ts +49 -0
  157. package/src/core/entities/sync-state.ts +35 -12
  158. package/src/core/entities/validation-diagnostic.ts +8 -0
  159. package/src/core/entities/workflow-definition.ts +2 -0
  160. package/src/core/errors.ts +26 -0
  161. package/src/core/index.ts +12 -0
  162. package/src/core/integrations.test.ts +38 -0
  163. package/src/core/integrations.ts +10 -1
  164. package/src/core/parse-definition-diagnostics.test.ts +47 -0
  165. package/src/core/parse-definition.test.ts +29 -5
  166. package/src/core/parse-definition.ts +7 -7
  167. package/src/core/resolve-definition-at-ref.test.ts +792 -0
  168. package/src/core/resolve-definition-at-ref.ts +551 -0
  169. package/src/core/sync-definitions.test.ts +61 -2
  170. package/src/core/sync-definitions.ts +7 -7
  171. package/src/core/validate-definition.test.ts +31 -22
  172. package/src/core/validate-definition.ts +21 -13
  173. package/src/core/workflow-model/cron-trigger.ts +6 -2
  174. package/src/core/workflow-model/index.ts +1 -0
  175. package/src/core/workflow-model/invalid-workflow-model-error.ts +5 -1
  176. package/src/core/workflow-model/normalize-job-listening.ts +55 -23
  177. package/src/core/workflow-model/normalize-triggers.ts +44 -15
  178. package/src/core/workflow-model/normalize-workflow-document.test.ts +494 -36
  179. package/src/core/workflow-model/normalize-workflow-document.ts +8 -4
  180. package/src/core/workflow-model/parse-interpolation-field.ts +1 -23
  181. package/src/core/workflow-model/validate-predicate-expression.ts +6 -0
  182. package/src/core/workflow-model/validation-issue.ts +8 -13
  183. package/src/db/db.ts +7 -1
  184. package/src/db/definition-triggers.test.ts +44 -0
  185. package/src/db/definition-triggers.ts +1 -1
  186. package/src/db/definitions.test.ts +300 -1
  187. package/src/db/definitions.ts +282 -28
  188. package/src/db/index.ts +2 -0
  189. package/src/db/schema/definitions.test.ts +1 -0
  190. package/src/db/schema/definitions.ts +11 -0
  191. package/src/db/schema/sync-states.ts +12 -3
  192. package/src/db/schema/workflows.ts +23 -0
  193. package/src/db/sync-states.test.ts +87 -18
  194. package/src/db/sync-states.ts +6 -6
  195. package/src/db/workflow-lineage-migration.test.ts +158 -0
  196. package/src/index.ts +3 -1
  197. package/src/metrics/index.ts +1 -0
  198. package/src/metrics/instance.test.ts +50 -0
  199. package/src/metrics/instance.ts +23 -0
  200. package/src/presentation/dto/definition.test.ts +89 -1
  201. package/src/presentation/dto/definition.ts +8 -5
  202. package/src/presentation/inter-module.test.ts +177 -0
  203. package/src/presentation/inter-module.ts +77 -4
  204. package/src/presentation/routes/create-definition.test.ts +38 -0
  205. package/src/presentation/routes/create-definition.ts +20 -3
  206. package/src/presentation/routes/get-definition.test.ts +125 -11
  207. package/src/presentation/routes/get-definition.ts +21 -4
  208. package/src/presentation/routes/list-definitions.test.ts +1 -1
  209. package/src/presentation/routes/validate-definition.test.ts +1 -1
  210. package/src/presentation/routes/validate-definition.ts +3 -3
  211. package/src/presentation/subscribers/on-integration-connection-available.test.ts +3 -0
  212. package/src/temporal/activities/sync-activities.test.ts +46 -2
  213. package/src/temporal/activities/sync-activities.ts +9 -7
  214. package/src/temporal/workflows/definition-sync.ts +1 -1
  215. package/test/factories/definition.ts +1 -0
  216. package/test/globalSetup.ts +2 -0
  217. package/tsconfig.build.tsbuildinfo +1 -1
  218. package/dist/core/entities/validation-warning.d.ts +0 -6
  219. package/dist/core/entities/validation-warning.d.ts.map +0 -1
  220. package/dist/core/entities/validation-warning.js +0 -3
  221. package/dist/core/entities/validation-warning.js.map +0 -1
  222. package/src/core/entities/validation-warning.ts +0 -5
  223. package/src/core/parse-definition-warnings.test.ts +0 -33
@@ -0,0 +1,792 @@
1
+ import type {AgentInterModuleClient} from '@shipfox/api-agent-dto/inter-module';
2
+ import {
3
+ type IntegrationsModuleClient,
4
+ integrationsInterModuleContract,
5
+ } from '@shipfox/api-integration-core-dto/inter-module';
6
+ import type {ProjectsModuleClient} from '@shipfox/api-projects-dto/inter-module';
7
+ import {createInterModuleKnownError} from '@shipfox/inter-module';
8
+ import {eq, sql} from 'drizzle-orm';
9
+ import {DefinitionAtRefError} from '#core/errors.js';
10
+ import {listDefinitionsAtRef, resolveDefinitionAtRef} from '#core/resolve-definition-at-ref.js';
11
+ import {db} from '#db/db.js';
12
+ import {workflowDefinitions} from '#db/schema/definitions.js';
13
+ import {definitionsOutbox} from '#db/schema/outbox.js';
14
+ import {workflowWorkflows} from '#db/schema/workflows.js';
15
+ import {agentValidationCatalog} from '#test/agent-validation-catalog.js';
16
+
17
+ const metrics = vi.hoisted(() => ({
18
+ recordDefinitionRefResolution: vi.fn(),
19
+ }));
20
+
21
+ vi.mock('#metrics/index.js', () => metrics);
22
+
23
+ const COMMIT = 'a1b2c3d4e5f6a7b8c9d0a1b2c3d4e5f6a7b8c9d0';
24
+ const CONFIG_PATH = '.shipfox/workflows/ci.yml';
25
+
26
+ const validYaml = `
27
+ name: CI
28
+ runner: ubuntu-latest
29
+ triggers:
30
+ on_demand:
31
+ source: manual
32
+ event: fire
33
+ on_push:
34
+ source: github_acme
35
+ event: push
36
+ filter: event.ref == "refs/heads/main"
37
+ jobs:
38
+ build:
39
+ steps:
40
+ - run: pnpm test
41
+ `;
42
+
43
+ const warningYaml = `
44
+ name: Warning only
45
+ runner: ubuntu-latest
46
+ jobs:
47
+ build:
48
+ steps:
49
+ - env:
50
+ MSG: '${'$'.concat('{{ event.x }}')}'
51
+ run: eval "$MSG"
52
+ `;
53
+
54
+ const invalidYaml = `
55
+ name: Bad YAML
56
+ jobs:
57
+ build:
58
+ steps:
59
+ - run: echo hello
60
+ invalid indentation here
61
+ `;
62
+
63
+ const integrationYaml = `
64
+ name: Agent CI
65
+ runner: ubuntu-latest
66
+ jobs:
67
+ build:
68
+ steps:
69
+ - prompt: Fix the issue
70
+ integrations:
71
+ - connection: github-main
72
+ include: [issue_read]
73
+ `;
74
+
75
+ const invalidIntegrationYaml = `
76
+ name: Agent CI
77
+ runner: ubuntu-latest
78
+ jobs:
79
+ build:
80
+ steps:
81
+ - prompt: Fix the issue
82
+ integrations:
83
+ - connection: github-main
84
+ include: [issue_read.missing]
85
+ `;
86
+
87
+ const agentToolsContext = {
88
+ selectionCatalogs: [
89
+ {
90
+ provider: 'github',
91
+ selectors: [
92
+ {token: 'issue_read', kind: 'family', sensitivity: 'read', sensitive: false},
93
+ {token: 'issue_read.get', kind: 'method', sensitivity: 'read', sensitive: false},
94
+ ],
95
+ },
96
+ ],
97
+ catalogs: [],
98
+ workspaceConnections: [
99
+ {
100
+ id: 'connection-1',
101
+ provider: 'github',
102
+ slug: 'github-main',
103
+ capabilities: ['agent_tools'],
104
+ },
105
+ ],
106
+ eventCatalogs: [],
107
+ fixedEventProviders: [],
108
+ defaultConnection: {id: 'connection-1', slug: 'github-main', provider: 'github'},
109
+ };
110
+
111
+ interface Clients {
112
+ projects: ProjectsModuleClient;
113
+ integrations: IntegrationsModuleClient;
114
+ agent: AgentInterModuleClient;
115
+ }
116
+
117
+ function makeClients(projectId = crypto.randomUUID()): Clients {
118
+ const project = {
119
+ id: projectId,
120
+ workspaceId: crypto.randomUUID(),
121
+ sourceConnectionId: crypto.randomUUID(),
122
+ sourceExternalRepositoryId: 'gitea:gitea-owner/platform',
123
+ name: 'Platform',
124
+ };
125
+ return {
126
+ projects: {
127
+ getProjectById: vi.fn(async () => ({project})),
128
+ } as unknown as ProjectsModuleClient,
129
+ integrations: {
130
+ resolveSourceRef: vi.fn(async () => ({ref: 'fix-branch', commit: COMMIT})),
131
+ fetchSourceFile: vi.fn(async () => ({path: CONFIG_PATH, ref: COMMIT, content: validYaml})),
132
+ listSourceFiles: vi.fn(async () => ({
133
+ files: [{path: CONFIG_PATH, type: 'file', size: validYaml.length}],
134
+ nextCursor: null,
135
+ })),
136
+ getAgentToolsContext: vi.fn(async () => agentToolsContext),
137
+ } as unknown as IntegrationsModuleClient,
138
+ agent: {
139
+ getValidationCatalog: vi.fn(async () => agentValidationCatalog),
140
+ } as unknown as AgentInterModuleClient,
141
+ };
142
+ }
143
+
144
+ function withClients(overrides: {
145
+ projects?: Partial<ProjectsModuleClient>;
146
+ integrations?: Partial<IntegrationsModuleClient>;
147
+ agent?: Partial<AgentInterModuleClient>;
148
+ }) {
149
+ const clients = makeClients();
150
+ return {
151
+ ...clients,
152
+ projects: {...clients.projects, ...overrides.projects} as ProjectsModuleClient,
153
+ integrations: {...clients.integrations, ...overrides.integrations} as IntegrationsModuleClient,
154
+ agent: {...clients.agent, ...overrides.agent} as AgentInterModuleClient,
155
+ };
156
+ }
157
+
158
+ async function expectRefError(
159
+ promise: Promise<unknown>,
160
+ code: DefinitionAtRefError['code'],
161
+ ): Promise<DefinitionAtRefError> {
162
+ const error = await promise.catch((caught: unknown) => caught);
163
+ expect(error).toBeInstanceOf(DefinitionAtRefError);
164
+ expect((error as DefinitionAtRefError).code).toBe(code);
165
+ return error as DefinitionAtRefError;
166
+ }
167
+
168
+ async function countLineageRows(projectId: string) {
169
+ return await db()
170
+ .select({id: workflowWorkflows.id})
171
+ .from(workflowWorkflows)
172
+ .where(eq(workflowWorkflows.projectId, projectId));
173
+ }
174
+
175
+ async function countOutboxRows(projectId: string) {
176
+ return await db()
177
+ .select({id: definitionsOutbox.id})
178
+ .from(definitionsOutbox)
179
+ .where(sql`${definitionsOutbox.payload}->>'projectId' = ${projectId}`);
180
+ }
181
+
182
+ beforeEach(() => {
183
+ metrics.recordDefinitionRefResolution.mockReset();
184
+ });
185
+
186
+ describe('resolveDefinitionAtRef', () => {
187
+ test('resolves a valid definition at the pinned commit and creates only the lineage', async () => {
188
+ const projectId = crypto.randomUUID();
189
+ const clients = makeClients(projectId);
190
+
191
+ const result = await resolveDefinitionAtRef({
192
+ projectId,
193
+ ref: 'fix-branch',
194
+ configPath: CONFIG_PATH,
195
+ ...clients,
196
+ });
197
+
198
+ expect(result.commit).toBe(COMMIT);
199
+ expect(result.workflow.configPath).toBe(CONFIG_PATH);
200
+ expect(result.model).toEqual({version: 2, model: expect.any(Object)});
201
+ expect(result.sourceSnapshot).toEqual({content: validYaml, format: 'yaml'});
202
+ expect(Object.keys(result.triggers)).toEqual(['on_demand', 'on_push']);
203
+ expect(result.triggers.on_demand).toEqual({source: 'manual', event: 'fire'});
204
+ expect(result.triggers.on_push).toMatchObject({
205
+ source: 'github_acme',
206
+ event: 'push',
207
+ filter: 'event.ref == "refs/heads/main"',
208
+ });
209
+ expect(result.warnings).toEqual([]);
210
+
211
+ // The file is fetched at the commit, never at the name.
212
+ expect(clients.integrations.fetchSourceFile).toHaveBeenCalledWith(
213
+ expect.objectContaining({ref: COMMIT, path: CONFIG_PATH}),
214
+ );
215
+
216
+ // Only the lineage row exists: no definition row and no outbox event.
217
+ const lineages = await countLineageRows(projectId);
218
+ expect(lineages).toHaveLength(1);
219
+ expect(lineages[0]?.id).toBe(result.workflow.id);
220
+ const definitions = await db()
221
+ .select({id: workflowDefinitions.id})
222
+ .from(workflowDefinitions)
223
+ .where(eq(workflowDefinitions.projectId, projectId));
224
+ expect(definitions).toHaveLength(0);
225
+ expect(await countOutboxRows(projectId)).toHaveLength(0);
226
+ expect(metrics.recordDefinitionRefResolution).toHaveBeenCalledWith('resolved');
227
+ });
228
+
229
+ test('reuses the lineage across calls so dev runs share numbering', async () => {
230
+ const projectId = crypto.randomUUID();
231
+ const clients = makeClients(projectId);
232
+
233
+ const first = await resolveDefinitionAtRef({
234
+ projectId,
235
+ ref: 'fix-branch',
236
+ configPath: CONFIG_PATH,
237
+ ...clients,
238
+ });
239
+ const second = await resolveDefinitionAtRef({
240
+ projectId,
241
+ ref: 'fix-branch',
242
+ configPath: CONFIG_PATH,
243
+ ...clients,
244
+ });
245
+
246
+ expect(second.workflow.id).toBe(first.workflow.id);
247
+ expect(await countLineageRows(projectId)).toHaveLength(1);
248
+ expect(await countOutboxRows(projectId)).toHaveLength(0);
249
+ });
250
+
251
+ test('answers project-not-found for an unknown project', async () => {
252
+ const clients = withClients({
253
+ projects: {getProjectById: async () => ({project: null})},
254
+ });
255
+ const error = await expectRefError(
256
+ resolveDefinitionAtRef({
257
+ projectId: crypto.randomUUID(),
258
+ ref: 'fix-branch',
259
+ configPath: CONFIG_PATH,
260
+ ...clients,
261
+ }),
262
+ 'project-not-found',
263
+ );
264
+ expect(error.details).toMatchObject({projectId: expect.any(String)});
265
+ });
266
+
267
+ test('answers ref-not-found when the ref does not resolve', async () => {
268
+ const clients = withClients({
269
+ integrations: {
270
+ resolveSourceRef: () => {
271
+ throw createInterModuleKnownError(
272
+ integrationsInterModuleContract.methods.resolveSourceRef,
273
+ 'ref-not-found',
274
+ {ref: 'missing-branch'},
275
+ );
276
+ },
277
+ },
278
+ });
279
+ const error = await expectRefError(
280
+ resolveDefinitionAtRef({
281
+ projectId: crypto.randomUUID(),
282
+ ref: 'missing-branch',
283
+ configPath: CONFIG_PATH,
284
+ ...clients,
285
+ }),
286
+ 'ref-not-found',
287
+ );
288
+ expect(error.details).toEqual({ref: 'missing-branch'});
289
+ });
290
+
291
+ test('answers ref-invalid for a raw commit sha or pull-request ref', async () => {
292
+ const clients = withClients({
293
+ integrations: {
294
+ resolveSourceRef: () => {
295
+ throw createInterModuleKnownError(
296
+ integrationsInterModuleContract.methods.resolveSourceRef,
297
+ 'ref-invalid',
298
+ {ref: 'refs/pull/12/head'},
299
+ );
300
+ },
301
+ },
302
+ });
303
+ const error = await expectRefError(
304
+ resolveDefinitionAtRef({
305
+ projectId: crypto.randomUUID(),
306
+ ref: 'refs/pull/12/head',
307
+ configPath: CONFIG_PATH,
308
+ ...clients,
309
+ }),
310
+ 'ref-invalid',
311
+ );
312
+ expect(error.details).toEqual({ref: 'refs/pull/12/head'});
313
+ });
314
+
315
+ test('answers source-unavailable when the source control call fails', async () => {
316
+ const clients = withClients({
317
+ integrations: {
318
+ resolveSourceRef: () => {
319
+ throw createInterModuleKnownError(
320
+ integrationsInterModuleContract.methods.resolveSourceRef,
321
+ 'provider-failure',
322
+ {reason: 'provider-unavailable'},
323
+ );
324
+ },
325
+ },
326
+ });
327
+ await expectRefError(
328
+ resolveDefinitionAtRef({
329
+ projectId: crypto.randomUUID(),
330
+ ref: 'fix-branch',
331
+ configPath: CONFIG_PATH,
332
+ ...clients,
333
+ }),
334
+ 'source-unavailable',
335
+ );
336
+ });
337
+
338
+ test('answers ref-moved when expectedCommit no longer matches', async () => {
339
+ const projectId = crypto.randomUUID();
340
+ const clients = makeClients(projectId);
341
+ const error = await expectRefError(
342
+ resolveDefinitionAtRef({
343
+ projectId,
344
+ ref: 'fix-branch',
345
+ configPath: CONFIG_PATH,
346
+ expectedCommit: '9'.repeat(40),
347
+ ...clients,
348
+ }),
349
+ 'ref-moved',
350
+ );
351
+ expect(error.details).toEqual({ref: 'fix-branch', expectedCommit: '9'.repeat(40)});
352
+ expect(await countLineageRows(projectId)).toHaveLength(0);
353
+ expect(metrics.recordDefinitionRefResolution).toHaveBeenCalledWith('ref-moved');
354
+ });
355
+
356
+ test('resolves when expectedCommit still matches', async () => {
357
+ const clients = makeClients();
358
+ const result = await resolveDefinitionAtRef({
359
+ projectId: crypto.randomUUID(),
360
+ ref: 'fix-branch',
361
+ configPath: CONFIG_PATH,
362
+ expectedCommit: COMMIT,
363
+ ...clients,
364
+ });
365
+ expect(result.commit).toBe(COMMIT);
366
+ });
367
+
368
+ test('answers file-not-found when the file is missing at the commit', async () => {
369
+ const clients = withClients({
370
+ integrations: {
371
+ fetchSourceFile: () => {
372
+ throw createInterModuleKnownError(
373
+ integrationsInterModuleContract.methods.fetchSourceFile,
374
+ 'provider-failure',
375
+ {reason: 'file-not-found'},
376
+ );
377
+ },
378
+ },
379
+ });
380
+ const error = await expectRefError(
381
+ resolveDefinitionAtRef({
382
+ projectId: crypto.randomUUID(),
383
+ ref: 'fix-branch',
384
+ configPath: CONFIG_PATH,
385
+ ...clients,
386
+ }),
387
+ 'file-not-found',
388
+ );
389
+ expect(error.details).toEqual({ref: 'fix-branch', configPath: CONFIG_PATH});
390
+ });
391
+
392
+ test('answers source-unavailable when the file fetch fails', async () => {
393
+ const clients = withClients({
394
+ integrations: {
395
+ fetchSourceFile: () => {
396
+ throw createInterModuleKnownError(
397
+ integrationsInterModuleContract.methods.fetchSourceFile,
398
+ 'connection-inactive',
399
+ {connectionId: crypto.randomUUID()},
400
+ );
401
+ },
402
+ },
403
+ });
404
+ await expectRefError(
405
+ resolveDefinitionAtRef({
406
+ projectId: crypto.randomUUID(),
407
+ ref: 'fix-branch',
408
+ configPath: CONFIG_PATH,
409
+ ...clients,
410
+ }),
411
+ 'source-unavailable',
412
+ );
413
+ });
414
+
415
+ test('answers content-too-large for a file over the sync limit', async () => {
416
+ const clients = withClients({
417
+ integrations: {
418
+ fetchSourceFile: async () => ({
419
+ path: CONFIG_PATH,
420
+ ref: COMMIT,
421
+ content: `# ${'x'.repeat(1_000_001)}`,
422
+ }),
423
+ },
424
+ });
425
+ const error = await expectRefError(
426
+ resolveDefinitionAtRef({
427
+ projectId: crypto.randomUUID(),
428
+ ref: 'fix-branch',
429
+ configPath: CONFIG_PATH,
430
+ ...clients,
431
+ }),
432
+ 'content-too-large',
433
+ );
434
+ expect(error.details).toEqual({configPath: CONFIG_PATH});
435
+ });
436
+
437
+ test('stops before creating lineage when the request is cancelled', async () => {
438
+ const projectId = crypto.randomUUID();
439
+ const controller = new AbortController();
440
+ const reason = new Error('cancelled');
441
+ const clients = withClients({
442
+ integrations: {
443
+ fetchSourceFile: () => {
444
+ controller.abort(reason);
445
+ return Promise.resolve({path: CONFIG_PATH, ref: COMMIT, content: validYaml});
446
+ },
447
+ },
448
+ });
449
+
450
+ await expect(
451
+ resolveDefinitionAtRef({
452
+ projectId,
453
+ ref: 'fix-branch',
454
+ configPath: CONFIG_PATH,
455
+ signal: controller.signal,
456
+ ...clients,
457
+ }),
458
+ ).rejects.toBe(reason);
459
+ expect(await countLineageRows(projectId)).toHaveLength(0);
460
+ expect(metrics.recordDefinitionRefResolution).not.toHaveBeenCalled();
461
+ });
462
+
463
+ test('answers invalid-definition with the validate-route error shape', async () => {
464
+ const projectId = crypto.randomUUID();
465
+ const clients = withClients({
466
+ integrations: {
467
+ fetchSourceFile: async () => ({path: CONFIG_PATH, ref: COMMIT, content: invalidYaml}),
468
+ },
469
+ });
470
+ const error = await expectRefError(
471
+ resolveDefinitionAtRef({
472
+ projectId,
473
+ ref: 'fix-branch',
474
+ configPath: CONFIG_PATH,
475
+ ...clients,
476
+ }),
477
+ 'invalid-definition',
478
+ );
479
+ expect(error.details.errors).toEqual(
480
+ expect.arrayContaining([expect.objectContaining({message: expect.any(String)})]),
481
+ );
482
+ expect(await countLineageRows(projectId)).toHaveLength(0);
483
+ expect(metrics.recordDefinitionRefResolution).toHaveBeenCalledWith('invalid-definition');
484
+ });
485
+
486
+ test('runs the two-pass integration validation like sync', async () => {
487
+ const clients = withClients({
488
+ integrations: {
489
+ fetchSourceFile: async () => ({
490
+ path: CONFIG_PATH,
491
+ ref: COMMIT,
492
+ content: invalidIntegrationYaml,
493
+ }),
494
+ },
495
+ });
496
+ const error = await expectRefError(
497
+ resolveDefinitionAtRef({
498
+ projectId: crypto.randomUUID(),
499
+ ref: 'fix-branch',
500
+ configPath: CONFIG_PATH,
501
+ ...clients,
502
+ }),
503
+ 'invalid-definition',
504
+ );
505
+ expect(error.details.errors).toEqual(
506
+ expect.arrayContaining([expect.objectContaining({message: expect.any(String)})]),
507
+ );
508
+ expect(clients.integrations.getAgentToolsContext).toHaveBeenCalled();
509
+
510
+ const validClients = withClients({
511
+ integrations: {
512
+ fetchSourceFile: async () => ({
513
+ path: CONFIG_PATH,
514
+ ref: COMMIT,
515
+ content: integrationYaml,
516
+ }),
517
+ },
518
+ });
519
+ const resolved = await resolveDefinitionAtRef({
520
+ projectId: crypto.randomUUID(),
521
+ ref: 'fix-branch',
522
+ configPath: CONFIG_PATH,
523
+ ...validClients,
524
+ });
525
+ expect(resolved.triggers).toEqual({});
526
+ expect(validClients.integrations.getAgentToolsContext).toHaveBeenCalled();
527
+ });
528
+
529
+ test('answers source-unavailable when integration validation context loading fails', async () => {
530
+ const clients = withClients({
531
+ integrations: {
532
+ fetchSourceFile: async () => ({path: CONFIG_PATH, ref: COMMIT, content: integrationYaml}),
533
+ getAgentToolsContext: () => {
534
+ throw createInterModuleKnownError(
535
+ integrationsInterModuleContract.methods.getAgentToolsContext,
536
+ 'connection-inactive',
537
+ {connectionId: crypto.randomUUID()},
538
+ );
539
+ },
540
+ },
541
+ });
542
+
543
+ await expectRefError(
544
+ resolveDefinitionAtRef({
545
+ projectId: crypto.randomUUID(),
546
+ ref: 'fix-branch',
547
+ configPath: CONFIG_PATH,
548
+ ...clients,
549
+ }),
550
+ 'source-unavailable',
551
+ );
552
+ expect(metrics.recordDefinitionRefResolution).toHaveBeenCalledWith('source-unavailable');
553
+ });
554
+
555
+ test('returns validation warnings for warning-only diagnostics', async () => {
556
+ const clients = withClients({
557
+ integrations: {
558
+ fetchSourceFile: async () => ({path: CONFIG_PATH, ref: COMMIT, content: warningYaml}),
559
+ },
560
+ });
561
+ const result = await resolveDefinitionAtRef({
562
+ projectId: crypto.randomUUID(),
563
+ ref: 'fix-branch',
564
+ configPath: CONFIG_PATH,
565
+ ...clients,
566
+ });
567
+ expect(result.warnings.length).toBeGreaterThan(0);
568
+ expect(result.warnings[0]).toEqual(
569
+ expect.objectContaining({code: expect.any(String), message: expect.any(String)}),
570
+ );
571
+ });
572
+ });
573
+
574
+ describe('listDefinitionsAtRef', () => {
575
+ test('lists files with validation state and the pinned commit', async () => {
576
+ const clients = withClients({
577
+ integrations: {
578
+ listSourceFiles: vi.fn(async () => ({
579
+ files: [
580
+ {path: CONFIG_PATH, type: 'file' as const, size: validYaml.length},
581
+ {path: '.shipfox/workflows/README.md', type: 'file' as const, size: 8},
582
+ ],
583
+ nextCursor: null,
584
+ })),
585
+ },
586
+ });
587
+ const result = await listDefinitionsAtRef({
588
+ projectId: crypto.randomUUID(),
589
+ ref: 'fix-branch',
590
+ ...clients,
591
+ });
592
+
593
+ expect(result.commit).toBe(COMMIT);
594
+ expect(result.files).toHaveLength(1);
595
+ expect(result.files[0]).toEqual({
596
+ configPath: CONFIG_PATH,
597
+ name: 'CI',
598
+ valid: true,
599
+ errors: [],
600
+ warnings: [],
601
+ triggers: expect.objectContaining({on_demand: {source: 'manual', event: 'fire'}}),
602
+ });
603
+ expect(clients.integrations.listSourceFiles).toHaveBeenCalledWith(
604
+ expect.objectContaining({ref: COMMIT, prefix: '.shipfox/workflows/', limit: 100}),
605
+ );
606
+ });
607
+
608
+ test('reports an invalid file without failing the listing', async () => {
609
+ const invalidPath = '.shipfox/workflows/broken.yml';
610
+ const clients = withClients({
611
+ integrations: {
612
+ listSourceFiles: async () => ({
613
+ files: [
614
+ {path: CONFIG_PATH, type: 'file' as const, size: validYaml.length},
615
+ {path: invalidPath, type: 'file' as const, size: invalidYaml.length},
616
+ ],
617
+ nextCursor: null,
618
+ }),
619
+ fetchSourceFile: async ({path}) => ({
620
+ path,
621
+ ref: COMMIT,
622
+ content: path === invalidPath ? invalidYaml : validYaml,
623
+ }),
624
+ },
625
+ });
626
+ const result = await listDefinitionsAtRef({
627
+ projectId: crypto.randomUUID(),
628
+ ref: 'fix-branch',
629
+ ...clients,
630
+ });
631
+
632
+ expect(result.files).toHaveLength(2);
633
+ const valid = result.files.find((file) => file.configPath === CONFIG_PATH);
634
+ const invalid = result.files.find((file) => file.configPath === invalidPath);
635
+ expect(valid).toMatchObject({valid: true, name: 'CI', errors: []});
636
+ expect(invalid).toMatchObject({
637
+ valid: false,
638
+ name: null,
639
+ warnings: [],
640
+ triggers: {},
641
+ });
642
+ expect(invalid?.errors.length).toBeGreaterThan(0);
643
+ expect(invalid?.errors[0]).toEqual(expect.objectContaining({message: expect.any(String)}));
644
+ });
645
+
646
+ test('reports a file over the size limit as invalid', async () => {
647
+ const hugePath = '.shipfox/workflows/huge.yml';
648
+ const clients = withClients({
649
+ integrations: {
650
+ listSourceFiles: async () => ({
651
+ files: [{path: hugePath, type: 'file' as const, size: 1_000_001}],
652
+ nextCursor: null,
653
+ }),
654
+ fetchSourceFile: async () => ({
655
+ path: hugePath,
656
+ ref: COMMIT,
657
+ content: `# ${'x'.repeat(1_000_001)}`,
658
+ }),
659
+ },
660
+ });
661
+ const result = await listDefinitionsAtRef({
662
+ projectId: crypto.randomUUID(),
663
+ ref: 'fix-branch',
664
+ ...clients,
665
+ });
666
+ expect(result.files).toHaveLength(1);
667
+ expect(result.files[0]).toMatchObject({
668
+ valid: false,
669
+ errors: [{message: expect.stringContaining('larger than')}],
670
+ });
671
+ });
672
+
673
+ test('answers too-many-files when the sync file limit is exceeded', async () => {
674
+ const files = Array.from({length: 150}, (_, index) => ({
675
+ path: `.shipfox/workflows/wf-${index}.yml`,
676
+ type: 'file' as const,
677
+ size: 1,
678
+ }));
679
+ const clients = withClients({
680
+ integrations: {
681
+ listSourceFiles: async () => ({files, nextCursor: 'more'}),
682
+ fetchSourceFile: async ({path}) => ({path, ref: COMMIT, content: validYaml}),
683
+ },
684
+ });
685
+ await expectRefError(
686
+ listDefinitionsAtRef({
687
+ projectId: crypto.randomUUID(),
688
+ ref: 'fix-branch',
689
+ ...clients,
690
+ }),
691
+ 'too-many-files',
692
+ );
693
+ expect(metrics.recordDefinitionRefResolution).toHaveBeenCalledWith('too-many-files');
694
+ });
695
+
696
+ test('reports a file that fails to fetch as invalid', async () => {
697
+ const clients = withClients({
698
+ integrations: {
699
+ fetchSourceFile: () => {
700
+ throw createInterModuleKnownError(
701
+ integrationsInterModuleContract.methods.fetchSourceFile,
702
+ 'provider-failure',
703
+ {reason: 'file-not-found'},
704
+ );
705
+ },
706
+ },
707
+ });
708
+ const result = await listDefinitionsAtRef({
709
+ projectId: crypto.randomUUID(),
710
+ ref: 'fix-branch',
711
+ ...clients,
712
+ });
713
+ expect(result.files).toHaveLength(1);
714
+ expect(result.files[0]).toMatchObject({valid: false, name: null});
715
+ });
716
+
717
+ test('answers source-unavailable when a listed file cannot reach the provider', async () => {
718
+ const clients = withClients({
719
+ integrations: {
720
+ fetchSourceFile: () => {
721
+ throw createInterModuleKnownError(
722
+ integrationsInterModuleContract.methods.fetchSourceFile,
723
+ 'connection-inactive',
724
+ {connectionId: crypto.randomUUID()},
725
+ );
726
+ },
727
+ },
728
+ });
729
+
730
+ await expectRefError(
731
+ listDefinitionsAtRef({
732
+ projectId: crypto.randomUUID(),
733
+ ref: 'fix-branch',
734
+ ...clients,
735
+ }),
736
+ 'source-unavailable',
737
+ );
738
+ expect(metrics.recordDefinitionRefResolution).toHaveBeenCalledWith('source-unavailable');
739
+ });
740
+
741
+ test('returns validation warnings for warning-only listing entries', async () => {
742
+ const clients = withClients({
743
+ integrations: {
744
+ fetchSourceFile: async () => ({path: CONFIG_PATH, ref: COMMIT, content: warningYaml}),
745
+ },
746
+ });
747
+
748
+ const result = await listDefinitionsAtRef({
749
+ projectId: crypto.randomUUID(),
750
+ ref: 'fix-branch',
751
+ ...clients,
752
+ });
753
+
754
+ expect(result.files[0]?.warnings).toEqual([
755
+ expect.objectContaining({code: expect.any(String), message: expect.any(String)}),
756
+ ]);
757
+ });
758
+
759
+ test('answers the ref resolution errors', async () => {
760
+ const missingRef = withClients({
761
+ integrations: {
762
+ resolveSourceRef: () => {
763
+ throw createInterModuleKnownError(
764
+ integrationsInterModuleContract.methods.resolveSourceRef,
765
+ 'ref-not-found',
766
+ {ref: 'missing-branch'},
767
+ );
768
+ },
769
+ },
770
+ });
771
+ await expectRefError(
772
+ listDefinitionsAtRef({
773
+ projectId: crypto.randomUUID(),
774
+ ref: 'missing-branch',
775
+ ...missingRef,
776
+ }),
777
+ 'ref-not-found',
778
+ );
779
+
780
+ const missingProject = withClients({
781
+ projects: {getProjectById: async () => ({project: null})},
782
+ });
783
+ await expectRefError(
784
+ listDefinitionsAtRef({
785
+ projectId: crypto.randomUUID(),
786
+ ref: 'fix-branch',
787
+ ...missingProject,
788
+ }),
789
+ 'project-not-found',
790
+ );
791
+ });
792
+ });