@aiconnect/agentjobs-mcp 1.2.0 → 1.3.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.
@@ -47,6 +47,37 @@ const jobConfigSchema = z.object({
47
47
  failure_cooldown_minutes: z.number().optional(),
48
48
  start_prompt: z.string().optional(),
49
49
  }).passthrough();
50
+ const activitySourceSchema = z.object({
51
+ type: z.enum(['dispatch', 'process_module', 'direct']),
52
+ reference_id: z.string().optional(),
53
+ execution_id: z.string().optional(),
54
+ job_id: z.string().optional(),
55
+ chat_id: z.string().optional(),
56
+ agent_job_type_id: z.string().optional(),
57
+ channel_code: z.string().optional(),
58
+ }).passthrough();
59
+ // Required-by-contract (per `job-activities-query` spec): every activity entry
60
+ // rendered by the formatter MUST surface id, created_at, status, activity_type_code,
61
+ // source.type, consumed_credits, allocated_credits. Records missing any of these
62
+ // fail the parse and fall back to "[unparseable activity]" so upstream audit-data
63
+ // corruption is surfaced rather than silently rendered as `unknown`/`n/a`.
64
+ const activitySchema = z.object({
65
+ id: z.string().min(1),
66
+ org_id: z.string().optional(),
67
+ activity_type_code: z.string().min(1),
68
+ status: z.enum(['submitted', 'completed', 'canceled']),
69
+ allocated_credits: z.number(),
70
+ consumed_credits: z.number(),
71
+ credits_rule_id: z.number().optional(),
72
+ payloads: z.object({
73
+ input: z.any().optional(),
74
+ output: z.any().optional(),
75
+ }).passthrough().optional(),
76
+ processed_at: isoOrMs.nullable().optional(),
77
+ created_at: z.union([z.string(), z.number()]),
78
+ updated_at: isoOrMs,
79
+ source: activitySourceSchema,
80
+ }).passthrough();
50
81
  const jobDetailsSchema = z.object({
51
82
  job_id: z.string(),
52
83
  job_type_id: z.string(),
@@ -66,6 +97,13 @@ const jobDetailsSchema = z.object({
66
97
  channel_data: channelDataSchema.optional().default({}),
67
98
  job_config: jobConfigSchema.optional().default({}),
68
99
  params: z.record(z.any()).optional().default({}),
100
+ activities_count: z.number().optional(),
101
+ // Activities are intentionally validated as `unknown[]` here (not as
102
+ // `z.array(activitySchema)`): per-entry parsing happens inside
103
+ // `formatActivityEntry`, so a single malformed record degrades to
104
+ // "[unparseable activity]" without making the whole job document fall back
105
+ // to the raw-JSON branch in formatJobDetails.
106
+ Activities: z.array(z.unknown()).optional(),
69
107
  }).passthrough();
70
108
  const bool = (v) => (v === true ? 'yes' : v === false ? 'no' : 'n/a');
71
109
  const safe = (v, fallback = 'n/a') => v === undefined || v === null || v === '' ? fallback : String(v);
@@ -75,7 +113,134 @@ const truncate = (s, max = 300) => {
75
113
  return s.length > max ? `${s.slice(0, max)}…` : s;
76
114
  };
77
115
  const fmtList = (arr) => (arr && arr.length ? arr.join(', ') : 'n/a');
78
- export function formatJobDetails(job) {
116
+ const ACTIVITY_OUTPUT_MAX = 200;
117
+ function isBinaryValue(value) {
118
+ if (value === null || value === undefined || typeof value !== 'object')
119
+ return false;
120
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))
121
+ return true;
122
+ if (value instanceof ArrayBuffer)
123
+ return true;
124
+ if (typeof ArrayBuffer.isView === 'function' && ArrayBuffer.isView(value))
125
+ return true;
126
+ return false;
127
+ }
128
+ function containsBinary(value, seen = new WeakSet()) {
129
+ if (isBinaryValue(value))
130
+ return true;
131
+ if (value === null || typeof value !== 'object')
132
+ return false;
133
+ if (seen.has(value))
134
+ return false;
135
+ seen.add(value);
136
+ if (Array.isArray(value)) {
137
+ for (const v of value) {
138
+ if (containsBinary(v, seen))
139
+ return true;
140
+ }
141
+ return false;
142
+ }
143
+ for (const v of Object.values(value)) {
144
+ if (containsBinary(v, seen))
145
+ return true;
146
+ }
147
+ return false;
148
+ }
149
+ function safeStringifyOutput(value) {
150
+ if (value === null || value === undefined)
151
+ return '';
152
+ if (typeof value === 'string')
153
+ return value;
154
+ if (containsBinary(value))
155
+ return '[non-serializable]';
156
+ try {
157
+ const text = JSON.stringify(value);
158
+ return text === undefined ? '[non-serializable]' : text;
159
+ }
160
+ catch {
161
+ return '[non-serializable]';
162
+ }
163
+ }
164
+ /**
165
+ * Collapses newlines/CR/tabs into escaped literals and clamps the result so it
166
+ * always fits in a single visual line of the activity output. Used by every
167
+ * code path that injects user-controlled activity content into the rendered
168
+ * line — both the normal `payloads.output` branch and the malformed-entry
169
+ * fallback — so a bad record cannot visually corrupt the surrounding entries.
170
+ */
171
+ function sanitizeForActivityLine(value, max = ACTIVITY_OUTPUT_MAX) {
172
+ const escaped = value
173
+ .replace(/\r\n/g, '\\n')
174
+ .replace(/\n/g, '\\n')
175
+ .replace(/\r/g, '\\n')
176
+ .replace(/\t/g, '\\t');
177
+ return escaped.length > max ? `${escaped.slice(0, max)}…` : escaped;
178
+ }
179
+ function formatActivitySource(source) {
180
+ if (!source || typeof source !== 'object')
181
+ return 'unknown';
182
+ const type = source.type ?? 'unknown';
183
+ const extras = [];
184
+ if (source.reference_id)
185
+ extras.push(`ref: ${source.reference_id}`);
186
+ if (source.execution_id)
187
+ extras.push(`exec: ${source.execution_id}`);
188
+ if (source.chat_id)
189
+ extras.push(`chat: ${source.chat_id}`);
190
+ if (source.agent_job_type_id)
191
+ extras.push(`type: ${source.agent_job_type_id}`);
192
+ if (source.channel_code)
193
+ extras.push(`channel: ${source.channel_code}`);
194
+ return extras.length ? `${type} (${extras.join(', ')})` : String(type);
195
+ }
196
+ export function formatActivityEntry(activity) {
197
+ let a;
198
+ try {
199
+ a = activitySchema.parse(activity);
200
+ }
201
+ catch {
202
+ const raw = safeStringifyOutput(activity);
203
+ return `- [unparseable activity] ${sanitizeForActivityLine(raw)}`;
204
+ }
205
+ const createdIso = toIso(a.created_at) ?? 'n/a';
206
+ const status = a.status ?? 'unknown';
207
+ const typeCode = a.activity_type_code ?? 'unknown';
208
+ const sourceLine = formatActivitySource(a.source);
209
+ const consumed = a.consumed_credits ?? 0;
210
+ const allocated = a.allocated_credits ?? 0;
211
+ const lines = [
212
+ `- ${createdIso} [${status}] ${typeCode} via ${sourceLine}`,
213
+ ` credits: ${consumed}/${allocated} id: ${a.id}`,
214
+ ];
215
+ const output = a.payloads?.output;
216
+ if (output !== undefined && output !== null && output !== '') {
217
+ const stringified = safeStringifyOutput(output);
218
+ if (stringified !== '') {
219
+ lines.push(` output: ${sanitizeForActivityLine(stringified)}`);
220
+ }
221
+ }
222
+ return lines.join('\n');
223
+ }
224
+ export function formatJobActivitiesList(jobId, activities, meta, offset = 0) {
225
+ if (!meta || typeof meta.count !== 'number' || typeof meta.limit !== 'number' || typeof meta.total !== 'number') {
226
+ throw new Error('formatJobActivitiesList: meta is required with numeric `count`, `limit`, and `total`. ' +
227
+ `Received: ${JSON.stringify(meta)}`);
228
+ }
229
+ const safeActivities = activities || [];
230
+ const { count, total } = meta;
231
+ const hasMore = (offset + count) < total;
232
+ // Advance by the number of rows actually returned, not by `limit`. If the
233
+ // backend ships a short non-terminal page (count < limit), advancing by
234
+ // `limit` would tell the caller to skip unseen rows.
235
+ const nextOffset = hasMore ? offset + count : null;
236
+ const footer = `Returned: ${count} | Total matching: ${total} | Has more: ${hasMore} | Next offset: ${nextOffset === null ? 'null' : nextOffset}`;
237
+ if (safeActivities.length === 0) {
238
+ return `No activities found for job ${jobId}.\n\n${footer}`;
239
+ }
240
+ const entries = safeActivities.map(formatActivityEntry).join('\n');
241
+ return `Activities for job ${jobId} (showing ${count}):\n\n${entries}\n\n${footer}`;
242
+ }
243
+ export function formatJobDetails(job, meta) {
79
244
  try {
80
245
  const j = jobDetailsSchema.parse(job);
81
246
  // Derivados
@@ -120,6 +285,27 @@ export function formatJobDetails(job) {
120
285
  }
121
286
  })();
122
287
  const startPrompt = j.job_config?.start_prompt ? truncate(j.job_config.start_prompt, 500) : undefined;
288
+ // Fail-closed: the only signal that the caller actually requested the
289
+ // overlay is `meta.activities_meta` (a field the backend returns ONLY for
290
+ // ?include=activities). Without that signal we omit the Activities block
291
+ // entirely — even if `j.Activities` happens to be populated in the payload —
292
+ // to keep flag-off output byte-identical to the legacy formatter.
293
+ let activitiesBlock = '';
294
+ const overlayRequested = meta?.activities_meta !== undefined;
295
+ if (overlayRequested) {
296
+ if (Array.isArray(j.Activities) && j.Activities.length > 0) {
297
+ const entries = j.Activities.map(formatActivityEntry).join('\n');
298
+ const total = meta?.activities_meta?.count;
299
+ const limit = meta?.activities_meta?.limit;
300
+ const truncationLine = (typeof total === 'number' && typeof limit === 'number' && total > limit)
301
+ ? `\n(showing ${j.Activities.length} of ${total} activities — use get_job_activities for full pagination)`
302
+ : '';
303
+ activitiesBlock = `\n\nActivities:\n${entries}${truncationLine}`;
304
+ }
305
+ else {
306
+ activitiesBlock = `\n\nActivities:\n - (no activities recorded for this job)`;
307
+ }
308
+ }
123
309
  return (`Job Details
124
310
  ===========
125
311
 
@@ -172,7 +358,7 @@ Result / Tags / Log:
172
358
  - Result: ${safe(j.result)}
173
359
  - Tags: ${fmtList(tagsList)}
174
360
  - Execution Log (last 5):
175
- ${lastLogs.length ? lastLogs.join('\n') : ' - n/a'}
361
+ ${lastLogs.length ? lastLogs.join('\n') : ' - n/a'}${activitiesBlock}
176
362
  `).trim();
177
363
  }
178
364
  catch (e) {
@@ -190,15 +376,32 @@ const jobSchema = z.object({
190
376
  job_status: z.string(),
191
377
  result: z.string().nullable(),
192
378
  job_type_id: z.string(),
379
+ activities_count: z.number().optional(),
380
+ Activities: z.array(z.any()).optional(),
193
381
  }).passthrough(); // .passthrough() permite outros campos não definidos no schema.
194
382
  /**
195
383
  * Formata um resumo de um job, com os campos principais.
196
- * @param job - O objeto do job.
197
- * @returns Uma string formatada com o resumo do job.
198
384
  */
199
- export function formatJobSummary(job) {
385
+ export function formatJobSummary(job, options = {}) {
200
386
  try {
201
387
  const parsedJob = jobSchema.parse(job);
388
+ const overlayConsidered = options.includeActivities === true && Array.isArray(parsedJob.Activities);
389
+ const overlayLen = overlayConsidered ? parsedJob.Activities.length : undefined;
390
+ const totalCount = typeof parsedJob.activities_count === 'number' ? parsedJob.activities_count : undefined;
391
+ let activitiesLine = '';
392
+ if (totalCount !== undefined && overlayLen !== undefined && overlayLen !== totalCount) {
393
+ // Surface any divergence between the canonical total and the overlay size,
394
+ // not just the truncation direction (overlay < total). A backend bug that
395
+ // returns more overlay entries than the canonical count is also a real
396
+ // mismatch the caller should see.
397
+ activitiesLine = `\n- Activities: ${totalCount} (overlay: ${overlayLen})`;
398
+ }
399
+ else if (totalCount !== undefined) {
400
+ activitiesLine = `\n- Activities: ${totalCount}`;
401
+ }
402
+ else if (overlayLen !== undefined) {
403
+ activitiesLine = `\n- Activities: ${overlayLen}`;
404
+ }
202
405
  return `
203
406
  - Job ID: ${parsedJob.job_id}
204
407
  - Status: ${parsedJob.job_status}
@@ -206,7 +409,7 @@ export function formatJobSummary(job) {
206
409
  - Channel: ${parsedJob.channel_code}
207
410
  - Scheduled At: ${parsedJob.scheduled_at}
208
411
  - Updated At: ${parsedJob.updated_at}
209
- - Result: ${parsedJob.result || 'N/A'}
412
+ - Result: ${parsedJob.result || 'N/A'}${activitiesLine}
210
413
  `.trim();
211
414
  }
212
415
  catch {
@@ -214,21 +417,7 @@ export function formatJobSummary(job) {
214
417
  return JSON.stringify(job, null, 2);
215
418
  }
216
419
  }
217
- /**
218
- * Formata a resposta para a lista de jobs.
219
- *
220
- * `meta` é obrigatório e os três campos (`count`, `limit`, `total`) precisam
221
- * ser numéricos — confirmado empiricamente que o backend sempre os retorna em
222
- * `/services/agent-jobs`. Falha explícita aqui é preferível a inventar um
223
- * footer com valores inferidos, pois isso recriaria a ambiguidade
224
- * "page count vs total" que esta tool deveria eliminar.
225
- *
226
- * @param jobs - Um array de jobs.
227
- * @param meta - O objeto `meta` retornado pelo backend.
228
- * @param offset - O `offset` que a tool enviou na requisição (default 0).
229
- * @returns Uma string formatada com a lista de resumos de jobs.
230
- */
231
- export function formatJobList(jobs, meta, offset = 0) {
420
+ export function formatJobList(jobs, meta, offset = 0, options = {}) {
232
421
  if (!meta || typeof meta.count !== 'number' || typeof meta.limit !== 'number' || typeof meta.total !== 'number') {
233
422
  throw new Error('formatJobList: meta is required with numeric `count`, `limit`, and `total`. ' +
234
423
  `Received: ${JSON.stringify(meta)}`);
@@ -237,11 +426,17 @@ export function formatJobList(jobs, meta, offset = 0) {
237
426
  const { count, limit, total } = meta;
238
427
  const hasMore = (offset + count) < total;
239
428
  const nextOffset = hasMore ? offset + limit : null;
240
- const footer = `Returned: ${count} | Total matching: ${total} | Has more: ${hasMore} | Next offset: ${nextOffset === null ? 'null' : nextOffset}`;
429
+ let footer = `Returned: ${count} | Total matching: ${total} | Has more: ${hasMore} | Next offset: ${nextOffset === null ? 'null' : nextOffset}`;
430
+ if (options.includeActivities === true && meta.activities_truncated === true) {
431
+ const returned = typeof meta.activities_total_returned === 'number' ? meta.activities_total_returned : '?';
432
+ const available = typeof meta.activities_total_available === 'number' ? meta.activities_total_available : '?';
433
+ footer += `\nActivities truncated: yes (returned ${returned} of ${available} available)`;
434
+ }
241
435
  if (safeJobs.length === 0) {
242
436
  return `Found 0 jobs.\n\n${footer}`;
243
437
  }
244
- const jobSummaries = safeJobs.map(job => formatJobSummary(job)).join('\n\n');
438
+ const summaryOpts = { includeActivities: options.includeActivities === true };
439
+ const jobSummaries = safeJobs.map(job => formatJobSummary(job, summaryOpts)).join('\n\n');
245
440
  return `Found ${safeJobs.length} jobs.\n\n${jobSummaries}\n\n${footer}`;
246
441
  }
247
442
  // Schema for job type details
@@ -22,3 +22,11 @@ export const flexibleDateTimeSchema = () => z.string().refine((value) => {
22
22
  }, {
23
23
  message: "Invalid date-time string. Please use a valid ISO 8601 format.",
24
24
  });
25
+ /**
26
+ * Enum factories for activity record fields. Returned as factories for the
27
+ * same reason as `flexibleDateTimeSchema` — singletons would cause `$ref`
28
+ * collisions in the serialized JSON Schema across sibling tool fields.
29
+ */
30
+ export const activityStatusSchema = () => z.enum(['submitted', 'completed', 'canceled']);
31
+ export const activitySourceTypeSchema = () => z.enum(['dispatch', 'process_module', 'direct']);
32
+ export const activitiesSortSchema = () => z.enum(['created_at', '-created_at']);
@@ -375,3 +375,118 @@ curl -X DELETE https://api.example.com/services/agent-jobs/job123 \
375
375
  "reason": "No longer needed"
376
376
  }'
377
377
  ```
378
+
379
+ ## Activities
380
+
381
+ Cada agent job acumula uma trilha de auditoria de **activities** (registros de operações como `ai_completion`, chamadas de ferramentas e logs internos). A trilha pode ser consultada por dois caminhos: o endpoint dedicado `/services/activities` (paginação real, recomendado para investigação) ou o overlay `?include=activities` em `/services/agent-jobs/:id` e `/services/agent-jobs` (conveniência, com caps server-side).
382
+
383
+ ### `ActivityRecord` schema
384
+
385
+ ```ts
386
+ {
387
+ id: string; // UUID v4
388
+ org_id: string;
389
+ activity_type_code: string; // código aberto (ex.: "ai_completion")
390
+ status: 'submitted' | 'completed' | 'canceled';
391
+ allocated_credits: number;
392
+ consumed_credits: number;
393
+ credits_rule_id: number; // 0..3
394
+ payloads?: { input?: any; output?: any };
395
+ processed_at?: string; // ISO 8601, opcional
396
+ created_at: string;
397
+ updated_at: string;
398
+ source: {
399
+ type: 'dispatch' | 'process_module' | 'direct';
400
+ reference_id?: string;
401
+ execution_id?: string;
402
+ job_id?: string;
403
+ chat_id?: string;
404
+ agent_job_type_id?: string;
405
+ channel_code?: string;
406
+ };
407
+ }
408
+ ```
409
+
410
+ O contador `activities_count` é mantido server-side no próprio documento do agent job (incrementado a cada activity criada com `source.job_id`). Está disponível em qualquer resposta de job, sem necessidade de `?include=activities`.
411
+
412
+ ### Endpoint dedicado: `GET /services/activities`
413
+
414
+ Recomendado para consulta paginada da trilha de um job específico. Sem caps de truncamento; suporta filtros server-side adicionais.
415
+
416
+ **Query params:**
417
+ - `job_id` (string, recomendado) — filtra activities do job indicado.
418
+ - `org_id` (string, opcional) — escopo de organização.
419
+ - `status` (`submitted | completed | canceled`, opcional)
420
+ - `activity_type_code` (string, opcional) — código aberto.
421
+ - `source_type` (`dispatch | process_module | direct`, opcional) — filtra pelo `source.type`.
422
+ - `limit` (int, default 50)
423
+ - `offset` (int, default 0)
424
+ - `sort` (string, default `-created_at`)
425
+
426
+ **Resposta:**
427
+ ```json
428
+ {
429
+ "data": [ "ActivityRecord", "..." ],
430
+ "meta": { "count": 50, "limit": 50, "total": 1234, "offset": 0 }
431
+ }
432
+ ```
433
+
434
+ **Exemplo:**
435
+ ```bash
436
+ curl -X GET "https://api.example.com/services/activities?job_id=job_abc&status=completed&limit=100" \
437
+ -H "Authorization: Bearer YOUR_TOKEN"
438
+ ```
439
+
440
+ ### Overlay: `?include=activities` em `/services/agent-jobs/:id`
441
+
442
+ Anexa as activities mais recentes ao detalhe do job. Útil para combinar contexto do job com trilha em uma única round-trip.
443
+
444
+ **Query params adicionais:**
445
+ - `include=activities` (obrigatório para ativar)
446
+ - `include_limit` (int 1–100, default 50) — máximo de activities anexadas.
447
+ - `include_sort` (`created_at | -created_at`, default `-created_at`)
448
+
449
+ **Resposta:**
450
+ ```json
451
+ {
452
+ "data": {
453
+ "job_id": "...",
454
+ "status": "completed",
455
+ "activities_count": 1234,
456
+ "Activities": [ "ActivityRecord", "..." ]
457
+ },
458
+ "meta": {
459
+ "activities_meta": { "count": 1234, "limit": 50 }
460
+ }
461
+ }
462
+ ```
463
+
464
+ `Activities` vem em **PascalCase** por convenção do serializador da API. Quando `meta.activities_meta.count > meta.activities_meta.limit`, a lista foi truncada — use o endpoint dedicado para acessar o restante.
465
+
466
+ ### Overlay: `?include=activities` em `/services/agent-jobs` (lista)
467
+
468
+ Anexa activities a cada job da listagem.
469
+
470
+ **Query params adicionais:**
471
+ - `include=activities` (obrigatório)
472
+ - `activities_limit_per_job` (int 1–100, default 15)
473
+ - `activities_total_limit` (int 1–3000, default 500) — cap global agregado.
474
+ - `activities_sort` (`created_at | -created_at`, default `-created_at`)
475
+
476
+ **Resposta:**
477
+ ```json
478
+ {
479
+ "data": [ { "Activities": [] } ],
480
+ "meta": {
481
+ "activities_total_returned": 45,
482
+ "activities_total_available": 120,
483
+ "activities_truncated": true
484
+ }
485
+ }
486
+ ```
487
+
488
+ ### Comportamento sensível
489
+
490
+ - **Fail-closed**: se a busca de activities falhar internamente quando `include=activities` está ativo, a request **inteira** falha (status 4xx/5xx). Garante integridade da trilha — nunca retorna o job sem as activities solicitadas. Em caso de erro, refaça a request sem `include=activities` ou use o endpoint dedicado.
491
+ - **Cache bypass**: requests com `?include=activities` em `/services/agent-jobs` ignoram o cache server-side, garantindo dados sempre frescos para uso operacional/auditoria.
492
+ - **Activities count sempre disponível**: `activities_count` no objeto do job é mantido independentemente de `include=activities`, então é consultável diretamente em qualquer resposta de job.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiconnect/agentjobs-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "MCP (Model Context Protocol) server for managing Agent Jobs in the AI Connect platform. Developed by AI Connect - Advanced AI automation and integration solutions.",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
@@ -1,22 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- describe('config.defaultTimezone', () => {
3
- beforeEach(() => {
4
- vi.resetModules();
5
- });
6
- afterEach(() => {
7
- vi.unstubAllEnvs();
8
- vi.resetModules();
9
- });
10
- it('reflects DEFAULT_TIMEZONE when set', async () => {
11
- vi.stubEnv('DEFAULT_TIMEZONE', 'America/Sao_Paulo');
12
- const { config } = await import('./config.js');
13
- expect(config.defaultTimezone).toBe('America/Sao_Paulo');
14
- expect(config.DEFAULT_TIMEZONE).toBe('America/Sao_Paulo');
15
- });
16
- it('falls back to UTC when DEFAULT_TIMEZONE is unset', async () => {
17
- vi.stubEnv('DEFAULT_TIMEZONE', '');
18
- const { config } = await import('./config.js');
19
- expect(config.defaultTimezone).toBe('UTC');
20
- expect(config.DEFAULT_TIMEZONE).toBe('UTC');
21
- });
22
- });