job-forge 2.14.36 → 2.14.38

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/scripts/trace.mjs CHANGED
@@ -1,36 +1,37 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawnSync } from 'child_process';
4
- import { existsSync } from 'fs';
5
4
  import { createRequire } from 'module';
6
5
  import { dirname, join, resolve } from 'path';
7
- import { fileURLToPath } from 'url';
8
6
  import {
9
- defaultOpenCodeDbPath,
10
- findSessionById,
11
- openCodeSessionLocator,
12
- parseSinceCutoff,
13
- } from '@razroo/iso-trace';
7
+ clean,
8
+ discoverProjectSessions,
9
+ findObservedSession,
10
+ loadObservedSession,
11
+ pad,
12
+ safeJson,
13
+ shorten,
14
+ statsForSessions,
15
+ } from '../lib/jobforge-observability.mjs';
14
16
 
15
- const __dirname = dirname(fileURLToPath(import.meta.url));
16
17
  const require = createRequire(import.meta.url);
17
18
  const PROJECT_DIR = process.env.JOB_FORGE_PROJECT || process.cwd();
18
19
 
19
- const USAGE = `job-forge trace — local OpenCode transcript observability
20
+ const USAGE = `job-forge trace — local transcript observability across supported harnesses
20
21
 
21
22
  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>]
23
+ job-forge trace:list [--since 7d] [--cwd <dir>] [--harness <name>] [--json]
24
+ job-forge trace:stats [<id-or-prefix>...] [--since 7d] [--cwd <dir>] [--harness <name>] [--json]
25
+ job-forge trace:show <id-or-prefix> [--cwd <dir>] [--harness <name>] [--events <kinds>] [--grep <regex>]
25
26
  job-forge trace <iso-trace args...>
26
27
 
27
- Common aliases default to OpenCode sessions for the current JobForge project.
28
+ Common aliases default to sessions for the current JobForge project.
28
29
  Use "job-forge trace sources" or "job-forge trace where" for raw iso-trace passthrough.`;
29
30
 
30
31
  const [cmd = 'help', ...args] = process.argv.slice(2);
31
32
 
32
33
  function parseFilters(rawArgs) {
33
- const opts = { since: '7d', cwd: PROJECT_DIR, json: false };
34
+ const opts = { since: '7d', cwd: PROJECT_DIR, harness: '', json: false };
34
35
  const positional = [];
35
36
 
36
37
  for (let i = 0; i < rawArgs.length; i++) {
@@ -43,6 +44,10 @@ function parseFilters(rawArgs) {
43
44
  opts.cwd = rawArgs[++i];
44
45
  } else if (arg.startsWith('--cwd=')) {
45
46
  opts.cwd = arg.slice('--cwd='.length);
47
+ } else if (arg === '--harness') {
48
+ opts.harness = rawArgs[++i];
49
+ } else if (arg.startsWith('--harness=')) {
50
+ opts.harness = arg.slice('--harness='.length);
46
51
  } else if (arg === '--json') {
47
52
  opts.json = true;
48
53
  } else if (arg === '--help' || arg === '-h') {
@@ -59,12 +64,20 @@ function parseFilters(rawArgs) {
59
64
  }
60
65
 
61
66
  function parseShowArgs(rawArgs) {
62
- const opts = {};
67
+ const opts = { cwd: PROJECT_DIR, harness: '' };
63
68
  const positional = [];
64
69
 
65
70
  for (let i = 0; i < rawArgs.length; i++) {
66
71
  const arg = rawArgs[i];
67
- if (arg === '--events') {
72
+ if (arg === '--cwd') {
73
+ opts.cwd = rawArgs[++i];
74
+ } else if (arg.startsWith('--cwd=')) {
75
+ opts.cwd = arg.slice('--cwd='.length);
76
+ } else if (arg === '--harness') {
77
+ opts.harness = rawArgs[++i];
78
+ } else if (arg.startsWith('--harness=')) {
79
+ opts.harness = arg.slice('--harness='.length);
80
+ } else if (arg === '--events') {
68
81
  const raw = rawArgs[++i] || '';
69
82
  opts.events = new Set(raw.split(',').map((s) => s.trim()).filter(Boolean));
70
83
  } else if (arg.startsWith('--events=')) {
@@ -83,6 +96,7 @@ function parseShowArgs(rawArgs) {
83
96
  }
84
97
  }
85
98
 
99
+ opts.cwd = resolve(opts.cwd || PROJECT_DIR);
86
100
  return { opts, positional };
87
101
  }
88
102
 
@@ -95,96 +109,23 @@ function compileRegex(pattern, context) {
95
109
  }
96
110
  }
97
111
 
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
112
  function sizeLabel(bytes) {
163
113
  if (bytes < 1024) return `${bytes} B`;
164
114
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
165
115
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
166
116
  }
167
117
 
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
118
  function printSessionTable(refs) {
180
119
  const rows = refs.map((ref) => [
181
120
  ref.id,
121
+ ref.source.harness,
182
122
  ref.startedAt.replace('T', ' ').replace(/\.\d+Z$/, 'Z'),
183
- shorten(ref.cwd, 42),
123
+ shorten(ref.title || '', 24),
124
+ shorten(ref.cwd, 40),
184
125
  String(ref.turnCount),
185
126
  sizeLabel(ref.sizeBytes),
186
127
  ]);
187
- const header = ['id', 'started', 'cwd', 'turns', 'size'];
128
+ const header = ['id', 'harness', 'started', 'title', 'cwd', 'turns', 'size'];
188
129
  const widths = header.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i].length)));
189
130
 
190
131
  console.log(header.map((h, i) => pad(h, widths[i])).join(' '));
@@ -211,151 +152,51 @@ function printStats(result) {
211
152
  }
212
153
  }
213
154
 
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);
155
+ function printSession(ref, session, opts) {
254
156
  console.log(`id: ${ref.id}`);
255
- console.log(`source: ${ref.source.harness} (${ref.source.format})`);
157
+ console.log(`harness: ${ref.source.harness}`);
158
+ console.log(`source: ${ref.source.format}`);
256
159
  console.log(`path: ${ref.source.path}`);
257
160
  console.log(`cwd: ${ref.cwd}`);
161
+ if (ref.title) console.log(`title: ${ref.title}`);
162
+ if (session.model) console.log(`model: ${session.model}`);
258
163
  console.log(`started: ${ref.startedAt}`);
259
164
  if (ref.endedAt) console.log(`ended: ${ref.endedAt}`);
260
- console.log(`turns: ${rows.messages.length}`);
165
+ console.log(`turns: ${session.turns.length}`);
261
166
  console.log('');
262
167
 
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);
168
+ for (const turn of session.turns) {
169
+ for (const event of turn.events) {
170
+ if (opts.events && !opts.events.has(event.kind)) continue;
171
+ const line = `${turn.at} ${event.kind}: ${formatEvent(event)}`;
172
+ if (opts.grep && !opts.grep.test(line)) continue;
173
+ console.log(line);
174
+ }
269
175
  }
270
176
  }
271
177
 
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
- }
178
+ function formatEvent(event) {
179
+ if (event.kind === 'message') {
180
+ return `${event.role}: ${oneLine(event.text, 360)}`;
303
181
  }
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
- }
182
+ if (event.kind === 'tool_call') {
183
+ return `${event.name || 'unknown'} ${oneLine(safeJson(event.input), 360)}`;
324
184
  }
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 };
185
+ if (event.kind === 'tool_result') {
186
+ const suffix = event.error ? ` error=${oneLine(event.error, 160)}` : '';
187
+ return `${event.toolUseId || '(unknown)'}${suffix}${event.output ? ` => ${oneLine(event.output, 240)}` : ''}`;
339
188
  }
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;
189
+ if (event.kind === 'file_op') {
190
+ return `${event.op} ${event.path} (${event.tool})`;
191
+ }
192
+ if (event.kind === 'token_usage') {
193
+ return `input=${event.input} output=${event.output} cache_read=${event.cacheRead} cache_created=${event.cacheCreated}${event.model ? ` model=${event.model}` : ''}`;
194
+ }
195
+ return oneLine(safeJson(event), 360);
355
196
  }
356
197
 
357
198
  function oneLine(value, max) {
358
- return shorten(String(value ?? '').replace(/\s+/g, ' ').trim(), max);
199
+ return shorten(clean(value), max);
359
200
  }
360
201
 
361
202
  function resolveIsoTraceCli() {
@@ -373,6 +214,18 @@ function passthroughIsoTrace(rawArgs) {
373
214
  return result.status ?? 1;
374
215
  }
375
216
 
217
+ function tryLoadSession(ref) {
218
+ try {
219
+ return { ref, session: loadObservedSession(ref), error: null };
220
+ } catch (error) {
221
+ return {
222
+ ref,
223
+ session: null,
224
+ error: error instanceof Error ? error.message : String(error),
225
+ };
226
+ }
227
+ }
228
+
376
229
  async function main() {
377
230
  if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
378
231
  console.log(USAGE);
@@ -389,13 +242,13 @@ async function main() {
389
242
  console.error(`job-forge trace:list: ${opts.error}`);
390
243
  return 2;
391
244
  }
392
- const refs = await discoverOpenCodeRefs(opts);
245
+ const refs = await discoverProjectSessions(opts);
393
246
  if (opts.json) {
394
247
  console.log(JSON.stringify(refs, null, 2));
395
248
  return 0;
396
249
  }
397
250
  if (refs.length === 0) {
398
- console.error('job-forge trace:list: no OpenCode sessions found for this project');
251
+ console.error('job-forge trace:list: no sessions found for this project');
399
252
  return 2;
400
253
  }
401
254
  printSessionTable(refs);
@@ -412,18 +265,30 @@ async function main() {
412
265
  console.error(`job-forge trace:stats: ${opts.error}`);
413
266
  return 2;
414
267
  }
415
- const refs = await discoverOpenCodeRefs(opts);
268
+ const refs = await discoverProjectSessions(opts);
416
269
  const selected = positional.length === 0
417
270
  ? refs
418
271
  : positional.map((id) => {
419
- const ref = findSessionById(refs, id);
420
- if (!ref) throw new Error(`job-forge trace:stats: no OpenCode session matches "${id}"`);
272
+ const ref = findObservedSession(refs, id);
273
+ if (!ref) throw new Error(`job-forge trace:stats: no session matches "${id}"`);
421
274
  return ref;
422
275
  });
423
- const result = computeOpenCodeStats(selected);
276
+ const loaded = selected.map(tryLoadSession);
277
+ const failures = loaded.filter((item) => item.error);
278
+ if (positional.length > 0 && failures.length > 0) {
279
+ throw new Error(`job-forge trace:stats: could not load session "${failures[0].ref.id}": ${failures[0].error}`);
280
+ }
281
+ const sessions = loaded.filter((item) => item.session).map((item) => item.session);
282
+ if (sessions.length === 0) {
283
+ throw new Error('job-forge trace:stats: no readable sessions found for this selection');
284
+ }
285
+ const result = statsForSessions(sessions);
424
286
  if (opts.json) {
425
287
  console.log(JSON.stringify(result, null, 2));
426
288
  } else {
289
+ if (failures.length > 0) {
290
+ console.error(`job-forge trace:stats: skipped ${failures.length} unreadable session(s)`);
291
+ }
427
292
  printStats(result);
428
293
  }
429
294
  return 0;
@@ -447,13 +312,19 @@ async function main() {
447
312
  console.error('job-forge trace:show: missing <id-or-prefix>');
448
313
  return 2;
449
314
  }
450
- const refs = await discoverOpenCodeRefs({ cwd: PROJECT_DIR, since: undefined });
451
- const ref = findSessionById(refs, positional[0]);
315
+ const refs = await discoverProjectSessions({ cwd: opts.cwd, harness: opts.harness, since: undefined });
316
+ const ref = findObservedSession(refs, positional[0]);
452
317
  if (!ref) {
453
- console.error(`job-forge trace:show: no OpenCode session matches "${positional[0]}"`);
318
+ console.error(`job-forge trace:show: no session matches "${positional[0]}"`);
319
+ return 2;
320
+ }
321
+ const loaded = tryLoadSession(ref);
322
+ if (!loaded.session) {
323
+ console.error(`job-forge trace:show: could not load session "${ref.id}": ${loaded.error}`);
454
324
  return 2;
455
325
  }
456
- printOpenCodeSession(ref, opts);
326
+ const session = loaded.session;
327
+ printSession(ref, session, opts);
457
328
  return 0;
458
329
  }
459
330