mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
refactor: extract Codex event interpretation (#1224)
* refactor: extract Codex event interpreter * fix: reuse Codex event interpretation * test: preserve dispatch coverage boundary * test: stabilize extracted dispatch coverage * test: await provider turn persistence * test: align web mocks with API client
This commit is contained in:
parent
2fa9b2ea89
commit
25be48454b
10 changed files with 405 additions and 348 deletions
|
|
@ -26,6 +26,7 @@
|
|||
"src/__tests__/codex-app-server-provider.smoke.test.ts",
|
||||
"src/__tests__/codex-app-server-provider.test.ts",
|
||||
"src/__tests__/codex-env.test.ts",
|
||||
"src/__tests__/codex-event-interpreter.test.ts",
|
||||
"src/__tests__/codex-provider-service.test.ts",
|
||||
"src/__tests__/ceremony-state-repository.test.ts",
|
||||
"src/__tests__/communication-adapter-schemas.test.ts",
|
||||
|
|
@ -113,7 +114,9 @@
|
|||
"description": "Provider dispatch, persisted runtime evidence, workflow execution, completion, and recovery.",
|
||||
"include": [
|
||||
"server/src/services/clawdbot-agent-service.ts",
|
||||
"server/src/services/codex-event-interpreter.ts",
|
||||
"server/src/services/*provider*.ts",
|
||||
"server/src/services/run-launch-compiler.ts",
|
||||
"server/src/services/workflow-run-service.ts",
|
||||
"server/src/services/workflow-step-executor.ts",
|
||||
"server/src/services/run-launch-manifest-service.ts",
|
||||
|
|
|
|||
85
server/src/__tests__/codex-event-interpreter.test.ts
Normal file
85
server/src/__tests__/codex-event-interpreter.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
interpretCodexEvent,
|
||||
redactProviderTraceText,
|
||||
} from '../services/codex-event-interpreter.js';
|
||||
|
||||
describe('Codex event interpretation', () => {
|
||||
it('normalizes nested completion events and protects trace text', () => {
|
||||
const interpreted = interpretCodexEvent(
|
||||
{
|
||||
args: [' status ', '', 42],
|
||||
file: 'direct.ts',
|
||||
file_path: [
|
||||
'./result.ts',
|
||||
'../parent.ts',
|
||||
'/tmp/output.log',
|
||||
'https://example.com/result',
|
||||
'nested/result.json',
|
||||
'notes.md',
|
||||
'line\nbreak',
|
||||
'',
|
||||
42,
|
||||
],
|
||||
retryAttempt: '2',
|
||||
retryDelayMs: false,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 4,
|
||||
cost_usd: 0.01,
|
||||
model: 'gpt-test',
|
||||
},
|
||||
nested: { final_response: ' done ' },
|
||||
item: { type: 'completed_item' },
|
||||
},
|
||||
'turn.completed'
|
||||
);
|
||||
|
||||
expect(interpreted).toMatchObject({
|
||||
command: 'status',
|
||||
files: [
|
||||
'direct.ts',
|
||||
'./result.ts',
|
||||
'../parent.ts',
|
||||
'/tmp/output.log',
|
||||
'https://example.com/result',
|
||||
'nested/result.json',
|
||||
'notes.md',
|
||||
],
|
||||
retryAttempt: 2,
|
||||
tool: 'completed_item',
|
||||
traceStepType: 'complete',
|
||||
logActivity: true,
|
||||
summary: 'done',
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 4,
|
||||
totalTokens: 14,
|
||||
cost: 0.01,
|
||||
model: 'gpt-test',
|
||||
},
|
||||
});
|
||||
expect(interpreted.stream).toBeUndefined();
|
||||
|
||||
const redacted = redactProviderTraceText(`token=secret-value ${'x'.repeat(2100)}`);
|
||||
expect(redacted.startsWith('token=[REDACTED]')).toBe(true);
|
||||
expect(redacted).toHaveLength(2003);
|
||||
expect(redacted.endsWith('...')).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['turn.retrying', {}, 'retry', undefined],
|
||||
['run.aborted', {}, 'abort', undefined],
|
||||
['run.cancelled', {}, 'abort', undefined],
|
||||
['turn.failed', { message: 'failed' }, 'error', undefined],
|
||||
['error', { error: 'broken' }, 'error', 'stderr'],
|
||||
['run.finalizing', {}, 'finalize', undefined],
|
||||
['response.output', {}, 'stream', 'stdout'],
|
||||
['item.created', { item: { type: 'message_delta' } }, 'stream', undefined],
|
||||
['response.completed', {}, 'complete', undefined],
|
||||
['item.created', {}, 'execute', undefined],
|
||||
] as const)('maps %s to its trace lifecycle', (type, event, traceStepType, stream) => {
|
||||
expect(interpretCodexEvent(event, type)).toMatchObject({ traceStepType, stream });
|
||||
});
|
||||
});
|
||||
|
|
@ -1691,6 +1691,15 @@ describe('ClawdbotAgentService Codex providers', () => {
|
|||
await waitFor(() => {
|
||||
expect(received.some(({ method }) => method === 'turn/start')).toBe(true);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockPatchTaskAttempt).toHaveBeenCalledWith(
|
||||
task.id,
|
||||
status.attemptId,
|
||||
expect.objectContaining({
|
||||
conversation: expect.objectContaining({ currentTurnId: 'turn-app-server-fixture' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
expect(captureBoundary).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
taskId: task.id,
|
||||
|
|
|
|||
|
|
@ -5,15 +5,19 @@ const createFsPromisesMock = vi.hoisted(() => () => {
|
|||
access: vi.fn().mockResolvedValue(undefined),
|
||||
appendFile: vi.fn().mockResolvedValue(undefined),
|
||||
copyFile: vi.fn().mockResolvedValue(undefined),
|
||||
cp: vi.fn().mockResolvedValue(undefined),
|
||||
lstat: vi.fn().mockResolvedValue({ isSymbolicLink: () => false }),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
open: vi.fn().mockResolvedValue(undefined),
|
||||
readFile: vi.fn().mockResolvedValue(''),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
readdir: vi.fn().mockResolvedValue([]),
|
||||
rename: vi.fn().mockResolvedValue(undefined),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
rm: vi.fn().mockResolvedValue(undefined),
|
||||
rmdir: vi.fn().mockResolvedValue(undefined),
|
||||
stat: vi.fn().mockResolvedValue({ isDirectory: () => true, size: 0 }),
|
||||
statfs: vi.fn().mockResolvedValue({ bfree: 1, bsize: 1 }),
|
||||
};
|
||||
return { ...mod, default: mod };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import jwt from 'jsonwebtoken';
|
|||
// vi.hoisted runs before vi.mock hoisting — makes mockFs available to the mock factory
|
||||
const mockFs: Record<string, string> = vi.hoisted(() => ({}));
|
||||
|
||||
vi.mock('node:fs/promises', () => {
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||
const access = vi.fn().mockResolvedValue(undefined);
|
||||
const lstat = vi.fn().mockResolvedValue({ isSymbolicLink: () => false });
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined);
|
||||
|
|
@ -16,6 +17,7 @@ vi.mock('node:fs/promises', () => {
|
|||
const unlink = vi.fn().mockResolvedValue(undefined);
|
||||
const rm = vi.fn().mockResolvedValue(undefined);
|
||||
return {
|
||||
...actual,
|
||||
access,
|
||||
lstat,
|
||||
mkdir,
|
||||
|
|
@ -25,7 +27,18 @@ vi.mock('node:fs/promises', () => {
|
|||
readdir,
|
||||
unlink,
|
||||
rm,
|
||||
default: { access, lstat, mkdir, readFile, rename, writeFile, readdir, unlink, rm },
|
||||
default: {
|
||||
...actual,
|
||||
access,
|
||||
lstat,
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
writeFile,
|
||||
readdir,
|
||||
unlink,
|
||||
rm,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import fs from 'fs/promises';
|
|||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { TraceService } from '../services/trace-service.js';
|
||||
import { TraceFileRepository } from '../storage/trace-file-repository.js';
|
||||
|
||||
function requireValue<T>(value: T | null | undefined): T {
|
||||
if (value === null || value === undefined) {
|
||||
|
|
@ -35,8 +36,11 @@ describe('TraceService', () => {
|
|||
|
||||
service = new TraceService();
|
||||
// Override private fields
|
||||
const internals = service as unknown as { tracesDir: string; enabled: boolean };
|
||||
internals.tracesDir = tracesDir;
|
||||
const internals = service as unknown as {
|
||||
repository: TraceFileRepository;
|
||||
enabled: boolean;
|
||||
};
|
||||
internals.repository = new TraceFileRepository(tracesDir);
|
||||
internals.enabled = true;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -315,19 +315,12 @@ import {
|
|||
DependencyCircuitExecutionService,
|
||||
type DependencyCircuitExecutionOptions,
|
||||
} from './dependency-circuit-routing-service.js';
|
||||
import {
|
||||
interpretCodexEvent,
|
||||
redactProviderTraceText,
|
||||
type CodexEventInterpretation,
|
||||
} from './codex-event-interpreter.js';
|
||||
const log = createLogger('clawdbot-agent-service');
|
||||
|
||||
const TRACE_SECRET_PATTERNS: Array<[RegExp, string]> = [
|
||||
[/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]'],
|
||||
[/\bsk-[A-Za-z0-9_-]{12,}/g, 'sk-[REDACTED]'],
|
||||
[/\bghp_[A-Za-z0-9_]{12,}/g, 'ghp_[REDACTED]'],
|
||||
[/\bgithub_pat_[A-Za-z0-9_]{12,}/g, 'github_pat_[REDACTED]'],
|
||||
[
|
||||
/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|ACCESS_KEY)[A-Z0-9_]*)\s*=\s*([^\s"'`]+)/gi,
|
||||
'$1=[REDACTED]',
|
||||
],
|
||||
[/\b(api[_-]?key|token|secret|password|authorization)\s*[:=]\s*([^\s"'`,}]+)/gi, '$1=[REDACTED]'],
|
||||
];
|
||||
const CLAUDE_CODE_MAX_STDERR_BUFFER_BYTES = 64 * 1024;
|
||||
const providerDependencyExecutionOptions = {
|
||||
signalsForError: (error: unknown) => {
|
||||
|
|
@ -8966,8 +8959,8 @@ export class ClawdbotAgentService {
|
|||
}
|
||||
const record = event as Record<string, unknown>;
|
||||
const type = String(record.type || record.event || 'codex.event');
|
||||
const summary = this.extractCodexSummary(record);
|
||||
const usage = this.extractCodexUsage(record);
|
||||
const interpreted = interpretCodexEvent(record, type);
|
||||
const { summary, usage } = interpreted;
|
||||
if (usage && task) {
|
||||
assertProviderRuntimeControl(
|
||||
pendingAgents.get(task.id)?.providerRuntimeManifest,
|
||||
|
|
@ -8975,7 +8968,7 @@ export class ClawdbotAgentService {
|
|||
);
|
||||
}
|
||||
if (task && attemptId) {
|
||||
await this.recordCodexEvent(task, attemptId, agentConfig, type, record, summary);
|
||||
await this.recordCodexEvent(task, attemptId, agentConfig, type, record, interpreted);
|
||||
}
|
||||
const redactedRecord = this.redactTraceText(JSON.stringify(record, null, 2));
|
||||
await this.appendLog(
|
||||
|
|
@ -9279,18 +9272,14 @@ export class ClawdbotAgentService {
|
|||
agentConfig: AgentConfig | undefined,
|
||||
type: string,
|
||||
event: Record<string, unknown>,
|
||||
summary?: string
|
||||
interpreted: CodexEventInterpretation
|
||||
): Promise<void> {
|
||||
const agent =
|
||||
agentConfig?.type || (agentConfig?.provider === 'codex-sdk' ? 'codex-sdk' : 'codex');
|
||||
const files = this.extractCodexFiles(event);
|
||||
const usage = this.extractCodexUsage(event);
|
||||
const command = this.extractCodexCommand(event);
|
||||
const tool = this.extractCodexTool(event, type);
|
||||
const error = this.extractCodexError(event, type);
|
||||
const { summary, files, usage, command, tool, error } = interpreted;
|
||||
const sanitizedSummary = summary ? this.redactTraceText(summary) : undefined;
|
||||
const stepType = this.codexTraceStepType(type, event);
|
||||
const stream = this.extractCodexStream(event, type);
|
||||
const stepType = interpreted.traceStepType;
|
||||
const stream = interpreted.stream;
|
||||
const provider = agentConfig?.provider === 'codex-sdk' ? 'codex-sdk' : 'codex-cli';
|
||||
const journalEvent = await this.appendMappedProviderEvent(
|
||||
task,
|
||||
|
|
@ -9332,8 +9321,8 @@ export class ClawdbotAgentService {
|
|||
tool,
|
||||
files,
|
||||
error: error ? this.redactTraceText(error) : undefined,
|
||||
retryAttempt: this.extractCodexNumber(event, ['retryAttempt', 'retry_attempt', 'attempt']),
|
||||
retryDelayMs: this.extractCodexNumber(event, ['retryDelayMs', 'retry_delay_ms', 'delayMs']),
|
||||
retryAttempt: interpreted.retryAttempt,
|
||||
retryDelayMs: interpreted.retryDelayMs,
|
||||
inputTokens: usage?.inputTokens,
|
||||
outputTokens: usage?.outputTokens,
|
||||
totalTokens: usage?.totalTokens,
|
||||
|
|
@ -9341,7 +9330,7 @@ export class ClawdbotAgentService {
|
|||
finalResult: stepType === 'complete' ? sanitizedSummary : undefined,
|
||||
});
|
||||
|
||||
if (this.shouldLogCodexActivity(type)) {
|
||||
if (interpreted.logActivity) {
|
||||
await activityService.logActivity(
|
||||
'agent_event',
|
||||
task.id,
|
||||
|
|
@ -9374,235 +9363,8 @@ export class ClawdbotAgentService {
|
|||
}
|
||||
}
|
||||
|
||||
private codexTraceStepType(type: string, event?: Record<string, unknown>): AgentRunTraceStepType {
|
||||
const normalized = type.toLowerCase();
|
||||
if (normalized.includes('retry')) return 'retry';
|
||||
if (normalized.includes('abort') || normalized.includes('cancel')) return 'abort';
|
||||
if (normalized.includes('failed') || normalized === 'error') return 'error';
|
||||
if (normalized.includes('finaliz')) return 'finalize';
|
||||
if (
|
||||
normalized.includes('delta') ||
|
||||
normalized.includes('stream') ||
|
||||
normalized.includes('output') ||
|
||||
normalized.includes('stdout') ||
|
||||
normalized.includes('stderr')
|
||||
) {
|
||||
return 'stream';
|
||||
}
|
||||
if (event && typeof event.item === 'object' && event.item !== null) {
|
||||
const itemType = String((event.item as Record<string, unknown>).type || '').toLowerCase();
|
||||
if (itemType.includes('delta') || itemType.includes('message_delta')) return 'stream';
|
||||
}
|
||||
if (type.includes('failed') || type === 'error') return 'error';
|
||||
if (type === 'turn.completed' || type === 'response.completed') return 'complete';
|
||||
return 'execute';
|
||||
}
|
||||
|
||||
private shouldLogCodexActivity(type: string): boolean {
|
||||
return (
|
||||
type.includes('command') ||
|
||||
type.includes('tool') ||
|
||||
type.includes('file') ||
|
||||
type.includes('retry') ||
|
||||
type.includes('abort') ||
|
||||
type.includes('completed') ||
|
||||
type.includes('failed') ||
|
||||
type === 'error'
|
||||
);
|
||||
}
|
||||
|
||||
private extractCodexFiles(event: unknown): string[] {
|
||||
const files = new Set<string>();
|
||||
const seen = new Set<unknown>();
|
||||
const fileKeys = new Set([
|
||||
'file',
|
||||
'file_path',
|
||||
'filePath',
|
||||
'path',
|
||||
'relative_path',
|
||||
'relativePath',
|
||||
'absolute_path',
|
||||
'absolutePath',
|
||||
]);
|
||||
|
||||
const visit = (value: unknown, key?: string): void => {
|
||||
if (!value) return;
|
||||
if (typeof value === 'string') {
|
||||
if (key && fileKeys.has(key) && this.looksLikeFilePath(value)) files.add(value);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) visit(item, key);
|
||||
return;
|
||||
}
|
||||
if (typeof value !== 'object' || seen.has(value)) return;
|
||||
seen.add(value);
|
||||
for (const [childKey, childValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
visit(childValue, childKey);
|
||||
}
|
||||
};
|
||||
|
||||
visit(event);
|
||||
return [...files].slice(0, 25);
|
||||
}
|
||||
|
||||
private extractCodexCommand(event: unknown): string | undefined {
|
||||
const command = this.findCodexString(event, [
|
||||
'command',
|
||||
'cmd',
|
||||
'shell_command',
|
||||
'shellCommand',
|
||||
]);
|
||||
const args = this.findCodexStringArray(event, ['args', 'argv']);
|
||||
if (command && args.length > 0) return `${command} ${args.join(' ')}`;
|
||||
return command ?? (args.length > 0 ? args.join(' ') : undefined);
|
||||
}
|
||||
|
||||
private extractCodexTool(event: unknown, fallbackType: string): string | undefined {
|
||||
const tool = this.findCodexString(event, [
|
||||
'tool',
|
||||
'tool_name',
|
||||
'toolName',
|
||||
'function_name',
|
||||
'functionName',
|
||||
]);
|
||||
if (tool) return tool;
|
||||
|
||||
if (event && typeof event === 'object') {
|
||||
const item = (event as Record<string, unknown>).item;
|
||||
if (item && typeof item === 'object') {
|
||||
const itemType = (item as Record<string, unknown>).type;
|
||||
if (typeof itemType === 'string' && itemType.trim()) return itemType.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackType;
|
||||
}
|
||||
|
||||
private extractCodexError(event: unknown, type: string): string | undefined {
|
||||
if (!type.includes('failed') && type !== 'error') return undefined;
|
||||
const error = this.findCodexString(event, ['error', 'message']);
|
||||
return error;
|
||||
}
|
||||
|
||||
private extractCodexStream(
|
||||
event: Record<string, unknown>,
|
||||
type: string
|
||||
): 'stdout' | 'stderr' | undefined {
|
||||
const stream = this.findCodexString(event, ['stream', 'channel', 'fd']);
|
||||
if (stream === 'stdout' || stream === 'stderr') return stream;
|
||||
if (/stderr|error/i.test(type)) return 'stderr';
|
||||
if (/stdout|delta|output|stream/i.test(type)) return 'stdout';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private extractCodexNumber(event: unknown, keys: string[]): number | undefined {
|
||||
const wanted = new Set(keys);
|
||||
const seen = new Set<unknown>();
|
||||
|
||||
const visit = (value: unknown, key?: string): number | undefined => {
|
||||
if (!value) return undefined;
|
||||
if (typeof value === 'number') {
|
||||
return key && wanted.has(key) ? value : undefined;
|
||||
}
|
||||
if (typeof value === 'string' && key && wanted.has(key)) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const result = visit(item, key);
|
||||
if (result !== undefined) return result;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'object' || seen.has(value)) return undefined;
|
||||
seen.add(value);
|
||||
for (const [childKey, childValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
const result = visit(childValue, childKey);
|
||||
if (result !== undefined) return result;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return visit(event);
|
||||
}
|
||||
|
||||
private findCodexString(event: unknown, keys: string[]): string | undefined {
|
||||
const wanted = new Set(keys);
|
||||
const seen = new Set<unknown>();
|
||||
|
||||
const visit = (value: unknown, key?: string): string | undefined => {
|
||||
if (!value) return undefined;
|
||||
if (typeof value === 'string') {
|
||||
if (key && wanted.has(key) && value.trim()) return value.trim();
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const result = visit(item, key);
|
||||
if (result) return result;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'object' || seen.has(value)) return undefined;
|
||||
seen.add(value);
|
||||
for (const [childKey, childValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
const result = visit(childValue, childKey);
|
||||
if (result) return result;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return visit(event);
|
||||
}
|
||||
|
||||
private findCodexStringArray(event: unknown, keys: string[]): string[] {
|
||||
const wanted = new Set(keys);
|
||||
const seen = new Set<unknown>();
|
||||
|
||||
const visit = (value: unknown, key?: string): string[] => {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) {
|
||||
if (key && wanted.has(key)) {
|
||||
return value
|
||||
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
.map((item) => item.trim())
|
||||
.slice(0, 20);
|
||||
}
|
||||
for (const item of value) {
|
||||
const result = visit(item, key);
|
||||
if (result.length > 0) return result;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
if (typeof value !== 'object' || seen.has(value)) return [];
|
||||
seen.add(value);
|
||||
for (const [childKey, childValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
const result = visit(childValue, childKey);
|
||||
if (result.length > 0) return result;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
return visit(event);
|
||||
}
|
||||
|
||||
private redactTraceText(value: string): string {
|
||||
let redacted = value;
|
||||
for (const [pattern, replacement] of TRACE_SECRET_PATTERNS) {
|
||||
redacted = redacted.replace(pattern, replacement);
|
||||
}
|
||||
return redacted.length > 2000 ? `${redacted.slice(0, 2000)}...` : redacted;
|
||||
}
|
||||
|
||||
private looksLikeFilePath(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.includes('\n')) return false;
|
||||
if (/^https?:\/\//i.test(trimmed)) return true;
|
||||
if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../'))
|
||||
return true;
|
||||
return /^[\w.-]+\/[\w./-]+$/.test(trimmed) || /\.[a-z0-9]{1,12}$/i.test(trimmed);
|
||||
return redactProviderTraceText(value);
|
||||
}
|
||||
|
||||
private async attachProviderDeliverables(
|
||||
|
|
@ -9726,87 +9488,6 @@ export class ClawdbotAgentService {
|
|||
return conversation;
|
||||
}
|
||||
|
||||
private extractCodexSummary(event: unknown): string | undefined {
|
||||
const seen = new Set<unknown>();
|
||||
const visit = (value: unknown): string | undefined => {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
if (seen.has(value)) return undefined;
|
||||
seen.add(value);
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const key of [
|
||||
'final_response',
|
||||
'finalMessage',
|
||||
'final_message',
|
||||
'message',
|
||||
'text',
|
||||
'delta',
|
||||
'chunk',
|
||||
'content',
|
||||
'output',
|
||||
]) {
|
||||
const candidate = record[key];
|
||||
if (typeof candidate === 'string' && candidate.trim()) return candidate.trim();
|
||||
}
|
||||
for (const child of Object.values(record)) {
|
||||
const result = visit(child);
|
||||
if (result) return result;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
return visit(event);
|
||||
}
|
||||
|
||||
private extractCodexUsage(event: unknown):
|
||||
| {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens?: number;
|
||||
cost?: number;
|
||||
model?: string;
|
||||
}
|
||||
| undefined {
|
||||
const seen = new Set<unknown>();
|
||||
const visit = (value: unknown): Record<string, unknown> | undefined => {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
if (seen.has(value)) return undefined;
|
||||
seen.add(value);
|
||||
const record = value as Record<string, unknown>;
|
||||
const input =
|
||||
record.input_tokens ?? record.inputTokens ?? record.prompt_tokens ?? record.promptTokens;
|
||||
const output =
|
||||
record.output_tokens ??
|
||||
record.outputTokens ??
|
||||
record.completion_tokens ??
|
||||
record.completionTokens;
|
||||
if (typeof input === 'number' && typeof output === 'number') return record;
|
||||
for (const child of Object.values(record)) {
|
||||
const result = visit(child);
|
||||
if (result) return result;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const usage = visit(event);
|
||||
if (!usage) return undefined;
|
||||
const input = (usage.input_tokens ??
|
||||
usage.inputTokens ??
|
||||
usage.prompt_tokens ??
|
||||
usage.promptTokens) as number;
|
||||
const output = (usage.output_tokens ??
|
||||
usage.outputTokens ??
|
||||
usage.completion_tokens ??
|
||||
usage.completionTokens) as number;
|
||||
const total = usage.total_tokens ?? usage.totalTokens;
|
||||
const cost = usage.cost ?? usage.cost_usd ?? usage.costUsd;
|
||||
return {
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
totalTokens: typeof total === 'number' ? total : input + output,
|
||||
cost: typeof cost === 'number' ? cost : undefined,
|
||||
model: typeof usage.model === 'string' ? usage.model : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async appendLog(logPath: string, content: string): Promise<void> {
|
||||
ensureWithinBase(this.logsDir, logPath);
|
||||
await fs.appendFile(logPath, content, 'utf-8');
|
||||
|
|
|
|||
256
server/src/services/codex-event-interpreter.ts
Normal file
256
server/src/services/codex-event-interpreter.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import type { AgentRunTraceStepType } from '@veritas-kanban/shared';
|
||||
|
||||
const SECRET_PATTERNS: Array<[RegExp, string]> = [
|
||||
[/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]'],
|
||||
[/\bsk-[A-Za-z0-9_-]{12,}/g, 'sk-[REDACTED]'],
|
||||
[/\bghp_[A-Za-z0-9_]{12,}/g, 'ghp_[REDACTED]'],
|
||||
[/\bgithub_pat_[A-Za-z0-9_]{12,}/g, 'github_pat_[REDACTED]'],
|
||||
[
|
||||
/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|ACCESS_KEY)[A-Z0-9_]*)\s*=\s*([^\s"'`]+)/gi,
|
||||
'$1=[REDACTED]',
|
||||
],
|
||||
[/\b(api[_-]?key|token|secret|password|authorization)\s*[:=]\s*([^\s"'`,}]+)/gi, '$1=[REDACTED]'],
|
||||
];
|
||||
|
||||
const SUMMARY_KEYS = [
|
||||
'final_response',
|
||||
'finalMessage',
|
||||
'final_message',
|
||||
'message',
|
||||
'text',
|
||||
'delta',
|
||||
'chunk',
|
||||
'content',
|
||||
'output',
|
||||
];
|
||||
const FILE_KEYS = new Set([
|
||||
'file',
|
||||
'file_path',
|
||||
'filePath',
|
||||
'path',
|
||||
'relative_path',
|
||||
'relativePath',
|
||||
'absolute_path',
|
||||
'absolutePath',
|
||||
]);
|
||||
|
||||
export interface CodexEventUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens?: number;
|
||||
cost?: number;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface CodexEventInterpretation {
|
||||
summary?: string;
|
||||
usage?: CodexEventUsage;
|
||||
files: string[];
|
||||
command?: string;
|
||||
tool?: string;
|
||||
error?: string;
|
||||
traceStepType: AgentRunTraceStepType;
|
||||
stream?: 'stdout' | 'stderr';
|
||||
retryAttempt?: number;
|
||||
retryDelayMs?: number;
|
||||
logActivity: boolean;
|
||||
}
|
||||
|
||||
export function redactProviderTraceText(value: string): string {
|
||||
let redacted = value;
|
||||
for (const [pattern, replacement] of SECRET_PATTERNS) {
|
||||
redacted = redacted.replace(pattern, replacement);
|
||||
}
|
||||
return redacted.length > 2000 ? `${redacted.slice(0, 2000)}...` : redacted;
|
||||
}
|
||||
|
||||
export function interpretCodexEvent(
|
||||
event: Record<string, unknown>,
|
||||
type: string
|
||||
): CodexEventInterpretation {
|
||||
const command = findString(event, new Set(['command', 'cmd', 'shell_command', 'shellCommand']));
|
||||
const args = findStringArray(event, new Set(['args', 'argv']));
|
||||
const tool =
|
||||
findString(
|
||||
event,
|
||||
new Set(['tool', 'tool_name', 'toolName', 'function_name', 'functionName'])
|
||||
) ??
|
||||
itemType(event) ??
|
||||
type;
|
||||
return {
|
||||
summary: extractSummary(event),
|
||||
usage: extractUsage(event),
|
||||
files: extractFiles(event),
|
||||
command: command
|
||||
? `${command}${args.length ? ` ${args.join(' ')}` : ''}`
|
||||
: args.join(' ') || undefined,
|
||||
tool,
|
||||
error:
|
||||
type.includes('failed') || type === 'error'
|
||||
? findString(event, new Set(['error', 'message']))
|
||||
: undefined,
|
||||
traceStepType: traceStepType(type, event),
|
||||
stream: extractStream(event, type),
|
||||
retryAttempt: findNumber(event, new Set(['retryAttempt', 'retry_attempt', 'attempt'])),
|
||||
retryDelayMs: findNumber(event, new Set(['retryDelayMs', 'retry_delay_ms', 'delayMs'])),
|
||||
logActivity: /command|tool|file|retry|abort|completed|failed/.test(type) || type === 'error',
|
||||
};
|
||||
}
|
||||
|
||||
function visitRecords(
|
||||
value: unknown,
|
||||
visitor: (record: Record<string, unknown>) => boolean | void,
|
||||
seen = new Set<unknown>()
|
||||
): void {
|
||||
if (!value || typeof value !== 'object' || seen.has(value)) return;
|
||||
seen.add(value);
|
||||
if (visitor(value as Record<string, unknown>)) return;
|
||||
for (const child of Object.values(value as Record<string, unknown>)) {
|
||||
visitRecords(child, visitor, seen);
|
||||
}
|
||||
}
|
||||
|
||||
function findString(value: unknown, keys: Set<string>): string | undefined {
|
||||
let found: string | undefined;
|
||||
visitRecords(value, (record) => {
|
||||
for (const [key, candidate] of Object.entries(record)) {
|
||||
if (keys.has(key) && typeof candidate === 'string' && candidate.trim()) {
|
||||
found = candidate.trim();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function findStringArray(value: unknown, keys: Set<string>): string[] {
|
||||
let found: string[] = [];
|
||||
visitRecords(value, (record) => {
|
||||
for (const [key, candidate] of Object.entries(record)) {
|
||||
if (keys.has(key) && Array.isArray(candidate)) {
|
||||
const strings = candidate
|
||||
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
.map((item) => item.trim())
|
||||
.slice(0, 20);
|
||||
if (strings.length) {
|
||||
found = strings;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function findNumber(value: unknown, keys: Set<string>): number | undefined {
|
||||
let found: number | undefined;
|
||||
visitRecords(value, (record) => {
|
||||
for (const [key, candidate] of Object.entries(record)) {
|
||||
if (!keys.has(key)) continue;
|
||||
if (typeof candidate !== 'number' && typeof candidate !== 'string') continue;
|
||||
const parsed = typeof candidate === 'number' ? candidate : Number(candidate);
|
||||
if (Number.isFinite(parsed)) {
|
||||
found = parsed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function extractFiles(value: unknown): string[] {
|
||||
const files = new Set<string>();
|
||||
visitRecords(value, (record) => {
|
||||
for (const [key, candidate] of Object.entries(record)) {
|
||||
if (FILE_KEYS.has(key) && typeof candidate === 'string' && looksLikeFilePath(candidate)) {
|
||||
files.add(candidate);
|
||||
}
|
||||
if (FILE_KEYS.has(key) && Array.isArray(candidate)) {
|
||||
for (const item of candidate) {
|
||||
if (typeof item === 'string' && looksLikeFilePath(item)) files.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return [...files].slice(0, 25);
|
||||
}
|
||||
|
||||
function extractUsage(value: unknown): CodexEventUsage | undefined {
|
||||
let usage: CodexEventUsage | undefined;
|
||||
visitRecords(value, (record) => {
|
||||
const input =
|
||||
record.input_tokens ?? record.inputTokens ?? record.prompt_tokens ?? record.promptTokens;
|
||||
const output =
|
||||
record.output_tokens ??
|
||||
record.outputTokens ??
|
||||
record.completion_tokens ??
|
||||
record.completionTokens;
|
||||
if (typeof input !== 'number' || typeof output !== 'number') return false;
|
||||
const total = record.total_tokens ?? record.totalTokens;
|
||||
const cost = record.cost ?? record.cost_usd ?? record.costUsd;
|
||||
usage = {
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
totalTokens: typeof total === 'number' ? total : input + output,
|
||||
cost: typeof cost === 'number' ? cost : undefined,
|
||||
model: typeof record.model === 'string' ? record.model : undefined,
|
||||
};
|
||||
return true;
|
||||
});
|
||||
return usage;
|
||||
}
|
||||
|
||||
function traceStepType(type: string, event: Record<string, unknown>): AgentRunTraceStepType {
|
||||
const normalized = type.toLowerCase();
|
||||
if (normalized.includes('retry')) return 'retry';
|
||||
if (normalized.includes('abort') || normalized.includes('cancel')) return 'abort';
|
||||
if (normalized.includes('failed') || normalized === 'error') return 'error';
|
||||
if (normalized.includes('finaliz')) return 'finalize';
|
||||
if (/delta|stream|output|stdout|stderr/.test(normalized)) return 'stream';
|
||||
if (/delta|message_delta/.test((itemType(event) ?? '').toLowerCase())) return 'stream';
|
||||
if (type === 'turn.completed' || type === 'response.completed') return 'complete';
|
||||
return 'execute';
|
||||
}
|
||||
|
||||
function itemType(event: Record<string, unknown>): string | undefined {
|
||||
if (!event.item || typeof event.item !== 'object') return undefined;
|
||||
const type = (event.item as Record<string, unknown>).type;
|
||||
return typeof type === 'string' && type.trim() ? type.trim() : undefined;
|
||||
}
|
||||
|
||||
function extractSummary(value: unknown): string | undefined {
|
||||
let summary: string | undefined;
|
||||
visitRecords(value, (record) => {
|
||||
for (const key of SUMMARY_KEYS) {
|
||||
const candidate = record[key];
|
||||
if (typeof candidate === 'string' && candidate.trim()) {
|
||||
summary = candidate.trim();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return summary;
|
||||
}
|
||||
|
||||
function extractStream(
|
||||
event: Record<string, unknown>,
|
||||
type: string
|
||||
): 'stdout' | 'stderr' | undefined {
|
||||
const stream = findString(event, new Set(['stream', 'channel', 'fd']));
|
||||
if (stream === 'stdout' || stream === 'stderr') return stream;
|
||||
if (/stderr|error/i.test(type)) return 'stderr';
|
||||
if (/stdout|delta|output|stream/i.test(type)) return 'stdout';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function looksLikeFilePath(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.includes('\n')) return false;
|
||||
if (/^https?:\/\//i.test(trimmed)) return true;
|
||||
if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../')) return true;
|
||||
return /^[\w.-]+\/[\w./-]+$/.test(trimmed) || /\.[a-z0-9]{1,12}$/i.test(trimmed);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
|
|||
deleteComment: vi.fn(),
|
||||
uploadAttachment: vi.fn(),
|
||||
deleteAttachment: vi.fn(),
|
||||
getAttachmentText: vi.fn(),
|
||||
timeStart: vi.fn(),
|
||||
timeStop: vi.fn(),
|
||||
timeAddEntry: vi.fn(),
|
||||
|
|
@ -57,6 +58,9 @@ vi.mock('@/lib/api', () => ({
|
|||
addEntry: mocks.timeAddEntry,
|
||||
deleteEntry: mocks.timeDeleteEntry,
|
||||
},
|
||||
attachments: {
|
||||
getText: mocks.getAttachmentText,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -69,6 +73,11 @@ describe('task detail support sections Mantine migration', () => {
|
|||
mocks.deleteComment.mockResolvedValue(undefined);
|
||||
mocks.uploadAttachment.mockResolvedValue(undefined);
|
||||
mocks.deleteAttachment.mockResolvedValue(undefined);
|
||||
mocks.getAttachmentText.mockResolvedValue({
|
||||
attachmentId: 'attachment-1',
|
||||
text: 'Extracted design notes',
|
||||
hasText: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -136,11 +145,6 @@ describe('task detail support sections Mantine migration', () => {
|
|||
|
||||
it('renders attachments through direct Mantine controls and preserves upload, preview, and delete', async () => {
|
||||
const user = userEvent.setup();
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue({ text: 'Extracted design notes' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const task = createMockTask({
|
||||
id: 'task-attachments',
|
||||
attachments: [
|
||||
|
|
@ -187,9 +191,7 @@ describe('task detail support sections Mantine migration', () => {
|
|||
taskId: task.id,
|
||||
formData: expect.any(FormData),
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/tasks/task-attachments/attachments/attachment-1/text')
|
||||
);
|
||||
expect(mocks.getAttachmentText).toHaveBeenCalledWith(task.id, 'attachment-1');
|
||||
expect(mocks.deleteAttachment).toHaveBeenCalledWith({
|
||||
taskId: task.id,
|
||||
attachmentId: 'attachment-1',
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ function jsonResponse(data: unknown, ok = true) {
|
|||
return {
|
||||
ok,
|
||||
status: ok ? 200 : 500,
|
||||
json: async () => ({ data }),
|
||||
json: async () => data,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue