@aiconnect/agentjobs-mcp 1.1.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.
- package/.env.example +4 -0
- package/README.md +39 -6
- package/build/config.js +3 -1
- package/build/index.js +10 -3
- package/build/lib/agentJobsClient.js +11 -0
- package/build/test-tools.js +1 -1
- package/build/tools/cancel_job.js +5 -2
- package/build/tools/create_job.js +1 -1
- package/build/tools/get_context.js +61 -0
- package/build/tools/get_job.js +39 -9
- package/build/tools/get_job_activities.js +68 -0
- package/build/tools/get_jobs_stats.js +7 -17
- package/build/tools/list_jobs.js +44 -9
- package/build/utils/formatters.js +301 -31
- package/build/utils/schemas.js +23 -11
- package/build/utils/version.js +12 -0
- package/docs/agent-jobs-api.md +156 -0
- package/package.json +3 -2
- package/build/utils/formatters.test.js +0 -387
- package/build/utils/schemas.test.js +0 -40
|
@@ -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
|
-
|
|
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,19 +417,27 @@ export function formatJobSummary(job) {
|
|
|
214
417
|
return JSON.stringify(job, null, 2);
|
|
215
418
|
}
|
|
216
419
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
420
|
+
export function formatJobList(jobs, meta, offset = 0, options = {}) {
|
|
421
|
+
if (!meta || typeof meta.count !== 'number' || typeof meta.limit !== 'number' || typeof meta.total !== 'number') {
|
|
422
|
+
throw new Error('formatJobList: meta is required with numeric `count`, `limit`, and `total`. ' +
|
|
423
|
+
`Received: ${JSON.stringify(meta)}`);
|
|
424
|
+
}
|
|
425
|
+
const safeJobs = jobs || [];
|
|
426
|
+
const { count, limit, total } = meta;
|
|
427
|
+
const hasMore = (offset + count) < total;
|
|
428
|
+
const nextOffset = hasMore ? offset + limit : null;
|
|
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)`;
|
|
226
434
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
435
|
+
if (safeJobs.length === 0) {
|
|
436
|
+
return `Found 0 jobs.\n\n${footer}`;
|
|
437
|
+
}
|
|
438
|
+
const summaryOpts = { includeActivities: options.includeActivities === true };
|
|
439
|
+
const jobSummaries = safeJobs.map(job => formatJobSummary(job, summaryOpts)).join('\n\n');
|
|
440
|
+
return `Found ${safeJobs.length} jobs.\n\n${jobSummaries}\n\n${footer}`;
|
|
230
441
|
}
|
|
231
442
|
// Schema for job type details
|
|
232
443
|
const jobTypeSchema = z.object({
|
|
@@ -454,21 +665,81 @@ export function formatJobTypeSummary(jobType) {
|
|
|
454
665
|
return JSON.stringify(jobType, null, 2);
|
|
455
666
|
}
|
|
456
667
|
}
|
|
457
|
-
|
|
668
|
+
const DATE_FILTER_KEYS = [
|
|
669
|
+
'scheduled_at_gte',
|
|
670
|
+
'scheduled_at_lte',
|
|
671
|
+
'created_at_gte',
|
|
672
|
+
'created_at_lte',
|
|
673
|
+
];
|
|
674
|
+
const renderDateRange = (label, gte, lte) => {
|
|
675
|
+
if (gte === undefined && lte === undefined)
|
|
676
|
+
return null;
|
|
677
|
+
const left = gte !== undefined ? String(gte) : '(open)';
|
|
678
|
+
const right = lte !== undefined ? String(lte) : '(open)';
|
|
679
|
+
return `${label}: ${left} → ${right}`;
|
|
680
|
+
};
|
|
681
|
+
const CONTEXT_LABEL_WIDTH = 16; // "Server version: " == 16 chars
|
|
682
|
+
const JOB_TYPE_ID_WIDTH = 22;
|
|
683
|
+
const JOB_TYPE_EMOJI_WIDTH = 4;
|
|
684
|
+
function padLabel(label) {
|
|
685
|
+
return (label + ':').padEnd(CONTEXT_LABEL_WIDTH, ' ');
|
|
686
|
+
}
|
|
687
|
+
function formatJobTypeLine(jt) {
|
|
688
|
+
const id = jt.id.padEnd(JOB_TYPE_ID_WIDTH, ' ');
|
|
689
|
+
const emoji = (jt.emoji ?? '').padEnd(JOB_TYPE_EMOJI_WIDTH, ' ');
|
|
690
|
+
const description = jt.description ? ` — ${jt.description}` : '';
|
|
691
|
+
return ` - ${id} ${emoji} ${jt.name}${description}`;
|
|
692
|
+
}
|
|
693
|
+
export function formatContext(input) {
|
|
694
|
+
const { localConfig, jobTypes, total, jobTypesError } = input;
|
|
695
|
+
const contextSection = [
|
|
696
|
+
'Context:',
|
|
697
|
+
` ${padLabel('Org ID')} ${localConfig.org_id}`,
|
|
698
|
+
` ${padLabel('Timezone')} ${localConfig.timezone}`,
|
|
699
|
+
` ${padLabel('API URL')} ${localConfig.api_url}`,
|
|
700
|
+
` ${padLabel('Server version')} ${localConfig.server_version}`
|
|
701
|
+
].join('\n');
|
|
702
|
+
let jobsSection;
|
|
703
|
+
if (jobTypesError !== undefined) {
|
|
704
|
+
jobsSection = `Job types: unavailable (error: ${jobTypesError})`;
|
|
705
|
+
}
|
|
706
|
+
else if (jobTypes === undefined) {
|
|
707
|
+
jobsSection = 'Job types: unavailable (error: unknown)';
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
const totalCount = typeof total === 'number' ? total : jobTypes.length;
|
|
711
|
+
if (totalCount === 0) {
|
|
712
|
+
jobsSection = 'Job types available (0):\n (no job types registered for this org)';
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
const lines = jobTypes.map(formatJobTypeLine);
|
|
716
|
+
let trailing = '';
|
|
717
|
+
if (totalCount > jobTypes.length) {
|
|
718
|
+
const missing = totalCount - jobTypes.length;
|
|
719
|
+
trailing = `\n … and ${missing} more job types not shown`;
|
|
720
|
+
}
|
|
721
|
+
jobsSection = `Job types available (${totalCount}):\n${lines.join('\n')}${trailing}`;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return `${contextSection}\n\n${jobsSection}`;
|
|
725
|
+
}
|
|
726
|
+
export function formatJobStats(stats, appliedFilters = {}) {
|
|
458
727
|
const { waiting = 0, running = 0, completed = 0, failed = 0, canceled = 0, scheduled = 0, } = stats.status;
|
|
459
728
|
const totalJobs = waiting + running + completed + failed + canceled + scheduled;
|
|
460
729
|
const successRate = totalJobs > 0 ? ((completed / (totalJobs - waiting - scheduled - running)) * 100).toFixed(1) : "0.0";
|
|
461
730
|
const completionRate = (completed + failed) > 0 ? (completed / (completed + failed) * 100).toFixed(1) : "0.0";
|
|
462
731
|
const activeJobs = running + waiting + scheduled;
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
const
|
|
732
|
+
const filters = appliedFilters || {};
|
|
733
|
+
const scheduledLine = renderDateRange('Scheduled', filters.scheduled_at_gte, filters.scheduled_at_lte);
|
|
734
|
+
const createdLine = renderDateRange('Created', filters.created_at_gte, filters.created_at_lte);
|
|
735
|
+
const hasAnyDateFilter = DATE_FILTER_KEYS.some((k) => filters[k] !== undefined);
|
|
736
|
+
const periodFallback = hasAnyDateFilter ? null : 'Period: All time';
|
|
737
|
+
const dateLines = [scheduledLine, createdLine, periodFallback].filter((l) => l !== null);
|
|
738
|
+
const dateFilterSet = new Set(DATE_FILTER_KEYS);
|
|
739
|
+
const otherFilterEntries = Object.entries(filters).filter(([k, v]) => !dateFilterSet.has(k) && v !== undefined && v !== null && v !== '');
|
|
740
|
+
const filtersSection = otherFilterEntries.length
|
|
741
|
+
? `Filters:\n${otherFilterEntries.map(([k, v]) => `- ${k}: ${v}`).join('\n')}\n\n`
|
|
742
|
+
: '';
|
|
472
743
|
const percentage = (value) => {
|
|
473
744
|
if (totalJobs === 0)
|
|
474
745
|
return "0.0";
|
|
@@ -478,10 +749,9 @@ export function formatJobStats(stats, filters) {
|
|
|
478
749
|
Job Statistics Report
|
|
479
750
|
====================
|
|
480
751
|
|
|
481
|
-
|
|
482
|
-
${org}
|
|
752
|
+
${dateLines.join('\n')}
|
|
483
753
|
|
|
484
|
-
Status Breakdown:
|
|
754
|
+
${filtersSection}Status Breakdown:
|
|
485
755
|
✓ Completed: ${completed} jobs (${percentage(completed)}%)
|
|
486
756
|
⏳ Running: ${running} jobs (${percentage(running)}%)
|
|
487
757
|
⏰ Scheduled: ${scheduled} jobs (${percentage(scheduled)}%)
|
package/build/utils/schemas.js
CHANGED
|
@@ -1,20 +1,32 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Factory that returns a fresh Zod schema validating ISO 8601 date-time strings.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Accepts any string parseable by `new Date(value)` (full ISO 8601 with `Z` or
|
|
6
|
+
* timezone offset, and date-only forms like `2024-07-23`).
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT — why this is a factory and not a singleton: when the MCP SDK
|
|
9
|
+
* serializes a tool's input shape to JSON Schema (via `zod-to-json-schema`),
|
|
10
|
+
* reusing the same Zod *instance* across multiple fields causes the serializer
|
|
11
|
+
* to emit `$ref` from sibling fields back to the first one. Many clients/LLMs
|
|
12
|
+
* then treat those fields as `any` and send `null`, which the server rejects.
|
|
13
|
+
* Returning a new instance per call guarantees inline definitions per field.
|
|
14
|
+
*
|
|
15
|
+
* Convention for this project: every reusable Zod schema referenced by more
|
|
16
|
+
* than one tool field MUST be exported as a factory (`() => z.something(...)`),
|
|
17
|
+
* never as a singleton `const`.
|
|
9
18
|
*/
|
|
10
|
-
export const flexibleDateTimeSchema = z.string().refine((value) => {
|
|
11
|
-
// Try to parse the date string.
|
|
12
|
-
// The Date constructor is quite flexible with ISO 8601 formats.
|
|
19
|
+
export const flexibleDateTimeSchema = () => z.string().refine((value) => {
|
|
13
20
|
const date = new Date(value);
|
|
14
|
-
// Check if the parsed date is valid.
|
|
15
|
-
// `isNaN(date.getTime())` is a reliable way to check for invalid dates.
|
|
16
21
|
return !isNaN(date.getTime());
|
|
17
22
|
}, {
|
|
18
|
-
// Custom error message for invalid date-time strings.
|
|
19
23
|
message: "Invalid date-time string. Please use a valid ISO 8601 format.",
|
|
20
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']);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
function readVersion() {
|
|
3
|
+
try {
|
|
4
|
+
const raw = fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf-8');
|
|
5
|
+
const pkg = JSON.parse(raw);
|
|
6
|
+
return pkg.version ?? 'unknown';
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return 'unknown';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export const mcpServerVersion = readVersion();
|
package/docs/agent-jobs-api.md
CHANGED
|
@@ -239,6 +239,47 @@ Cancels a job by changing its status to CANCELED.
|
|
|
239
239
|
- 404: Job not found
|
|
240
240
|
- 409: Conflict (job is already in a terminal state)
|
|
241
241
|
|
|
242
|
+
### List Job Types (`agent-jobs-type`)
|
|
243
|
+
|
|
244
|
+
Lists agent job types registered for an organization. Used by `get_job_type` and `get_context` in this MCP.
|
|
245
|
+
|
|
246
|
+
#### Path em uso pelas tools deste MCP hoje
|
|
247
|
+
|
|
248
|
+
```http
|
|
249
|
+
GET /organizations/:org_id/agent-jobs-type
|
|
250
|
+
GET /organizations/:org_id/agent-jobs-type/:job_type_id
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
(Sem prefixo `/services/`.) **Toda tool nova neste repo deve usar este caminho** até a unificação descrita abaixo, para manter consistência com `get_job_type`.
|
|
254
|
+
|
|
255
|
+
#### Path canônico upstream
|
|
256
|
+
|
|
257
|
+
```http
|
|
258
|
+
GET /services/organizations/:org_id/agent-jobs-type
|
|
259
|
+
GET /services/organizations/:org_id/agent-jobs-type/:job_type_id
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Este é o caminho documentado upstream pelo AI Connect API. O backend serve a mesma controller em ambos os mounts (legacy + atual). Uma migração futura para o caminho canônico deve ser feita em **change separado abrangendo todas as tools afetadas**: `get_job_type`, `list_jobs`, `create_job`, `get_jobs_stats` e `get_context`.
|
|
263
|
+
|
|
264
|
+
#### Query parameters suportados
|
|
265
|
+
|
|
266
|
+
- `enrich=emoji` — adiciona o campo `emoji` no payload (cache server-side de 1h por job_type).
|
|
267
|
+
- `include=schema` — inclui `params_schema` completo de cada job type (não usado por `get_context`; pesado).
|
|
268
|
+
- `limit` — número máximo de itens por página. `get_context` fixa em `100`.
|
|
269
|
+
- `offset` — paginação numérica.
|
|
270
|
+
- `sort` — formato `field:direction`, ex: `name:asc` (default usado por `get_context`).
|
|
271
|
+
|
|
272
|
+
#### Response shape
|
|
273
|
+
|
|
274
|
+
```json
|
|
275
|
+
{
|
|
276
|
+
"data": [
|
|
277
|
+
{ "id": "string", "name": "string", "description": "string?", "emoji": "string?" }
|
|
278
|
+
],
|
|
279
|
+
"meta": { "total": 0, "limit": 100, "offset": 0 }
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
242
283
|
## Job Status Values
|
|
243
284
|
|
|
244
285
|
Jobs can have the following status values:
|
|
@@ -334,3 +375,118 @@ curl -X DELETE https://api.example.com/services/agent-jobs/job123 \
|
|
|
334
375
|
"reason": "No longer needed"
|
|
335
376
|
}'
|
|
336
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.
|
|
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",
|
|
@@ -61,7 +61,8 @@
|
|
|
61
61
|
"eslint": "^9.14.0",
|
|
62
62
|
"typescript-eslint": "^8.12.2",
|
|
63
63
|
"typescript": "^5.7.2",
|
|
64
|
-
"vitest": "^3.2.4"
|
|
64
|
+
"vitest": "^3.2.4",
|
|
65
|
+
"zod-to-json-schema": "^3.23.0"
|
|
65
66
|
},
|
|
66
67
|
"engines": {
|
|
67
68
|
"node": ">=18.0.0"
|