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.
@@ -0,0 +1,469 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from 'child_process';
4
+ import { existsSync } from 'fs';
5
+ import { createRequire } from 'module';
6
+ import { dirname, join, resolve } from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ import {
9
+ defaultOpenCodeDbPath,
10
+ findSessionById,
11
+ openCodeSessionLocator,
12
+ parseSinceCutoff,
13
+ } from '@razroo/iso-trace';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const require = createRequire(import.meta.url);
17
+ const PROJECT_DIR = process.env.JOB_FORGE_PROJECT || process.cwd();
18
+
19
+ const USAGE = `job-forge trace — local OpenCode transcript observability
20
+
21
+ Usage:
22
+ job-forge trace:list [--since 7d] [--cwd <dir>] [--json]
23
+ job-forge trace:stats [<id-or-prefix>...] [--since 7d] [--cwd <dir>] [--json]
24
+ job-forge trace:show <id-or-prefix> [--events <kinds>] [--grep <regex>]
25
+ job-forge trace <iso-trace args...>
26
+
27
+ Common aliases default to OpenCode sessions for the current JobForge project.
28
+ Use "job-forge trace sources" or "job-forge trace where" for raw iso-trace passthrough.`;
29
+
30
+ const [cmd = 'help', ...args] = process.argv.slice(2);
31
+
32
+ function parseFilters(rawArgs) {
33
+ const opts = { since: '7d', cwd: PROJECT_DIR, json: false };
34
+ const positional = [];
35
+
36
+ for (let i = 0; i < rawArgs.length; i++) {
37
+ const arg = rawArgs[i];
38
+ if (arg === '--since') {
39
+ opts.since = rawArgs[++i];
40
+ } else if (arg.startsWith('--since=')) {
41
+ opts.since = arg.slice('--since='.length);
42
+ } else if (arg === '--cwd') {
43
+ opts.cwd = rawArgs[++i];
44
+ } else if (arg.startsWith('--cwd=')) {
45
+ opts.cwd = arg.slice('--cwd='.length);
46
+ } else if (arg === '--json') {
47
+ opts.json = true;
48
+ } else if (arg === '--help' || arg === '-h') {
49
+ opts.help = true;
50
+ } else if (arg.startsWith('--')) {
51
+ opts.error = `unknown flag "${arg}"`;
52
+ } else {
53
+ positional.push(arg);
54
+ }
55
+ }
56
+
57
+ opts.cwd = resolve(opts.cwd || PROJECT_DIR);
58
+ return { opts, positional };
59
+ }
60
+
61
+ function parseShowArgs(rawArgs) {
62
+ const opts = {};
63
+ const positional = [];
64
+
65
+ for (let i = 0; i < rawArgs.length; i++) {
66
+ const arg = rawArgs[i];
67
+ if (arg === '--events') {
68
+ const raw = rawArgs[++i] || '';
69
+ opts.events = new Set(raw.split(',').map((s) => s.trim()).filter(Boolean));
70
+ } else if (arg.startsWith('--events=')) {
71
+ const raw = arg.slice('--events='.length);
72
+ opts.events = new Set(raw.split(',').map((s) => s.trim()).filter(Boolean));
73
+ } else if (arg === '--grep') {
74
+ opts.grep = compileRegex(rawArgs[++i], 'trace:show');
75
+ } else if (arg.startsWith('--grep=')) {
76
+ opts.grep = compileRegex(arg.slice('--grep='.length), 'trace:show');
77
+ } else if (arg === '--help' || arg === '-h') {
78
+ opts.help = true;
79
+ } else if (arg.startsWith('--')) {
80
+ opts.error = `unknown flag "${arg}"`;
81
+ } else {
82
+ positional.push(arg);
83
+ }
84
+ }
85
+
86
+ return { opts, positional };
87
+ }
88
+
89
+ function compileRegex(pattern, context) {
90
+ try {
91
+ return new RegExp(pattern || '', 'i');
92
+ } catch (error) {
93
+ const message = error instanceof Error ? error.message : String(error);
94
+ return new Error(`${context}: invalid --grep regex: ${message}`);
95
+ }
96
+ }
97
+
98
+ async function discoverOpenCodeRefs(opts) {
99
+ const dbPath = defaultOpenCodeDbPath();
100
+ if (!existsSync(dbPath)) return [];
101
+
102
+ const where = [
103
+ 's.time_archived is null',
104
+ `s.directory = ${sqlString(resolve(opts.cwd || PROJECT_DIR))}`,
105
+ ];
106
+ const sinceMs = parseSinceCutoff(opts.since);
107
+ if (sinceMs !== undefined) {
108
+ where.push(`s.time_created >= ${Number(sinceMs)}`);
109
+ }
110
+
111
+ const rows = queryOpenCodeDb(dbPath, [
112
+ 'select',
113
+ ' s.id,',
114
+ ' s.directory,',
115
+ ' s.time_created,',
116
+ ' s.time_updated,',
117
+ ' (select count(*) from message m where m.session_id = s.id) as turn_count,',
118
+ ' (',
119
+ ' (select coalesce(sum(length(data)), 0) from message m where m.session_id = s.id) +',
120
+ ' (select coalesce(sum(length(data)), 0) from part p where p.session_id = s.id)',
121
+ ' ) as size_bytes',
122
+ 'from session s',
123
+ `where ${where.join(' and ')}`,
124
+ 'order by s.time_updated desc',
125
+ ].join(' '));
126
+
127
+ return rows.map((row) => ({
128
+ id: row.id,
129
+ source: {
130
+ harness: 'opencode',
131
+ format: 'opencode/sqlite-v1',
132
+ path: openCodeSessionLocator(row.id, dbPath),
133
+ },
134
+ cwd: row.directory,
135
+ startedAt: msToIso(row.time_created),
136
+ endedAt: msToIso(row.time_updated),
137
+ turnCount: row.turn_count ?? 0,
138
+ sizeBytes: row.size_bytes ?? 0,
139
+ }));
140
+ }
141
+
142
+ function queryOpenCodeDb(dbPath, sql) {
143
+ const result = spawnSync('sqlite3', ['-json', dbPath, sql], {
144
+ encoding: 'utf8',
145
+ maxBuffer: 16 * 1024 * 1024,
146
+ });
147
+ if ((result.status ?? 0) !== 0) {
148
+ const detail = result.stderr?.trim() || result.stdout?.trim() || `exit ${result.status ?? 1}`;
149
+ throw new Error(`job-forge trace: sqlite3 query failed: ${detail}`);
150
+ }
151
+ return JSON.parse(result.stdout || '[]');
152
+ }
153
+
154
+ function sqlString(value) {
155
+ return `'${String(value).replaceAll("'", "''")}'`;
156
+ }
157
+
158
+ function msToIso(ms) {
159
+ return new Date(Number(ms)).toISOString();
160
+ }
161
+
162
+ function sizeLabel(bytes) {
163
+ if (bytes < 1024) return `${bytes} B`;
164
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
165
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
166
+ }
167
+
168
+ function shorten(value, max) {
169
+ const text = String(value ?? '');
170
+ if (text.length <= max) return text;
171
+ return `${text.slice(0, max - 1)}...`;
172
+ }
173
+
174
+ function pad(value, width) {
175
+ const text = String(value ?? '');
176
+ return text.length >= width ? text : text + ' '.repeat(width - text.length);
177
+ }
178
+
179
+ function printSessionTable(refs) {
180
+ const rows = refs.map((ref) => [
181
+ ref.id,
182
+ ref.startedAt.replace('T', ' ').replace(/\.\d+Z$/, 'Z'),
183
+ shorten(ref.cwd, 42),
184
+ String(ref.turnCount),
185
+ sizeLabel(ref.sizeBytes),
186
+ ]);
187
+ const header = ['id', 'started', 'cwd', 'turns', 'size'];
188
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i].length)));
189
+
190
+ console.log(header.map((h, i) => pad(h, widths[i])).join(' '));
191
+ console.log(widths.map((w) => '-'.repeat(w)).join(' '));
192
+ for (const row of rows) {
193
+ console.log(row.map((cell, i) => pad(cell, widths[i])).join(' '));
194
+ }
195
+ }
196
+
197
+ function printStats(result) {
198
+ console.log(`sessions: ${result.sessions}`);
199
+ console.log(`turns: ${result.turns}`);
200
+ console.log(`duration: ${Math.round(result.durationMs / 1000)}s`);
201
+ console.log(`tokens: input=${result.tokens.input} output=${result.tokens.output} cache_read=${result.tokens.cacheRead} cache_created=${result.tokens.cacheCreated}`);
202
+
203
+ console.log('\ntool calls:');
204
+ for (const [name, count] of Object.entries(result.toolCalls).sort((a, b) => b[1] - a[1])) {
205
+ console.log(` ${pad(name, 28)} ${count}`);
206
+ }
207
+
208
+ console.log('\nfile ops:');
209
+ for (const [name, count] of Object.entries(result.fileOps).sort((a, b) => b[1] - a[1])) {
210
+ console.log(` ${pad(name, 8)} ${count}`);
211
+ }
212
+ }
213
+
214
+ function computeOpenCodeStats(refs) {
215
+ const result = {
216
+ sessions: refs.length,
217
+ turns: 0,
218
+ durationMs: 0,
219
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheCreated: 0 },
220
+ toolCalls: {},
221
+ fileOps: {},
222
+ };
223
+
224
+ for (const ref of refs) {
225
+ const rows = loadOpenCodeRows(ref.id);
226
+ result.turns += rows.messages.length;
227
+ result.durationMs += Math.max(0, Date.parse(ref.endedAt || ref.startedAt) - Date.parse(ref.startedAt));
228
+
229
+ for (const row of rows.messages) {
230
+ const data = parseJson(row.data);
231
+ const tokens = data.tokens;
232
+ if (!tokens || typeof tokens !== 'object') continue;
233
+ result.tokens.input += Number(tokens.input || 0);
234
+ result.tokens.output += Number(tokens.output || 0);
235
+ result.tokens.cacheRead += Number(tokens.cache?.read || 0);
236
+ result.tokens.cacheCreated += Number(tokens.cache?.write || 0);
237
+ }
238
+
239
+ for (const row of rows.parts) {
240
+ const data = parseJson(row.data);
241
+ if (data.type !== 'tool') continue;
242
+ const toolName = data.tool || 'unknown';
243
+ result.toolCalls[toolName] = (result.toolCalls[toolName] || 0) + 1;
244
+ const op = fileOpForTool(toolName);
245
+ if (op) result.fileOps[op] = (result.fileOps[op] || 0) + 1;
246
+ }
247
+ }
248
+
249
+ return result;
250
+ }
251
+
252
+ function printOpenCodeSession(ref, opts) {
253
+ const rows = loadOpenCodeRows(ref.id);
254
+ console.log(`id: ${ref.id}`);
255
+ console.log(`source: ${ref.source.harness} (${ref.source.format})`);
256
+ console.log(`path: ${ref.source.path}`);
257
+ console.log(`cwd: ${ref.cwd}`);
258
+ console.log(`started: ${ref.startedAt}`);
259
+ if (ref.endedAt) console.log(`ended: ${ref.endedAt}`);
260
+ console.log(`turns: ${rows.messages.length}`);
261
+ console.log('');
262
+
263
+ const events = openCodeEvents(rows);
264
+ for (const event of events) {
265
+ if (opts.events && !opts.events.has(event.kind)) continue;
266
+ const line = formatOpenCodeEvent(event);
267
+ if (opts.grep && !opts.grep.test(line)) continue;
268
+ console.log(line);
269
+ }
270
+ }
271
+
272
+ function loadOpenCodeRows(sessionId) {
273
+ const dbPath = defaultOpenCodeDbPath();
274
+ const id = sqlString(sessionId);
275
+ return {
276
+ messages: queryOpenCodeDb(dbPath, `select id, time_created, data from message where session_id = ${id} order by time_created, id`),
277
+ parts: queryOpenCodeDb(dbPath, `select id, message_id, time_created, data from part where session_id = ${id} order by time_created, id`),
278
+ };
279
+ }
280
+
281
+ function openCodeEvents(rows) {
282
+ const events = [];
283
+
284
+ for (const row of rows.messages) {
285
+ const data = parseJson(row.data);
286
+ const at = msToIso(row.time_created);
287
+ const model = data.modelID && data.providerID ? `${data.providerID}/${data.modelID}` : undefined;
288
+ const error = data.error?.data?.message || data.error?.message;
289
+ events.push({
290
+ kind: error ? 'error' : 'turn',
291
+ at,
292
+ text: error
293
+ ? `${data.role || 'assistant'} ${data.agent || ''} ${model || ''}: ${error}`
294
+ : `${data.role || 'unknown'} ${data.agent || ''} ${model || ''} finish=${data.finish || 'unknown'}`,
295
+ });
296
+ if (data.tokens) {
297
+ events.push({
298
+ kind: 'token_usage',
299
+ at,
300
+ text: `input=${data.tokens.input || 0} output=${data.tokens.output || 0} cache_read=${data.tokens.cache?.read || 0} cache_created=${data.tokens.cache?.write || 0}${model ? ` model=${model}` : ''}`,
301
+ });
302
+ }
303
+ }
304
+
305
+ for (const row of rows.parts) {
306
+ const data = parseJson(row.data);
307
+ const at = msToIso(row.time_created);
308
+ if (data.type === 'text') {
309
+ events.push({ kind: 'message', at, text: data.text || '' });
310
+ } else if (data.type === 'reasoning') {
311
+ events.push({ kind: 'reasoning', at, text: data.text || '' });
312
+ } else if (data.type === 'tool') {
313
+ const status = data.state?.status ? ` status=${data.state.status}` : '';
314
+ const input = data.state?.input ? ` ${JSON.stringify(data.state.input)}` : '';
315
+ const output = data.state?.output ? ` => ${data.state.output}` : '';
316
+ events.push({ kind: 'tool_call', at, text: `${data.tool || 'unknown'}${status}${input}${output}` });
317
+ const op = fileOpForTool(data.tool);
318
+ if (op) events.push({ kind: 'file_op', at, text: `${op} ${filePathFromTool(data) || ''}`.trim() });
319
+ } else if (data.__parseError) {
320
+ events.push({ kind: 'error', at, text: `unparseable part JSON: ${data.__parseError}` });
321
+ } else {
322
+ events.push({ kind: data.type || 'part', at, text: JSON.stringify(data) });
323
+ }
324
+ }
325
+
326
+ return events.sort((a, b) => a.at.localeCompare(b.at));
327
+ }
328
+
329
+ function formatOpenCodeEvent(event) {
330
+ return `${event.at} ${event.kind}: ${oneLine(event.text, 360)}`;
331
+ }
332
+
333
+ function parseJson(raw) {
334
+ try {
335
+ return JSON.parse(raw || '{}');
336
+ } catch (error) {
337
+ const message = error instanceof Error ? error.message : String(error);
338
+ return { __parseError: message, __raw: raw };
339
+ }
340
+ }
341
+
342
+ function fileOpForTool(toolName) {
343
+ if (toolName === 'read') return 'read';
344
+ if (toolName === 'write') return 'write';
345
+ if (toolName === 'edit') return 'edit';
346
+ if (toolName === 'glob') return 'list';
347
+ if (toolName === 'grep') return 'search';
348
+ return undefined;
349
+ }
350
+
351
+ function filePathFromTool(part) {
352
+ const input = part.state?.input;
353
+ if (!input || typeof input !== 'object') return undefined;
354
+ return input.filePath || input.path || input.pattern;
355
+ }
356
+
357
+ function oneLine(value, max) {
358
+ return shorten(String(value ?? '').replace(/\s+/g, ' ').trim(), max);
359
+ }
360
+
361
+ function resolveIsoTraceCli() {
362
+ const pkgJsonPath = require.resolve('@razroo/iso-trace/package.json');
363
+ return join(dirname(pkgJsonPath), 'dist/cli.js');
364
+ }
365
+
366
+ function passthroughIsoTrace(rawArgs) {
367
+ const cliPath = resolveIsoTraceCli();
368
+ const result = spawnSync(process.execPath, [cliPath, ...rawArgs], {
369
+ stdio: 'inherit',
370
+ cwd: PROJECT_DIR,
371
+ env: process.env,
372
+ });
373
+ return result.status ?? 1;
374
+ }
375
+
376
+ async function main() {
377
+ if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
378
+ console.log(USAGE);
379
+ return 0;
380
+ }
381
+
382
+ if (cmd === 'list') {
383
+ const { opts } = parseFilters(args);
384
+ if (opts.help) {
385
+ console.log(USAGE);
386
+ return 0;
387
+ }
388
+ if (opts.error) {
389
+ console.error(`job-forge trace:list: ${opts.error}`);
390
+ return 2;
391
+ }
392
+ const refs = await discoverOpenCodeRefs(opts);
393
+ if (opts.json) {
394
+ console.log(JSON.stringify(refs, null, 2));
395
+ return 0;
396
+ }
397
+ if (refs.length === 0) {
398
+ console.error('job-forge trace:list: no OpenCode sessions found for this project');
399
+ return 2;
400
+ }
401
+ printSessionTable(refs);
402
+ return 0;
403
+ }
404
+
405
+ if (cmd === 'stats') {
406
+ const { opts, positional } = parseFilters(args);
407
+ if (opts.help) {
408
+ console.log(USAGE);
409
+ return 0;
410
+ }
411
+ if (opts.error) {
412
+ console.error(`job-forge trace:stats: ${opts.error}`);
413
+ return 2;
414
+ }
415
+ const refs = await discoverOpenCodeRefs(opts);
416
+ const selected = positional.length === 0
417
+ ? refs
418
+ : positional.map((id) => {
419
+ const ref = findSessionById(refs, id);
420
+ if (!ref) throw new Error(`job-forge trace:stats: no OpenCode session matches "${id}"`);
421
+ return ref;
422
+ });
423
+ const result = computeOpenCodeStats(selected);
424
+ if (opts.json) {
425
+ console.log(JSON.stringify(result, null, 2));
426
+ } else {
427
+ printStats(result);
428
+ }
429
+ return 0;
430
+ }
431
+
432
+ if (cmd === 'show') {
433
+ const { opts, positional } = parseShowArgs(args);
434
+ if (opts.help) {
435
+ console.log(USAGE);
436
+ return 0;
437
+ }
438
+ if (opts.error) {
439
+ console.error(`job-forge trace:show: ${opts.error}`);
440
+ return 2;
441
+ }
442
+ if (opts.grep instanceof Error) {
443
+ console.error(opts.grep.message);
444
+ return 2;
445
+ }
446
+ if (positional.length === 0) {
447
+ console.error('job-forge trace:show: missing <id-or-prefix>');
448
+ return 2;
449
+ }
450
+ const refs = await discoverOpenCodeRefs({ cwd: PROJECT_DIR, since: undefined });
451
+ const ref = findSessionById(refs, positional[0]);
452
+ if (!ref) {
453
+ console.error(`job-forge trace:show: no OpenCode session matches "${positional[0]}"`);
454
+ return 2;
455
+ }
456
+ printOpenCodeSession(ref, opts);
457
+ return 0;
458
+ }
459
+
460
+ return passthroughIsoTrace([cmd, ...args]);
461
+ }
462
+
463
+ main()
464
+ .then((code) => process.exit(code))
465
+ .catch((error) => {
466
+ const message = error instanceof Error ? error.message : String(error);
467
+ console.error(message);
468
+ process.exit(1);
469
+ });