@pulsehub/runner 0.1.11 → 0.1.12
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/README.md +19 -0
- package/package.json +1 -1
- package/src/diagnostics.js +73 -0
- package/src/process.js +45 -4
- package/src/runtime.js +1 -1
- package/test/diagnostics.test.js +55 -0
- package/test/integration.test.js +9 -0
package/README.md
CHANGED
|
@@ -105,3 +105,22 @@ Los ZIPs tienen un límite de 30 MiB comprimidos/256 MiB expandidos por repo;
|
|
|
105
105
|
enlaces, archivos especiales y traversal se rechazan con diagnóstico visible.
|
|
106
106
|
La limpieza y recuperación eliminan también snapshots interrumpidos de sólo
|
|
107
107
|
lectura. Este protocolo requiere desplegar la versión compatible del backend.
|
|
108
|
+
|
|
109
|
+
### Diagnóstico de QA Codex
|
|
110
|
+
|
|
111
|
+
QA Codex usa `exec --json`. `runner.jsonl` registra la versión del CLI, los
|
|
112
|
+
permisos efectivos, la presencia de la credencial (boolean), el número de
|
|
113
|
+
snapshots y el ciclo de llamadas MCP a Pulse, correlacionados por `jobId`.
|
|
114
|
+
Los eventos `codex.mcp.call` se originan en eventos del CLI, no en menciones
|
|
115
|
+
al nombre de una herramienta dentro del texto del modelo. `hasResult` indica
|
|
116
|
+
que llegó un resultado; no demuestra que la revisión se haya reclamado ni
|
|
117
|
+
que el veredicto haya sido aceptado. Eso se verifica en el estado de Pulse.
|
|
118
|
+
|
|
119
|
+
`codex.diagnostics.summary` cuenta llamadas iniciadas/completadas/fallidas,
|
|
120
|
+
líneas malformadas y truncamiento de eventos. Cero llamadas observadas permite
|
|
121
|
+
distinguir un proceso que termina sin usar MCP. Los mensajes de startup conocidos
|
|
122
|
+
se convierten a estados fijos; stderr restante se cuenta en bytes. Argumentos,
|
|
123
|
+
resultados, errores libres, prompts y transcripts nunca se guardan ni se envían
|
|
124
|
+
en el reporte QA. Los nombres de herramientas desconocidas se registran como
|
|
125
|
+
`other`. Las líneas se limitan a 256 KiB y se emiten como máximo 500 eventos
|
|
126
|
+
más el resumen. Un fallo al escribir el diagnóstico no interrumpe el job.
|
package/package.json
CHANGED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Provider output is untrusted and can contain credentials. Only emit constants,
|
|
2
|
+
// counts and explicitly approved Pulse tool names; never forward payload fields.
|
|
3
|
+
const tools = new Set(['pulse_next_review', 'pulse_get_review_context', 'pulse_submit_review', 'pulse_report_review_incomplete', 'pulse_whoami', 'pulse_get_issue', 'pulse_get_project', 'pulse_list_comments']);
|
|
4
|
+
export function codexDiagnostic(value) {
|
|
5
|
+
if (!value || typeof value !== 'object') return null;
|
|
6
|
+
if (['thread.started', 'turn.started', 'turn.completed', 'turn.failed', 'error'].includes(value.type)) {
|
|
7
|
+
return { event: `codex.${value.type}` };
|
|
8
|
+
}
|
|
9
|
+
if (!['item.started', 'item.updated', 'item.completed'].includes(value.type)) return null;
|
|
10
|
+
const item = value.item;
|
|
11
|
+
if (item?.type !== 'mcp_tool_call' || item.server !== 'pulse') return null;
|
|
12
|
+
if (!['in_progress', 'completed', 'failed'].includes(item.status)) return null;
|
|
13
|
+
return { event: 'codex.mcp.call', lifecycle: value.type, server: 'pulse', tool: tools.has(item.tool) ? item.tool : 'other', status: item.status, hasResult: item.result != null, hasError: item.error != null };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function codexDiagnostics(emit) {
|
|
17
|
+
let pending = '';
|
|
18
|
+
let dropping = false;
|
|
19
|
+
let lines = 0;
|
|
20
|
+
let malformed = 0;
|
|
21
|
+
let observed = 0;
|
|
22
|
+
let mcpStarted = 0;
|
|
23
|
+
let mcpCompleted = 0;
|
|
24
|
+
let mcpFailed = 0;
|
|
25
|
+
let emitted = 0;
|
|
26
|
+
const line = (raw) => {
|
|
27
|
+
lines++;
|
|
28
|
+
let value;
|
|
29
|
+
try { value = JSON.parse(raw); } catch { malformed++; return; }
|
|
30
|
+
const safe = codexDiagnostic(value);
|
|
31
|
+
if (!safe) return;
|
|
32
|
+
observed++;
|
|
33
|
+
if (safe.event === 'codex.mcp.call') {
|
|
34
|
+
if (safe.lifecycle === 'item.started') mcpStarted++;
|
|
35
|
+
if (safe.lifecycle === 'item.completed') {
|
|
36
|
+
if (safe.status === 'completed') mcpCompleted++;
|
|
37
|
+
else if (safe.status === 'failed') mcpFailed++;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (emitted++ < 500) emit(safe);
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
// Split by newline with a bounded buffer, even for huge or malformed lines.
|
|
44
|
+
push(chunk) {
|
|
45
|
+
for (const part of String(chunk).split(/(?<=\n)/)) {
|
|
46
|
+
const end = part.endsWith('\n');
|
|
47
|
+
if (!dropping) {
|
|
48
|
+
if (pending.length + part.length > 256 * 1024) { pending = ''; dropping = true; }
|
|
49
|
+
else pending += part;
|
|
50
|
+
}
|
|
51
|
+
if (end) {
|
|
52
|
+
if (dropping) { lines++; malformed++; } else line(pending);
|
|
53
|
+
pending = ''; dropping = false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
finish() {
|
|
58
|
+
if (dropping) { lines++; malformed++; } else if (pending) line(pending);
|
|
59
|
+
pending = ''; dropping = false;
|
|
60
|
+
const summary = { event: 'codex.diagnostics.summary', lines, malformed, observed, mcpStarted, mcpCompleted, mcpFailed, truncated: emitted > 500 };
|
|
61
|
+
emit(summary);
|
|
62
|
+
return summary;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Classify startup stderr using constants only, without retaining messages.
|
|
68
|
+
export function codexStartupDiagnostic(line) {
|
|
69
|
+
if (/^mcp: pulse ready\s*$/.test(line.trim())) return { event: 'codex.mcp.startup', server: 'pulse', status: 'ready' };
|
|
70
|
+
if (/^mcp: pulse failed:/.test(line.trim())) return { event: 'codex.mcp.startup', server: 'pulse', status: 'failed' };
|
|
71
|
+
if (/^error: unexpected argument /.test(line.trim())) return { event: 'codex.startup.error', category: 'cli_arguments' };
|
|
72
|
+
return null;
|
|
73
|
+
}
|
package/src/process.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { codexDiagnostics, codexStartupDiagnostic } from './diagnostics.js';
|
|
5
6
|
|
|
6
7
|
export function command(executable, args, options = {}) {
|
|
7
8
|
return new Promise((resolve, reject) => {
|
|
@@ -29,8 +30,10 @@ export function command(executable, args, options = {}) {
|
|
|
29
30
|
options.signal?.addEventListener('abort', abort, { once: true });
|
|
30
31
|
const timer = options.timeout ? setTimeout(abort, options.timeout) : null;
|
|
31
32
|
let output = '';
|
|
32
|
-
const append = (chunk) => { output = (output + chunk).slice(-(options.maxOutput || 1_000_000)); };
|
|
33
|
-
child.stdout.
|
|
33
|
+
const append = (chunk) => { if (options.captureOutput !== false) output = (output + chunk).slice(-(options.maxOutput || 1_000_000)); };
|
|
34
|
+
child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8');
|
|
35
|
+
child.stdout.on('data', (chunk) => { append(chunk); options.onStdout?.(chunk); });
|
|
36
|
+
child.stderr.on('data', (chunk) => { append(chunk); options.onStderr?.(chunk); });
|
|
34
37
|
const cleanup = () => {
|
|
35
38
|
if (timer) clearTimeout(timer);
|
|
36
39
|
// Kill any descendants before releasing the process group on cancellation.
|
|
@@ -85,7 +88,7 @@ async function mcpConfig(dir, credential, endpoint) {
|
|
|
85
88
|
export function providerInvocation(kind, worktree, additionalDirs, config, mcp, prompt, mode = 'task') {
|
|
86
89
|
if (kind === 'codex') {
|
|
87
90
|
const reviewArgs = mode === 'review'
|
|
88
|
-
? ['--skip-git-repo-check', '-c', 'mcp_servers.pulse.default_tools_approval_mode="approve"']
|
|
91
|
+
? ['--json', '--skip-git-repo-check', '-c', 'mcp_servers.pulse.default_tools_approval_mode="approve"']
|
|
89
92
|
: [];
|
|
90
93
|
return {
|
|
91
94
|
executable: resolveExecutable('codex'),
|
|
@@ -117,6 +120,44 @@ export async function runAgent(kind, job, worktree, additionalWorktrees, config,
|
|
|
117
120
|
const invocation = providerInvocation(kind, agentDirectory, additionalDirs, config, mcp, prompt, job.mode);
|
|
118
121
|
if (options.profile?.model) invocation.args.splice(kind === 'codex' ? 1 : 0, 0, '--model', options.profile.model);
|
|
119
122
|
const env = kind === 'codex' ? { ...process.env, PULSE_RUNNER_MCP_CREDENTIAL: `Bearer ${credential}` } : process.env;
|
|
120
|
-
|
|
123
|
+
const diagnosticMode = kind === 'codex' && job.mode === 'review';
|
|
124
|
+
let writes = Promise.resolve();
|
|
125
|
+
let stderrBytes = 0;
|
|
126
|
+
let stderrLine = '';
|
|
127
|
+
const stderrEvents = new Set();
|
|
128
|
+
const emit = (details) => {
|
|
129
|
+
// Keep order and drain writes before completion; sink failures are nonfatal.
|
|
130
|
+
writes = writes.then(() => options.onDiagnostic?.(details)).catch(() => {});
|
|
131
|
+
};
|
|
132
|
+
const diagnostics = diagnosticMode ? codexDiagnostics(emit) : null;
|
|
133
|
+
if (diagnosticMode) {
|
|
134
|
+
const version = await command(invocation.executable, ['--version'], { signal: options.signal, timeout: 10_000, maxOutput: 1000 }).catch(() => ({ output: '' }));
|
|
135
|
+
const cliVersion = /^codex-cli (\d+\.\d+\.\d+)\s*$/.exec(version.output.trim())?.[1] || 'unknown';
|
|
136
|
+
emit({ event: 'codex.startup', cliVersion, sandbox: 'read-only', ephemeral: true, json: true, mcpApproval: 'approve', credentialPresent: Boolean(credential), snapshotCount: reviewWorktrees.length });
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const result = await command(invocation.executable, invocation.args, { cwd: agentDirectory, env, signal: options.signal, captureOutput: !diagnosticMode, onStdout: diagnostics ? (chunk) => diagnostics.push(chunk) : undefined, onStderr: diagnosticMode ? (chunk) => {
|
|
140
|
+
stderrBytes += Buffer.byteLength(chunk);
|
|
141
|
+
for (const part of String(chunk).split(/(?<=\n)/)) {
|
|
142
|
+
stderrLine = (stderrLine + part).slice(-4096);
|
|
143
|
+
if (!part.endsWith('\n')) continue;
|
|
144
|
+
const safe = codexStartupDiagnostic(stderrLine);
|
|
145
|
+
stderrLine = '';
|
|
146
|
+
if (safe && !stderrEvents.has(JSON.stringify(safe))) { stderrEvents.add(JSON.stringify(safe)); emit(safe); }
|
|
147
|
+
}
|
|
148
|
+
} : undefined });
|
|
149
|
+
if (diagnostics) {
|
|
150
|
+
emit({ event: 'codex.process.exited', exitCode: result.code, stderrBytes });
|
|
151
|
+
const summary = diagnostics.finish();
|
|
152
|
+
result.output = `Codex QA: events=${summary.observed}, MCP started=${summary.mcpStarted}, completed=${summary.mcpCompleted}, failed=${summary.mcpFailed}, malformed=${summary.malformed}.`;
|
|
153
|
+
}
|
|
154
|
+
return result;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
// Do not forward a spawn error that can include the executable's path.
|
|
157
|
+
if (!diagnosticMode) throw error;
|
|
158
|
+
diagnostics.finish();
|
|
159
|
+
emit({ event: 'codex.spawn.failed' });
|
|
160
|
+
throw new Error('Codex QA process could not start.');
|
|
161
|
+
} finally { await writes; }
|
|
121
162
|
} finally { await rm(mcp, { force: true }); }
|
|
122
163
|
}
|
package/src/runtime.js
CHANGED
|
@@ -235,7 +235,7 @@ export async function runOnce(config, options = {}) {
|
|
|
235
235
|
controller.signal.throwIfAborted();
|
|
236
236
|
jobPhase = 'run-agent';
|
|
237
237
|
await logEvent('agent.started', { jobId: job.id, repo: primary.repo, mode: job.mode });
|
|
238
|
-
const result = await runAgent(agent.kind, job, primary.worktree, checkouts.filter((entry) => entry !== primary), config, delivery.mcpCredential, temp, { signal: controller.signal, profile });
|
|
238
|
+
const result = await runAgent(agent.kind, job, primary.worktree, checkouts.filter((entry) => entry !== primary), config, delivery.mcpCredential, temp, { signal: controller.signal, profile, onDiagnostic: ({ event, ...details }) => logEvent(event, { jobId: job.id, ...details }) });
|
|
239
239
|
controller.signal.throwIfAborted();
|
|
240
240
|
await checkActive();
|
|
241
241
|
await logEvent(result.code === 0 ? 'agent.completed' : 'agent.failed', { jobId: job.id, exitCode: result.code, ...(result.code === 0 ? {} : { error: failureDetail(result.output) }) });
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { codexDiagnostic, codexDiagnostics, codexStartupDiagnostic } from '../src/diagnostics.js';
|
|
4
|
+
import { command, runAgent } from '../src/process.js';
|
|
5
|
+
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
const secret = 'opaque-session-secret';
|
|
9
|
+
const item = { id: secret, type: 'mcp_tool_call', server: 'pulse', tool: 'pulse_next_review', status: 'completed', arguments: { token: secret }, result: { content: [{ text: secret }] }, error: { message: secret } };
|
|
10
|
+
|
|
11
|
+
test('only allow-listed metadata survives untrusted MCP payloads', () => {
|
|
12
|
+
assert.deepEqual(codexDiagnostic({ type: 'item.completed', item }), { event: 'codex.mcp.call', lifecycle: 'item.completed', server: 'pulse', tool: 'pulse_next_review', status: 'completed', hasResult: true, hasError: true });
|
|
13
|
+
for (const changes of [{ server: secret }, { status: secret }, { type: 'agent_message', text: secret }]) assert.equal(codexDiagnostic({ type: 'item.completed', item: { ...item, ...changes } }), null);
|
|
14
|
+
assert.equal(codexDiagnostic({ type: 'item.completed', item: { ...item, tool: secret } }).tool, 'other');
|
|
15
|
+
assert.deepEqual(codexDiagnostic({ type: 'turn.failed', error: { message: secret } }), { event: 'codex.turn.failed' });
|
|
16
|
+
});
|
|
17
|
+
test('split, malformed, oversized and final lines remain bounded and safe', () => {
|
|
18
|
+
const events = [], stream = codexDiagnostics(e => events.push(e));
|
|
19
|
+
const started = JSON.stringify({ type: 'item.started', item: { ...item, status: 'in_progress' } });
|
|
20
|
+
stream.push(started.slice(0,35)); stream.push(started.slice(35)+'\n');
|
|
21
|
+
stream.push(secret+'\n'); stream.push('x'.repeat(300_000)); stream.push('\n');
|
|
22
|
+
stream.push(JSON.stringify({ type: 'item.completed', item }));
|
|
23
|
+
const summary = stream.finish();
|
|
24
|
+
assert.equal(summary.mcpStarted,1); assert.equal(summary.mcpCompleted,1); assert.equal(summary.malformed,2);
|
|
25
|
+
assert(!JSON.stringify(events).includes(secret));
|
|
26
|
+
});
|
|
27
|
+
test('zero MCP calls and bounded emission are explicit', () => {
|
|
28
|
+
const events = [], stream = codexDiagnostics(e => events.push(e));
|
|
29
|
+
for (let i=0;i<600;i++) stream.push('{"type":"turn.started"}\n');
|
|
30
|
+
assert.equal(stream.finish().mcpStarted,0); assert.equal(events.length,501); assert.equal(events.at(-1).truncated,true);
|
|
31
|
+
});
|
|
32
|
+
test('QA child JSONL is observed and secrets in stdout/stderr are discarded on failure', async () => {
|
|
33
|
+
const root = await mkdtemp(join(tmpdir(),'pulse-diagnostics-')), previous = process.env.PULSE_RUNNER_CODEX_BIN;
|
|
34
|
+
try {
|
|
35
|
+
const fake = join(root,'codex.mjs');
|
|
36
|
+
await writeFile(fake, `if (process.argv.includes('--version')) console.log('codex-cli 0.159.2'); else { console.log(${JSON.stringify(JSON.stringify({ type:'item.started', item:{...item,status:'in_progress'} }))}); console.log(${JSON.stringify(JSON.stringify({ type:'item.completed',item:{...item,status:'failed'} }))}); console.error(${JSON.stringify(secret)}); process.exitCode=1; }`);
|
|
37
|
+
process.env.PULSE_RUNNER_CODEX_BIN=fake;
|
|
38
|
+
const events=[];
|
|
39
|
+
const result=await runAgent('codex',{mode:'review',issueId:'issue-fixture',repoFullName:'owner/repo'},root,[],{endpoint:'https://example.invalid'},secret,join(root,'temp'),{onDiagnostic:async e=>events.push(e)});
|
|
40
|
+
assert.equal(result.code,1); assert.equal(events[0].cliVersion,'0.159.2'); assert.equal(events.at(-1).mcpFailed,1);
|
|
41
|
+
assert(!JSON.stringify({result,events}).includes(secret));
|
|
42
|
+
assert.equal(events.find(e=>e.event==='codex.process.exited').stderrBytes,Buffer.byteLength(secret+'\n'));
|
|
43
|
+
assert.equal((await command(fake,[],{captureOutput:false})).output,'');
|
|
44
|
+
} finally {
|
|
45
|
+
if(previous===undefined) delete process.env.PULSE_RUNNER_CODEX_BIN; else process.env.PULSE_RUNNER_CODEX_BIN=previous;
|
|
46
|
+
await rm(root,{recursive:true,force:true});
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('startup stderr recognizes only fixed Pulse states and CLI incompatibility', () => {
|
|
51
|
+
assert.deepEqual(codexStartupDiagnostic('mcp: pulse ready'), {event:'codex.mcp.startup', server:'pulse', status:'ready'});
|
|
52
|
+
assert.deepEqual(codexStartupDiagnostic('mcp: pulse failed: '+secret), {event:'codex.mcp.startup', server:'pulse', status:'failed'});
|
|
53
|
+
assert.deepEqual(codexStartupDiagnostic('error: unexpected argument '+secret), {event:'codex.startup.error', category:'cli_arguments'});
|
|
54
|
+
assert.equal(codexStartupDiagnostic(secret),null);
|
|
55
|
+
});
|
package/test/integration.test.js
CHANGED
|
@@ -255,6 +255,15 @@ for (const kind of ['codex','claude']) test(`integration: ${kind} QA reads all p
|
|
|
255
255
|
assert.equal(f.calls.filter(c=>c.path==='/pulseQaSource' && c.body.repo).length,3);
|
|
256
256
|
assert.equal(published,false);
|
|
257
257
|
assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'completed');
|
|
258
|
+
if (kind === 'codex') {
|
|
259
|
+
const raw = await readFile(join(paths().logs, 'runner.jsonl'), 'utf8');
|
|
260
|
+
const events = raw.trim().split('\n').map(JSON.parse);
|
|
261
|
+
assert.equal(events.find(e => e.event === 'codex.startup').snapshotCount, 3);
|
|
262
|
+
assert.equal(events.find(e => e.event === 'codex.diagnostics.summary').jobId, f.job.id);
|
|
263
|
+
assert.equal(events.find(e => e.event === 'codex.diagnostics.summary').mcpStarted, 0);
|
|
264
|
+
assert(!raw.includes('fixture-secret'));
|
|
265
|
+
assert(!JSON.stringify(f.calls.find(c=>c.path==='/pulseRunnerComplete').body).includes('fixture-secret'));
|
|
266
|
+
}
|
|
258
267
|
}));
|
|
259
268
|
|
|
260
269
|
test('integration: signed multi-repo worktrees are passed to the adapter and all cleaned',()=>fixture('codex',async f=>{
|