@shipfox/api-agent-access 20.3.0 → 21.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-agent-access",
3
3
  "license": "MIT",
4
- "version": "20.3.0",
4
+ "version": "21.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -32,13 +32,13 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@modelcontextprotocol/sdk": "1.29.0",
35
- "@shipfox/api-agent-access-dto": "20.3.0",
35
+ "@shipfox/api-agent-access-dto": "21.0.0",
36
36
  "@shipfox/annotations-dto": "20.3.0",
37
- "@shipfox/api-auth-context": "20.2.0",
38
- "@shipfox/api-definitions-dto": "20.3.0",
39
- "@shipfox/api-projects-dto": "20.0.0",
40
- "@shipfox/api-triggers-dto": "19.0.0",
41
- "@shipfox/api-workflows-dto": "20.3.0",
37
+ "@shipfox/api-auth-context": "20.4.0",
38
+ "@shipfox/api-definitions-dto": "21.0.0",
39
+ "@shipfox/api-projects-dto": "21.0.0",
40
+ "@shipfox/api-triggers-dto": "21.0.0",
41
+ "@shipfox/api-workflows-dto": "21.0.0",
42
42
  "@shipfox/inter-module": "0.2.3",
43
43
  "@shipfox/node-drizzle": "0.3.5",
44
44
  "@shipfox/node-error-monitoring": "0.3.0",
@@ -0,0 +1,425 @@
1
+ import type {AgentAccessEnvelopeDto} from '@shipfox/api-agent-access-dto';
2
+ import {
3
+ AGENT_ACCESS_FACET_MAX_ITEMS,
4
+ AGENT_ACCESS_FACET_VALUE_MAX_BYTES,
5
+ AGENT_ACCESS_RESPONSE_MAX_BYTES,
6
+ AGENT_ACCESS_SERIALIZED_JSON_MAX_BYTES,
7
+ AGENT_ACCESS_TRIGGER_DECISION_MAX_ITEMS,
8
+ AGENT_ACCESS_TRIGGER_REPLAY_MAX_ITEMS,
9
+ agentAccessEnvelopeSchema,
10
+ getTriggerEventFacetsResultSchema,
11
+ getTriggerEventResultJsonSchema,
12
+ getTriggerEventResultSchema,
13
+ } from '@shipfox/api-agent-access-dto';
14
+ import type {AgentAccessContext} from '@shipfox/api-auth-context';
15
+ import {projectsInterModuleContract} from '@shipfox/api-projects-dto/inter-module';
16
+ import type {TriggersInterModuleClient} from '@shipfox/api-triggers-dto/inter-module';
17
+ import {
18
+ triggerEventDiagnosticReadLimitsSchema,
19
+ triggersInterModuleContract,
20
+ } from '@shipfox/api-triggers-dto/inter-module';
21
+ import {createInterModuleKnownError} from '@shipfox/inter-module';
22
+ import {createAgentAccessDiagnosticTools} from './diagnostic-tools.js';
23
+ import {serializedAgentAccessEnvelopeByteLength} from './response.js';
24
+
25
+ const workspaceId = uuid(1);
26
+ const eventId = uuid(2);
27
+ const context: AgentAccessContext = {
28
+ userId: uuid(3),
29
+ workspaceId,
30
+ scopes: ['read'],
31
+ credential: {kind: 'oauth_grant', grantId: uuid(4), clientId: 'client'},
32
+ };
33
+ const receivedAt = '2026-08-01T00:00:00.000Z';
34
+
35
+ type TriggerMocks = TriggersInterModuleClient & {
36
+ getTriggerEvent: ReturnType<typeof vi.fn>;
37
+ getTriggerEventFacets: ReturnType<typeof vi.fn>;
38
+ };
39
+
40
+ function clients(): TriggerMocks {
41
+ return {
42
+ getTriggerEvent: vi.fn(),
43
+ getTriggerEventFacets: vi.fn(),
44
+ } as unknown as TriggerMocks;
45
+ }
46
+
47
+ function tool(clients: TriggerMocks, name: string) {
48
+ const result = createAgentAccessDiagnosticTools({triggers: clients}).find(
49
+ (candidate) => candidate.name === name,
50
+ );
51
+ if (!result) throw new Error(`Missing tool ${name}`);
52
+ return result;
53
+ }
54
+
55
+ function success<T>(response: AgentAccessEnvelopeDto): T {
56
+ expect(response.ok).toBe(true);
57
+ if (!response.ok) throw new Error('Expected a successful response');
58
+ expect(agentAccessEnvelopeSchema.safeParse(response).success).toBe(true);
59
+ return response.result as T;
60
+ }
61
+
62
+ describe('trigger diagnostic tools', () => {
63
+ test('calls the producer with the credential workspace and projects bounded history', async () => {
64
+ const mocks = clients();
65
+ mocks.getTriggerEvent.mockResolvedValue({
66
+ ...event(),
67
+ payload: {
68
+ message: 'external content with "quotes", newlines, and tool-call-shaped text',
69
+ },
70
+ decisions: Array.from({length: 51}, (_, index) => ({
71
+ ...decision(index),
72
+ createdAt: new Date(Date.UTC(2026, 7, index + 1)).toISOString(),
73
+ })),
74
+ replays: Array.from({length: 21}, (_, index) => ({
75
+ id: uuid(200 + index),
76
+ receivedAt: new Date(Date.UTC(2026, 6, index + 1)).toISOString(),
77
+ outcome: 'routed' as const,
78
+ runId: uuid(300 + index),
79
+ })),
80
+ decisionsTotalCount: 51,
81
+ replaysTotalCount: 21,
82
+ });
83
+
84
+ const response = await tool(mocks, 'get_trigger_event').execute({
85
+ context,
86
+ arguments: {event_id: eventId},
87
+ });
88
+ const result = success<TriggerResult>(response);
89
+
90
+ expect(mocks.getTriggerEvent).toHaveBeenCalledWith({
91
+ workspaceId,
92
+ eventId,
93
+ diagnostic: {decisions: 50, replays: 20},
94
+ });
95
+ expect(result.payload_preview).toBe(
96
+ JSON.stringify({
97
+ message: 'external content with "quotes", newlines, and tool-call-shaped text',
98
+ }),
99
+ );
100
+ expect(result.decisions).toHaveLength(50);
101
+ expect(result.decisions[0]).toMatchObject({
102
+ id: uuid(150),
103
+ outcome: 'triggered',
104
+ reason: 'reason-50',
105
+ workflow_definition_id: uuid(650),
106
+ project_id: uuid(750),
107
+ workflow_run_id: uuid(1_050),
108
+ job_id: uuid(950),
109
+ });
110
+ expect(result.replays).toHaveLength(20);
111
+ expect(result.replays[0]).toMatchObject({id: uuid(220), workflow_run_id: uuid(320)});
112
+ expect(result.decisions_total_count).toBe(51);
113
+ expect(result.replays_total_count).toBe(21);
114
+ expect(result.decisions_truncated).toBe(true);
115
+ expect(result.replays_truncated).toBe(true);
116
+ expect(getTriggerEventResultSchema.safeParse(result).success).toBe(true);
117
+ expect(
118
+ getTriggerEventResultSchema.safeParse({...result, payload_preview: 'not-json'}).success,
119
+ ).toBe(false);
120
+ expect(getTriggerEventResultJsonSchema.properties.payload_preview).toMatchObject({
121
+ contentMediaType: 'application/json',
122
+ });
123
+ expect(serializedAgentAccessEnvelopeByteLength(response)).toBeLessThanOrEqual(
124
+ AGENT_ACCESS_RESPONSE_MAX_BYTES,
125
+ );
126
+ expect(JSON.stringify(result)).not.toContain('event_ref');
127
+ });
128
+
129
+ test('orders tied history by descending id and preserves trigger and listener run links', async () => {
130
+ const mocks = clients();
131
+ mocks.getTriggerEvent.mockResolvedValue({
132
+ ...event(),
133
+ decisions: [
134
+ decision(1),
135
+ {
136
+ ...decision(2),
137
+ id: uuid(102),
138
+ subscriptionKind: 'listener' as const,
139
+ workflowDefinitionId: null,
140
+ projectId: null,
141
+ workflowRunId: uuid(812),
142
+ jobId: uuid(912),
143
+ runId: null,
144
+ },
145
+ ],
146
+ replays: [
147
+ {id: uuid(21), receivedAt, outcome: 'routed' as const, runId: uuid(321)},
148
+ {id: uuid(22), receivedAt, outcome: 'routed' as const, runId: uuid(322)},
149
+ ],
150
+ });
151
+
152
+ const response = await tool(mocks, 'get_trigger_event').execute({
153
+ context,
154
+ arguments: {event_id: eventId},
155
+ });
156
+ const result = success<TriggerResult>(response);
157
+
158
+ expect(result.decisions.map((item) => item.id)).toEqual([uuid(102), uuid(101)]);
159
+ expect(result.decisions.find((item) => item.id === uuid(101))).toMatchObject({
160
+ workflow_run_id: uuid(1_001),
161
+ });
162
+ expect(result.decisions.find((item) => item.id === uuid(102))).toMatchObject({
163
+ workflow_run_id: uuid(812),
164
+ });
165
+ expect(result.replays.map((item) => item.id)).toEqual([uuid(22), uuid(21)]);
166
+ });
167
+
168
+ test('keeps an escaping-heavy capped payload valid JSON and reports its original byte size', async () => {
169
+ const mocks = clients();
170
+ const payload = {message: '\\"\n\\\\🙂'.repeat(8_000)};
171
+ mocks.getTriggerEvent.mockResolvedValue({...event(), payload, decisions: [], replays: []});
172
+
173
+ const response = await tool(mocks, 'get_trigger_event').execute({
174
+ context,
175
+ arguments: {event_id: eventId},
176
+ });
177
+ const result = success<TriggerResult>(response);
178
+
179
+ expect(result.payload_preview_truncated).toBe(true);
180
+ expect(result.payload_preview_total_bytes).toBeGreaterThan(
181
+ AGENT_ACCESS_SERIALIZED_JSON_MAX_BYTES,
182
+ );
183
+ expect(new TextEncoder().encode(result.payload_preview).byteLength).toBeLessThanOrEqual(
184
+ AGENT_ACCESS_SERIALIZED_JSON_MAX_BYTES,
185
+ );
186
+ expect(() => JSON.parse(result.payload_preview)).not.toThrow();
187
+ expect(getTriggerEventResultSchema.safeParse(result).success).toBe(true);
188
+ });
189
+
190
+ test('preserves JSON semantics for bounded mixed payloads', async () => {
191
+ const mocks = clients();
192
+ const payload = {
193
+ nested: {
194
+ keep: 'value',
195
+ nan: Number.NaN,
196
+ infinity: Number.POSITIVE_INFINITY,
197
+ negative_zero: -0,
198
+ omitted: undefined,
199
+ function_value: () => 'ignored',
200
+ symbol_value: Symbol('ignored'),
201
+ },
202
+ values: [
203
+ Number.NaN,
204
+ Number.POSITIVE_INFINITY,
205
+ -0,
206
+ undefined,
207
+ () => 'ignored',
208
+ Symbol('ignored'),
209
+ ],
210
+ long: 'x'.repeat(20_000),
211
+ };
212
+ mocks.getTriggerEvent.mockResolvedValue({...event(), payload, decisions: [], replays: []});
213
+
214
+ const response = await tool(mocks, 'get_trigger_event').execute({
215
+ context,
216
+ arguments: {event_id: eventId},
217
+ });
218
+ const result = success<TriggerResult>(response);
219
+ const parsed = JSON.parse(result.payload_preview) as {
220
+ nested: Record<string, unknown>;
221
+ values: unknown[];
222
+ };
223
+
224
+ expect(parsed.nested).toEqual({
225
+ keep: 'value',
226
+ nan: null,
227
+ infinity: null,
228
+ negative_zero: 0,
229
+ });
230
+ expect(parsed.values).toEqual([null, null, 0, null, null, null]);
231
+ expect(new TextEncoder().encode(result.payload_preview).byteLength).toBeLessThanOrEqual(
232
+ AGENT_ACCESS_SERIALIZED_JSON_MAX_BYTES,
233
+ );
234
+ });
235
+
236
+ test('caps facet collections and values while preserving the producer workspace', async () => {
237
+ const mocks = clients();
238
+ mocks.getTriggerEventFacets.mockResolvedValue({
239
+ sources: Array.from({length: AGENT_ACCESS_FACET_MAX_ITEMS + 1}, (_, index) => ({
240
+ value: `source-${index}-🙂`.repeat(100),
241
+ count: index,
242
+ })),
243
+ events: [{value: 'push', count: 3}],
244
+ origins: [{value: 'integration', count: 3}],
245
+ });
246
+
247
+ const response = await tool(mocks, 'get_trigger_event_facets').execute({
248
+ context,
249
+ arguments: {},
250
+ });
251
+ const result = success<FacetsResult>(response);
252
+
253
+ expect(mocks.getTriggerEventFacets).toHaveBeenCalledWith({workspaceId});
254
+ expect(result.sources).toHaveLength(AGENT_ACCESS_FACET_MAX_ITEMS);
255
+ expect(new TextEncoder().encode(result.sources[0]?.value ?? '').byteLength).toBe(
256
+ AGENT_ACCESS_FACET_VALUE_MAX_BYTES,
257
+ );
258
+ expect(serializedAgentAccessEnvelopeByteLength(response)).toBeLessThanOrEqual(
259
+ AGENT_ACCESS_RESPONSE_MAX_BYTES,
260
+ );
261
+ expect(getTriggerEventFacetsResultSchema.safeParse(result).success).toBe(true);
262
+ });
263
+
264
+ test('merges facet values that share their bounded prefix', async () => {
265
+ const mocks = clients();
266
+ const prefix = 'x'.repeat(AGENT_ACCESS_FACET_VALUE_MAX_BYTES);
267
+ mocks.getTriggerEventFacets.mockResolvedValue({
268
+ sources: [
269
+ {value: `${prefix}-first`, count: 2},
270
+ {value: `${prefix}-second`, count: 3},
271
+ ],
272
+ events: [],
273
+ origins: [],
274
+ });
275
+
276
+ const response = await tool(mocks, 'get_trigger_event_facets').execute({
277
+ context,
278
+ arguments: {},
279
+ });
280
+ const result = success<FacetsResult>(response);
281
+
282
+ expect(result.sources).toEqual([{value: prefix, count: 5}]);
283
+ });
284
+
285
+ test('rejects malformed input before calling the producer', async () => {
286
+ const mocks = clients();
287
+ const eventTool = tool(mocks, 'get_trigger_event');
288
+ const facetsTool = tool(mocks, 'get_trigger_event_facets');
289
+
290
+ await expect(
291
+ eventTool.execute({context, arguments: {event_id: 'not-a-uuid'}}),
292
+ ).resolves.toEqual({ok: false, error: {code: 'invalid-request'}});
293
+ await expect(
294
+ eventTool.execute({context, arguments: {event_id: eventId, extra: true}}),
295
+ ).resolves.toEqual({ok: false, error: {code: 'invalid-request'}});
296
+ await expect(
297
+ facetsTool.execute({context, arguments: {workspace_id: workspaceId}}),
298
+ ).resolves.toEqual({ok: false, error: {code: 'invalid-request'}});
299
+
300
+ expect(mocks.getTriggerEvent).not.toHaveBeenCalled();
301
+ expect(mocks.getTriggerEventFacets).not.toHaveBeenCalled();
302
+ });
303
+
304
+ test('maps a producer not-found error to the common error envelope', async () => {
305
+ const mocks = clients();
306
+ mocks.getTriggerEvent.mockRejectedValue(
307
+ createInterModuleKnownError(
308
+ triggersInterModuleContract.methods.getTriggerEvent,
309
+ 'trigger-event-not-found',
310
+ {eventId},
311
+ ),
312
+ );
313
+
314
+ const response = await tool(mocks, 'get_trigger_event').execute({
315
+ context,
316
+ arguments: {event_id: eventId},
317
+ });
318
+
319
+ expect(response).toEqual({ok: false, error: {code: 'not-found'}});
320
+ expect(agentAccessEnvelopeSchema.safeParse(response).success).toBe(true);
321
+ });
322
+
323
+ test('rethrows unexpected producer errors for the framework failure envelope', async () => {
324
+ const mocks = clients();
325
+ const error = new Error('producer unavailable');
326
+ mocks.getTriggerEvent.mockRejectedValue(error);
327
+
328
+ await expect(
329
+ tool(mocks, 'get_trigger_event').execute({
330
+ context,
331
+ arguments: {event_id: eventId},
332
+ }),
333
+ ).rejects.toBe(error);
334
+ });
335
+
336
+ test('does not map an unrelated producer known error to not-found', async () => {
337
+ const mocks = clients();
338
+ const error = createInterModuleKnownError(
339
+ projectsInterModuleContract.methods.requireProjectForWorkspace,
340
+ 'project-not-found',
341
+ {projectId: eventId},
342
+ );
343
+ mocks.getTriggerEvent.mockRejectedValue(error);
344
+
345
+ await expect(
346
+ tool(mocks, 'get_trigger_event').execute({
347
+ context,
348
+ arguments: {event_id: eventId},
349
+ }),
350
+ ).rejects.toBe(error);
351
+ });
352
+
353
+ test('keeps diagnostic read limits aligned with the producer contract', () => {
354
+ expect(
355
+ triggerEventDiagnosticReadLimitsSchema.safeParse({
356
+ decisions: AGENT_ACCESS_TRIGGER_DECISION_MAX_ITEMS,
357
+ replays: AGENT_ACCESS_TRIGGER_REPLAY_MAX_ITEMS,
358
+ }).success,
359
+ ).toBe(true);
360
+ });
361
+ });
362
+
363
+ function event() {
364
+ return {
365
+ id: eventId,
366
+ eventRef: 'event-ref',
367
+ origin: 'integration' as const,
368
+ workspaceId,
369
+ provider: 'github',
370
+ source: 'push',
371
+ event: 'push',
372
+ replayOfEventId: null,
373
+ deliveryId: 'delivery',
374
+ connectionId: uuid(5),
375
+ connectionName: 'Connection',
376
+ outcome: 'routed' as const,
377
+ matchedCount: 1,
378
+ payload: null,
379
+ receivedAt,
380
+ processedAt: receivedAt,
381
+ createdAt: receivedAt,
382
+ };
383
+ }
384
+
385
+ function decision(index: number) {
386
+ return {
387
+ id: uuid(100 + index),
388
+ receivedEventId: eventId,
389
+ subscriptionKind: 'trigger' as const,
390
+ subscriptionId: uuid(500 + index),
391
+ subscriptionName: `subscription-${index}`,
392
+ workflowDefinitionId: uuid(600 + index),
393
+ projectId: uuid(700 + index),
394
+ workflowRunId: uuid(800 + index),
395
+ jobId: uuid(900 + index),
396
+ matcherKind: 'on' as const,
397
+ matcherOrdinal: index,
398
+ decision: 'triggered' as const,
399
+ runId: uuid(1_000 + index),
400
+ runName: `run-${index}`,
401
+ reason: `reason-${index}`,
402
+ createdAt: receivedAt,
403
+ };
404
+ }
405
+
406
+ interface TriggerResult {
407
+ payload_preview: string;
408
+ payload_preview_truncated?: true;
409
+ payload_preview_total_bytes?: number;
410
+ decisions: Array<Record<string, unknown>>;
411
+ decisions_truncated?: true;
412
+ decisions_total_count: number;
413
+ replays: Array<Record<string, unknown>>;
414
+ replays_truncated?: true;
415
+ replays_total_count: number;
416
+ [key: string]: unknown;
417
+ }
418
+
419
+ interface FacetsResult {
420
+ sources: Array<{value: string; count: number}>;
421
+ }
422
+
423
+ function uuid(value: number): string {
424
+ return `00000000-0000-4000-8000-${String(value).padStart(12, '0')}`;
425
+ }