job-forge 2.14.9 → 2.14.11
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/.cursor/rules/main.mdc +4 -1
- package/.opencode/skills/job-forge.md +3 -0
- package/AGENTS.md +4 -1
- package/CLAUDE.md +4 -1
- package/bin/create-job-forge.mjs +7 -0
- package/bin/job-forge.mjs +58 -0
- package/docs/ARCHITECTURE.md +2 -0
- package/docs/CUSTOMIZATION.md +26 -3
- package/docs/SETUP.md +6 -0
- package/iso/commands/job-forge.md +3 -0
- package/iso/instructions.md +4 -1
- package/package.json +9 -5
- package/scripts/telemetry.mjs +643 -0
- package/scripts/trace.mjs +469 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from 'child_process';
|
|
4
|
+
import { existsSync, readdirSync, statSync } from 'fs';
|
|
5
|
+
import { join, resolve } from 'path';
|
|
6
|
+
import { defaultOpenCodeDbPath, findSessionById, parseSinceCutoff } from '@razroo/iso-trace';
|
|
7
|
+
|
|
8
|
+
const PROJECT_DIR = process.env.JOB_FORGE_PROJECT || process.cwd();
|
|
9
|
+
const DEFAULT_SINCE = '24h';
|
|
10
|
+
|
|
11
|
+
const USAGE = `job-forge telemetry — JobForge pipeline view over local OpenCode traces
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
job-forge telemetry:list [--since 24h] [--cwd <dir>] [--json]
|
|
15
|
+
job-forge telemetry:status [--since 24h] [--cwd <dir>] [--json]
|
|
16
|
+
job-forge telemetry:show <id-or-prefix> [--cwd <dir>] [--json]
|
|
17
|
+
job-forge telemetry:watch [--since 24h] [--cwd <dir>] [--interval 5]
|
|
18
|
+
|
|
19
|
+
Telemetry is local-only and passive. It derives status from OpenCode's SQLite DB
|
|
20
|
+
plus JobForge tracker files; agents do not need to emit custom events.`;
|
|
21
|
+
|
|
22
|
+
const [cmd = 'help', ...args] = process.argv.slice(2);
|
|
23
|
+
|
|
24
|
+
function parseArgs(rawArgs, { allowSession = false, allowInterval = false } = {}) {
|
|
25
|
+
const opts = { since: DEFAULT_SINCE, cwd: PROJECT_DIR, json: false, interval: 5 };
|
|
26
|
+
const positional = [];
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
29
|
+
const arg = rawArgs[i];
|
|
30
|
+
if (arg === '--since') {
|
|
31
|
+
opts.since = rawArgs[++i];
|
|
32
|
+
} else if (arg.startsWith('--since=')) {
|
|
33
|
+
opts.since = arg.slice('--since='.length);
|
|
34
|
+
} else if (arg === '--cwd') {
|
|
35
|
+
opts.cwd = rawArgs[++i];
|
|
36
|
+
} else if (arg.startsWith('--cwd=')) {
|
|
37
|
+
opts.cwd = arg.slice('--cwd='.length);
|
|
38
|
+
} else if (arg === '--json') {
|
|
39
|
+
opts.json = true;
|
|
40
|
+
} else if (allowInterval && arg === '--interval') {
|
|
41
|
+
opts.interval = Number(rawArgs[++i] || 5);
|
|
42
|
+
} else if (allowInterval && arg.startsWith('--interval=')) {
|
|
43
|
+
opts.interval = Number(arg.slice('--interval='.length));
|
|
44
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
45
|
+
opts.help = true;
|
|
46
|
+
} else if (arg.startsWith('--')) {
|
|
47
|
+
opts.error = `unknown flag "${arg}"`;
|
|
48
|
+
} else if (allowSession) {
|
|
49
|
+
positional.push(arg);
|
|
50
|
+
} else {
|
|
51
|
+
opts.error = `unexpected argument "${arg}"`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
opts.cwd = resolve(opts.cwd || PROJECT_DIR);
|
|
56
|
+
if (!Number.isFinite(opts.interval) || opts.interval < 1) opts.interval = 5;
|
|
57
|
+
return { opts, positional };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function queryOpenCodeDb(dbPath, sql) {
|
|
61
|
+
const result = spawnSync('sqlite3', ['-json', dbPath, sql], {
|
|
62
|
+
encoding: 'utf8',
|
|
63
|
+
maxBuffer: 24 * 1024 * 1024,
|
|
64
|
+
});
|
|
65
|
+
if ((result.status ?? 0) !== 0) {
|
|
66
|
+
const detail = result.stderr?.trim() || result.stdout?.trim() || `exit ${result.status ?? 1}`;
|
|
67
|
+
throw new Error(`job-forge telemetry: sqlite3 query failed: ${detail}`);
|
|
68
|
+
}
|
|
69
|
+
return JSON.parse(result.stdout || '[]');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sqlString(value) {
|
|
73
|
+
return `'${String(value).replaceAll("'", "''")}'`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function msToIso(ms) {
|
|
77
|
+
return new Date(Number(ms)).toISOString();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function discoverSessions(opts, { includeAllForShow = false } = {}) {
|
|
81
|
+
const dbPath = defaultOpenCodeDbPath();
|
|
82
|
+
if (!existsSync(dbPath)) return [];
|
|
83
|
+
|
|
84
|
+
const where = [
|
|
85
|
+
's.time_archived is null',
|
|
86
|
+
`s.directory = ${sqlString(opts.cwd)}`,
|
|
87
|
+
];
|
|
88
|
+
const sinceMs = includeAllForShow ? undefined : parseSinceCutoff(opts.since);
|
|
89
|
+
if (sinceMs !== undefined) where.push(`s.time_created >= ${Number(sinceMs)}`);
|
|
90
|
+
|
|
91
|
+
const rows = queryOpenCodeDb(dbPath, [
|
|
92
|
+
'select',
|
|
93
|
+
' s.id,',
|
|
94
|
+
' s.parent_id,',
|
|
95
|
+
' s.title,',
|
|
96
|
+
' s.directory,',
|
|
97
|
+
' s.time_created,',
|
|
98
|
+
' s.time_updated,',
|
|
99
|
+
' (select count(*) from message m where m.session_id = s.id) as turn_count,',
|
|
100
|
+
' (',
|
|
101
|
+
' (select coalesce(sum(length(data)), 0) from message m where m.session_id = s.id) +',
|
|
102
|
+
' (select coalesce(sum(length(data)), 0) from part p where p.session_id = s.id)',
|
|
103
|
+
' ) as size_bytes',
|
|
104
|
+
'from session s',
|
|
105
|
+
`where ${where.join(' and ')}`,
|
|
106
|
+
'order by s.time_updated desc',
|
|
107
|
+
].join(' '));
|
|
108
|
+
|
|
109
|
+
return rows.map((row) => ({
|
|
110
|
+
id: row.id,
|
|
111
|
+
parentId: row.parent_id || null,
|
|
112
|
+
title: row.title || '',
|
|
113
|
+
cwd: row.directory,
|
|
114
|
+
startedAt: msToIso(row.time_created),
|
|
115
|
+
endedAt: msToIso(row.time_updated),
|
|
116
|
+
turnCount: row.turn_count ?? 0,
|
|
117
|
+
sizeBytes: row.size_bytes ?? 0,
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function loadRows(sessionId) {
|
|
122
|
+
const dbPath = defaultOpenCodeDbPath();
|
|
123
|
+
const id = sqlString(sessionId);
|
|
124
|
+
return {
|
|
125
|
+
messages: queryOpenCodeDb(dbPath, `select id, time_created, data from message where session_id = ${id} order by time_created, id`),
|
|
126
|
+
parts: queryOpenCodeDb(dbPath, `select id, message_id, time_created, data from part where session_id = ${id} order by time_created, id`),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseJson(raw) {
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(raw || '{}');
|
|
133
|
+
} catch (error) {
|
|
134
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
135
|
+
return { __parseError: message, __raw: raw || '' };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function analyzeSession(session, allSessions, opts) {
|
|
140
|
+
const rows = loadRows(session.id);
|
|
141
|
+
const messages = rows.messages.map((row) => ({ row, data: parseJson(row.data) }));
|
|
142
|
+
const parts = rows.parts.map((row) => ({ row, data: parseJson(row.data) }));
|
|
143
|
+
const messageById = new Map(messages.map((m) => [m.row.id, m.data]));
|
|
144
|
+
const textParts = parts.filter((p) => p.data.type === 'text');
|
|
145
|
+
const userPrompt = firstUserText(textParts, messageById);
|
|
146
|
+
const taskCalls = parts.filter((p) => p.data.type === 'tool' && p.data.tool === 'task').map(taskCallSummary);
|
|
147
|
+
const providerErrors = messages.map(providerErrorSummary).filter(Boolean);
|
|
148
|
+
const policyIssues = detectPolicyIssues(session, parts, textParts, messageById, providerErrors);
|
|
149
|
+
const tracker = trackerStatus(opts.cwd);
|
|
150
|
+
const children = allSessions
|
|
151
|
+
.filter((candidate) => candidate.parentId === session.id)
|
|
152
|
+
.sort((a, b) => a.startedAt.localeCompare(b.startedAt))
|
|
153
|
+
.map((child) => childSummary(child));
|
|
154
|
+
const childOutcomes = children.filter((child) => child.outcome !== 'unknown').length;
|
|
155
|
+
const childProviderErrors = children.reduce((sum, child) => sum + child.providerErrors, 0);
|
|
156
|
+
const status = sessionStatus({ session, taskCalls, children, childOutcomes, childProviderErrors, policyIssues, providerErrors });
|
|
157
|
+
const recommendations = nextActions({ tracker, policyIssues, providerErrors, taskCalls, children });
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
session,
|
|
161
|
+
projectDir: opts.cwd,
|
|
162
|
+
status,
|
|
163
|
+
prompt: userPrompt,
|
|
164
|
+
tasks: {
|
|
165
|
+
total: taskCalls.length,
|
|
166
|
+
statusPolls: taskCalls.filter((task) => task.isStatusPoll).length,
|
|
167
|
+
calls: taskCalls,
|
|
168
|
+
},
|
|
169
|
+
children: {
|
|
170
|
+
total: children.length,
|
|
171
|
+
withOutcomes: childOutcomes,
|
|
172
|
+
providerErrors: childProviderErrors,
|
|
173
|
+
sessions: children,
|
|
174
|
+
},
|
|
175
|
+
providerErrors,
|
|
176
|
+
policyIssues,
|
|
177
|
+
tracker,
|
|
178
|
+
recommendations,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function firstUserText(textParts, messageById) {
|
|
183
|
+
for (const part of textParts) {
|
|
184
|
+
if (messageById.get(part.row.message_id)?.role === 'user') {
|
|
185
|
+
return clean(redactSecrets(part.data.text || ''));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return '';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function taskCallSummary(part) {
|
|
192
|
+
const input = objectOrEmpty(part.data.state?.input);
|
|
193
|
+
const metadata = objectOrEmpty(part.data.state?.metadata);
|
|
194
|
+
const prompt = typeof input.prompt === 'string' ? input.prompt : '';
|
|
195
|
+
const description = stringValue(input.description || metadata.description || part.data.state?.title);
|
|
196
|
+
const sessionId = stringValue(input.task_id || metadata.sessionId);
|
|
197
|
+
const subagentType = stringValue(input.subagent_type || metadata.subagent_type || metadata.agent);
|
|
198
|
+
const isStatusPoll = Boolean(input.task_id) ||
|
|
199
|
+
/\b(check|poll|status|force|abort|progress|result)\b/i.test(description) ||
|
|
200
|
+
/\b(return your final outcome now|if still working|current status|report your current status|still running)\b/i.test(prompt);
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
at: msToIso(part.row.time_created),
|
|
204
|
+
description,
|
|
205
|
+
subagentType,
|
|
206
|
+
sessionId,
|
|
207
|
+
status: stringValue(part.data.state?.status),
|
|
208
|
+
isStatusPoll,
|
|
209
|
+
promptBytes: Buffer.byteLength(prompt, 'utf8'),
|
|
210
|
+
proxyLeak: hasProxyLeak(prompt),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function providerErrorSummary(message) {
|
|
215
|
+
const error = message.data.error;
|
|
216
|
+
if (!error) return null;
|
|
217
|
+
const rawMessage = stringValue(error.data?.message || error.message || error.name || 'unknown provider error');
|
|
218
|
+
const statusCode = error.data?.statusCode ?? statusCodeFromText(rawMessage);
|
|
219
|
+
return {
|
|
220
|
+
at: msToIso(message.row.time_created),
|
|
221
|
+
provider: stringValue(message.data.providerID),
|
|
222
|
+
model: stringValue(message.data.modelID),
|
|
223
|
+
statusCode,
|
|
224
|
+
category: providerErrorCategory(rawMessage, statusCode),
|
|
225
|
+
message: redactSecrets(rawMessage),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function detectPolicyIssues(session, parts, textParts, messageById, providerErrors) {
|
|
230
|
+
const issues = [];
|
|
231
|
+
const taskParts = parts.filter((p) => p.data.type === 'tool' && p.data.tool === 'task');
|
|
232
|
+
const statusPolls = taskParts.map(taskCallSummary).filter((task) => task.isStatusPoll);
|
|
233
|
+
if (statusPolls.length > 0) {
|
|
234
|
+
issues.push({
|
|
235
|
+
type: 'task_status_poll',
|
|
236
|
+
severity: 'high',
|
|
237
|
+
count: statusPolls.length,
|
|
238
|
+
detail: 'A task call tried to poll/check an existing task session.',
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const proxyLeakCount = parts.reduce((count, part) => count + (partHasProxyLeak(part) ? 1 : 0), 0);
|
|
243
|
+
if (proxyLeakCount > 0) {
|
|
244
|
+
issues.push({
|
|
245
|
+
type: 'proxy_prompt_leak',
|
|
246
|
+
severity: 'high',
|
|
247
|
+
count: proxyLeakCount,
|
|
248
|
+
detail: 'Prompt/tool input appears to contain proxy field values. Values are intentionally not printed.',
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const childTaskCalls = session.parentId ? taskParts.length : 0;
|
|
253
|
+
if (childTaskCalls > 0) {
|
|
254
|
+
issues.push({
|
|
255
|
+
type: 'subagent_spawned_task',
|
|
256
|
+
severity: 'high',
|
|
257
|
+
count: childTaskCalls,
|
|
258
|
+
detail: 'A child/subagent session used the task tool.',
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const provider402 = providerErrors.filter((err) => err.statusCode === 402).length;
|
|
263
|
+
if (provider402 > 0) {
|
|
264
|
+
issues.push({
|
|
265
|
+
type: 'provider_balance_error',
|
|
266
|
+
severity: 'medium',
|
|
267
|
+
count: provider402,
|
|
268
|
+
detail: 'Provider reported insufficient balance/credits.',
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const finalText = textParts
|
|
273
|
+
.filter((part) => messageById.get(part.row.message_id)?.role === 'assistant')
|
|
274
|
+
.slice(-5)
|
|
275
|
+
.map((part) => part.data.text || '')
|
|
276
|
+
.join('\n');
|
|
277
|
+
if (taskParts.length > 0 && !hasOutcome(finalText) && !/round .*in flight|still running|waiting/i.test(finalText)) {
|
|
278
|
+
issues.push({
|
|
279
|
+
type: 'no_visible_final_outcome',
|
|
280
|
+
severity: 'medium',
|
|
281
|
+
count: 1,
|
|
282
|
+
detail: 'Session dispatched task work but recent assistant text has no final outcome or in-flight notice.',
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return issues;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function partHasProxyLeak(part) {
|
|
290
|
+
const data = part.data;
|
|
291
|
+
if (data.type === 'text' || data.type === 'reasoning') return hasProxyLeak(data.text || '');
|
|
292
|
+
if (data.type === 'tool') return hasProxyLeak(JSON.stringify(data.state?.input || {}));
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function childSummary(session) {
|
|
297
|
+
const rows = loadRows(session.id);
|
|
298
|
+
const messages = rows.messages.map((row) => ({ row, data: parseJson(row.data) }));
|
|
299
|
+
const parts = rows.parts.map((row) => ({ row, data: parseJson(row.data) }));
|
|
300
|
+
const text = parts.filter((p) => p.data.type === 'text').map((p) => p.data.text || '').join('\n');
|
|
301
|
+
const providerErrors = messages.map(providerErrorSummary).filter(Boolean);
|
|
302
|
+
const taskCalls = parts.filter((p) => p.data.type === 'tool' && p.data.tool === 'task').length;
|
|
303
|
+
const trackerWrites = parts.filter((p) => p.data.type === 'tool' && /batch\/tracker-additions\/.*\.tsv/.test(JSON.stringify(p.data.state?.input || {}))).length;
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
id: session.id,
|
|
307
|
+
title: session.title,
|
|
308
|
+
startedAt: session.startedAt,
|
|
309
|
+
endedAt: session.endedAt,
|
|
310
|
+
outcome: outcomeFromText(text, trackerWrites),
|
|
311
|
+
providerErrors: providerErrors.length,
|
|
312
|
+
taskCalls,
|
|
313
|
+
trackerWrites,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function outcomeFromText(text, trackerWrites = 0) {
|
|
318
|
+
if (/\bAPPLIED\b|status[^\n|]*Applied|successfully submitted|Applied via/i.test(text)) return 'Applied';
|
|
319
|
+
if (/\bDiscarded\b|\bSKIP\b/i.test(text)) return 'Discarded';
|
|
320
|
+
if (/\bAPPLY FAILED\b|\bFailed\b|\bFAILED\b/i.test(text)) return 'Failed';
|
|
321
|
+
if (trackerWrites > 0) return 'TSV written';
|
|
322
|
+
return 'unknown';
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function hasOutcome(text) {
|
|
326
|
+
return outcomeFromText(text) !== 'unknown' || /tracker-additions\/.*\.tsv/i.test(text);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function sessionStatus({ taskCalls, children, childOutcomes, childProviderErrors, policyIssues, providerErrors }) {
|
|
330
|
+
if (policyIssues.some((issue) => issue.severity === 'high')) return 'attention';
|
|
331
|
+
if (providerErrors.length > 0) return 'attention';
|
|
332
|
+
if (childProviderErrors > 0) return 'attention';
|
|
333
|
+
if (taskCalls.length > 0 && children.length > childOutcomes) return 'in-flight-or-incomplete';
|
|
334
|
+
if (taskCalls.length > 0 && children.length === childOutcomes) return 'complete';
|
|
335
|
+
return 'observed';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function trackerStatus(projectDir) {
|
|
339
|
+
const pendingDir = join(projectDir, 'batch', 'tracker-additions');
|
|
340
|
+
const mergedDir = join(pendingDir, 'merged');
|
|
341
|
+
return {
|
|
342
|
+
pending: listTsv(pendingDir),
|
|
343
|
+
mergedCount: listTsv(mergedDir).length,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function listTsv(dir) {
|
|
348
|
+
try {
|
|
349
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
|
|
350
|
+
return readdirSync(dir)
|
|
351
|
+
.filter((name) => name.endsWith('.tsv'))
|
|
352
|
+
.sort()
|
|
353
|
+
.map((name) => join(dir, name));
|
|
354
|
+
} catch {
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function nextActions({ tracker, policyIssues, providerErrors, children }) {
|
|
360
|
+
const actions = [];
|
|
361
|
+
if (tracker.pending.length > 0) actions.push('Run `npm run merge && npm run verify` when you are ready to fold pending TSV outcomes into day files.');
|
|
362
|
+
if (policyIssues.some((issue) => issue.type === 'task_status_poll')) actions.push('Avoid resuming by spawning "check task status" tasks; inspect telemetry/trace and tracker files instead.');
|
|
363
|
+
if (policyIssues.some((issue) => issue.type === 'proxy_prompt_leak')) actions.push('Restart OpenCode after updating the harness so new sessions load the proxy prompt hygiene rule.');
|
|
364
|
+
if (providerErrors.some((err) => err.statusCode === 402)) actions.push('Provider balance errors occurred; use a non-402 fallback or add provider credits before retrying paid routes.');
|
|
365
|
+
if (children.some((child) => child.outcome === 'unknown')) actions.push('Some child sessions have no visible final outcome; inspect them with `npm run telemetry:show -- <child-session-id>`.');
|
|
366
|
+
return actions;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function summaryForList(telemetry) {
|
|
370
|
+
return {
|
|
371
|
+
id: telemetry.session.id,
|
|
372
|
+
startedAt: telemetry.session.startedAt,
|
|
373
|
+
updatedAt: telemetry.session.endedAt,
|
|
374
|
+
status: telemetry.status,
|
|
375
|
+
prompt: telemetry.prompt,
|
|
376
|
+
tasks: telemetry.tasks.total,
|
|
377
|
+
children: telemetry.children.total,
|
|
378
|
+
outcomes: telemetry.children.withOutcomes,
|
|
379
|
+
issues: telemetry.policyIssues.length,
|
|
380
|
+
providerErrors: telemetry.providerErrors.length + telemetry.children.providerErrors,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function printList(items) {
|
|
385
|
+
const rows = items.map((item) => [
|
|
386
|
+
item.id,
|
|
387
|
+
item.startedAt.replace('T', ' ').replace(/\.\d+Z$/, 'Z'),
|
|
388
|
+
item.status,
|
|
389
|
+
String(item.tasks),
|
|
390
|
+
`${item.outcomes}/${item.children}`,
|
|
391
|
+
String(item.issues + item.providerErrors),
|
|
392
|
+
shorten(item.prompt || '', 42),
|
|
393
|
+
]);
|
|
394
|
+
const header = ['session', 'started', 'status', 'tasks', 'outcomes', 'alerts', 'prompt'];
|
|
395
|
+
printTable(header, rows);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function printStatus(telemetry) {
|
|
399
|
+
console.log(`project: ${telemetry.projectDir}`);
|
|
400
|
+
console.log(`session: ${telemetry.session.id}`);
|
|
401
|
+
console.log(`status: ${telemetry.status}`);
|
|
402
|
+
console.log(`started: ${telemetry.session.startedAt}`);
|
|
403
|
+
console.log(`prompt: ${shorten(telemetry.prompt || '', 100)}`);
|
|
404
|
+
console.log(`tasks: ${telemetry.tasks.total} (${telemetry.tasks.statusPolls} status-poll)`);
|
|
405
|
+
console.log(`children: ${telemetry.children.withOutcomes}/${telemetry.children.total} with outcomes`);
|
|
406
|
+
console.log(`tracker: ${telemetry.tracker.pending.length} pending TSVs, ${telemetry.tracker.mergedCount} merged TSVs`);
|
|
407
|
+
console.log(`errors: ${telemetry.providerErrors.length} root, ${telemetry.children.providerErrors} child provider errors`);
|
|
408
|
+
console.log(`issues: ${telemetry.policyIssues.length}`);
|
|
409
|
+
|
|
410
|
+
if (telemetry.policyIssues.length > 0) {
|
|
411
|
+
console.log('\nissues:');
|
|
412
|
+
for (const issue of telemetry.policyIssues) {
|
|
413
|
+
console.log(` - ${issue.severity} ${issue.type} x${issue.count}: ${issue.detail}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (telemetry.tracker.pending.length > 0) {
|
|
418
|
+
console.log('\npending TSVs:');
|
|
419
|
+
for (const file of telemetry.tracker.pending.slice(0, 12)) {
|
|
420
|
+
console.log(` - ${relativeToProject(file, telemetry.projectDir)}`);
|
|
421
|
+
}
|
|
422
|
+
if (telemetry.tracker.pending.length > 12) console.log(` - ...${telemetry.tracker.pending.length - 12} more`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (telemetry.children.sessions.length > 0) {
|
|
426
|
+
console.log('\nchild sessions:');
|
|
427
|
+
for (const child of telemetry.children.sessions) {
|
|
428
|
+
const alerts = [];
|
|
429
|
+
if (child.providerErrors) alerts.push(`${child.providerErrors} provider error`);
|
|
430
|
+
if (child.taskCalls) alerts.push(`${child.taskCalls} task call`);
|
|
431
|
+
console.log(` - ${child.id} ${child.outcome} ${child.title}${alerts.length ? ` (${alerts.join(', ')})` : ''}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (telemetry.recommendations.length > 0) {
|
|
436
|
+
console.log('\nnext:');
|
|
437
|
+
for (const action of telemetry.recommendations) console.log(` - ${action}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function printShow(telemetry) {
|
|
442
|
+
printStatus(telemetry);
|
|
443
|
+
if (telemetry.tasks.calls.length > 0) {
|
|
444
|
+
console.log('\ntask dispatches:');
|
|
445
|
+
for (const task of telemetry.tasks.calls) {
|
|
446
|
+
const flags = [
|
|
447
|
+
task.isStatusPoll ? 'status-poll' : '',
|
|
448
|
+
task.proxyLeak ? 'proxy-values-detected' : '',
|
|
449
|
+
].filter(Boolean).join(', ');
|
|
450
|
+
console.log(` - ${task.at} ${task.description || '(no description)'} ${task.sessionId || ''} ${task.subagentType || ''}${flags ? ` [${flags}]` : ''}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (telemetry.providerErrors.length > 0) {
|
|
454
|
+
console.log('\nprovider errors:');
|
|
455
|
+
for (const err of telemetry.providerErrors) {
|
|
456
|
+
console.log(` - ${err.at} ${err.provider}/${err.model} ${err.statusCode || ''} ${err.category}: ${err.message}`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function printTable(header, rows) {
|
|
462
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i].length)));
|
|
463
|
+
console.log(header.map((h, i) => pad(h, widths[i])).join(' '));
|
|
464
|
+
console.log(widths.map((w) => '-'.repeat(w)).join(' '));
|
|
465
|
+
for (const row of rows) console.log(row.map((cell, i) => pad(cell, widths[i])).join(' '));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function latestRootTelemetry(opts) {
|
|
469
|
+
const sessions = discoverSessions(opts);
|
|
470
|
+
const roots = sessions.filter((session) => !session.parentId);
|
|
471
|
+
if (roots.length === 0) return { sessions, telemetry: null };
|
|
472
|
+
return { sessions, telemetry: analyzeSession(roots[0], sessions, opts) };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function objectOrEmpty(value) {
|
|
476
|
+
return value && typeof value === 'object' ? value : {};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function stringValue(value) {
|
|
480
|
+
return typeof value === 'string' ? value : value == null ? '' : String(value);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function statusCodeFromText(text) {
|
|
484
|
+
const match = String(text).match(/\b(40[0-9]|42[0-9]|50[0-9])\b/);
|
|
485
|
+
return match ? Number(match[1]) : undefined;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function providerErrorCategory(text, statusCode) {
|
|
489
|
+
if (statusCode === 402 || /insufficient|balance|credits|diem/i.test(text)) return 'balance';
|
|
490
|
+
if (statusCode === 429 || /rate.?limit|quota/i.test(text)) return 'rate-limit';
|
|
491
|
+
if (/overload|temporarily unavailable|timeout/i.test(text)) return 'transient';
|
|
492
|
+
return 'provider-error';
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function hasProxyLeak(text) {
|
|
496
|
+
const raw = String(text || '');
|
|
497
|
+
if (!/proxy/i.test(raw)) return false;
|
|
498
|
+
return /\b(server|username|password|bypass)["']?\s*[:=]\s*["']?[^"',\s)}]+/i.test(raw) ||
|
|
499
|
+
/brd-customer|superproxy|oxylabs|smartproxy|soax/i.test(raw);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function redactSecrets(text) {
|
|
503
|
+
return String(text || '')
|
|
504
|
+
.replace(/\b(password|username|server|bypass)["']?\s*[:=]\s*["']?[^"',\s)}]+/gi, '$1=<redacted>')
|
|
505
|
+
.replace(/brd-customer-[A-Za-z0-9_.-]+/g, '<redacted-proxy-user>');
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function relativeToProject(file, projectDir = PROJECT_DIR) {
|
|
509
|
+
return file.startsWith(`${projectDir}/`) ? file.slice(projectDir.length + 1) : file;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function clean(text) {
|
|
513
|
+
return String(text || '').replace(/\s+/g, ' ').trim();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function shorten(value, max) {
|
|
517
|
+
const text = clean(value);
|
|
518
|
+
if (text.length <= max) return text;
|
|
519
|
+
return `${text.slice(0, max - 1)}...`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function pad(value, width) {
|
|
523
|
+
const text = String(value ?? '');
|
|
524
|
+
return text.length >= width ? text : text + ' '.repeat(width - text.length);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function runWatch(opts) {
|
|
528
|
+
while (true) {
|
|
529
|
+
console.clear();
|
|
530
|
+
console.log(new Date().toISOString());
|
|
531
|
+
const { telemetry } = latestRootTelemetry(opts);
|
|
532
|
+
if (!telemetry) {
|
|
533
|
+
console.log('No recent JobForge OpenCode sessions found.');
|
|
534
|
+
} else {
|
|
535
|
+
printStatus(telemetry);
|
|
536
|
+
}
|
|
537
|
+
await new Promise((resolveTimer) => setTimeout(resolveTimer, opts.interval * 1000));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function main() {
|
|
542
|
+
if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
543
|
+
console.log(USAGE);
|
|
544
|
+
return 0;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (cmd === 'list') {
|
|
548
|
+
const { opts } = parseArgs(args);
|
|
549
|
+
if (opts.help) {
|
|
550
|
+
console.log(USAGE);
|
|
551
|
+
return 0;
|
|
552
|
+
}
|
|
553
|
+
if (opts.error) {
|
|
554
|
+
console.error(`job-forge telemetry:list: ${opts.error}`);
|
|
555
|
+
return 2;
|
|
556
|
+
}
|
|
557
|
+
const sessions = discoverSessions(opts);
|
|
558
|
+
const items = sessions
|
|
559
|
+
.filter((session) => !session.parentId)
|
|
560
|
+
.map((session) => summaryForList(analyzeSession(session, sessions, opts)));
|
|
561
|
+
if (opts.json) {
|
|
562
|
+
console.log(JSON.stringify(items, null, 2));
|
|
563
|
+
} else if (items.length === 0) {
|
|
564
|
+
console.error('job-forge telemetry:list: no recent JobForge OpenCode sessions found');
|
|
565
|
+
return 2;
|
|
566
|
+
} else {
|
|
567
|
+
printList(items);
|
|
568
|
+
}
|
|
569
|
+
return 0;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (cmd === 'status') {
|
|
573
|
+
const { opts } = parseArgs(args);
|
|
574
|
+
if (opts.help) {
|
|
575
|
+
console.log(USAGE);
|
|
576
|
+
return 0;
|
|
577
|
+
}
|
|
578
|
+
if (opts.error) {
|
|
579
|
+
console.error(`job-forge telemetry:status: ${opts.error}`);
|
|
580
|
+
return 2;
|
|
581
|
+
}
|
|
582
|
+
const { telemetry } = latestRootTelemetry(opts);
|
|
583
|
+
if (!telemetry) {
|
|
584
|
+
console.error('job-forge telemetry:status: no recent JobForge OpenCode sessions found');
|
|
585
|
+
return 2;
|
|
586
|
+
}
|
|
587
|
+
if (opts.json) console.log(JSON.stringify(telemetry, null, 2));
|
|
588
|
+
else printStatus(telemetry);
|
|
589
|
+
return 0;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (cmd === 'show') {
|
|
593
|
+
const { opts, positional } = parseArgs(args, { allowSession: true });
|
|
594
|
+
if (opts.help) {
|
|
595
|
+
console.log(USAGE);
|
|
596
|
+
return 0;
|
|
597
|
+
}
|
|
598
|
+
if (opts.error) {
|
|
599
|
+
console.error(`job-forge telemetry:show: ${opts.error}`);
|
|
600
|
+
return 2;
|
|
601
|
+
}
|
|
602
|
+
if (positional.length === 0) {
|
|
603
|
+
console.error('job-forge telemetry:show: missing <id-or-prefix>');
|
|
604
|
+
return 2;
|
|
605
|
+
}
|
|
606
|
+
const sessions = discoverSessions(opts, { includeAllForShow: true });
|
|
607
|
+
const session = findSessionById(sessions, positional[0]);
|
|
608
|
+
if (!session) {
|
|
609
|
+
console.error(`job-forge telemetry:show: no session matches "${positional[0]}"`);
|
|
610
|
+
return 2;
|
|
611
|
+
}
|
|
612
|
+
const telemetry = analyzeSession(session, sessions, opts);
|
|
613
|
+
if (opts.json) console.log(JSON.stringify(telemetry, null, 2));
|
|
614
|
+
else printShow(telemetry);
|
|
615
|
+
return 0;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (cmd === 'watch') {
|
|
619
|
+
const { opts } = parseArgs(args, { allowInterval: true });
|
|
620
|
+
if (opts.help) {
|
|
621
|
+
console.log(USAGE);
|
|
622
|
+
return 0;
|
|
623
|
+
}
|
|
624
|
+
if (opts.error) {
|
|
625
|
+
console.error(`job-forge telemetry:watch: ${opts.error}`);
|
|
626
|
+
return 2;
|
|
627
|
+
}
|
|
628
|
+
await runWatch(opts);
|
|
629
|
+
return 0;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
console.error(`job-forge telemetry: unknown command "${cmd}"\n`);
|
|
633
|
+
console.error(USAGE);
|
|
634
|
+
return 2;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
main()
|
|
638
|
+
.then((code) => process.exit(code))
|
|
639
|
+
.catch((error) => {
|
|
640
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
641
|
+
console.error(message);
|
|
642
|
+
process.exit(1);
|
|
643
|
+
});
|