Clean up old stuff & fix ui errors
This commit is contained in:
@@ -70,6 +70,62 @@ const textFromPart = (part: Record<string, unknown>) => {
|
||||
return typeof text === 'string' ? text : '';
|
||||
};
|
||||
|
||||
const commandString = (value: unknown) => {
|
||||
if (Array.isArray(value)) return value.map((part) => stringify(part)).join(' ');
|
||||
return stringify(value);
|
||||
};
|
||||
|
||||
const toolNameFromRecord = (record: Record<string, unknown> | null) =>
|
||||
stringify(
|
||||
record?.tool ??
|
||||
record?.tool_name ??
|
||||
record?.toolName ??
|
||||
record?.name ??
|
||||
record?.function ??
|
||||
record?.type ??
|
||||
record?.command ??
|
||||
'tool',
|
||||
);
|
||||
|
||||
const toolInputFromRecord = (record: Record<string, unknown> | null) =>
|
||||
commandString(
|
||||
record?.input ??
|
||||
record?.arguments ??
|
||||
record?.args ??
|
||||
record?.params ??
|
||||
record?.command ??
|
||||
record?.cmd,
|
||||
);
|
||||
|
||||
const toolOutputFromRecord = (
|
||||
record: Record<string, unknown> | null,
|
||||
fallback?: unknown,
|
||||
) =>
|
||||
stringify(
|
||||
record?.output ??
|
||||
record?.result ??
|
||||
record?.content ??
|
||||
record?.text ??
|
||||
fallback,
|
||||
);
|
||||
|
||||
const recordLooksLikeTool = (
|
||||
type: string,
|
||||
record: Record<string, unknown> | null,
|
||||
) => {
|
||||
const recordType = stringify(record?.type).toLowerCase();
|
||||
const lowerType = type.toLowerCase();
|
||||
return (
|
||||
lowerType.includes('tool') ||
|
||||
lowerType.includes('function_call') ||
|
||||
recordType.includes('tool') ||
|
||||
recordType.includes('function_call') ||
|
||||
recordType.includes('local_shell_call') ||
|
||||
recordType.includes('mcp') ||
|
||||
Boolean(record?.tool ?? record?.tool_name ?? record?.name)
|
||||
);
|
||||
};
|
||||
|
||||
export const normalizeCodexJsonLine = (
|
||||
line: string,
|
||||
): NormalizedAgentEvent[] => {
|
||||
@@ -95,6 +151,37 @@ export const normalizeCodexJsonLine = (
|
||||
const item = asRecord(event.item);
|
||||
const data = asRecord(event.data);
|
||||
const part = asRecord(event.part);
|
||||
const itemType = item ? stringify(item.type) : '';
|
||||
const lowerType = type.toLowerCase();
|
||||
const lowerItemType = itemType.toLowerCase();
|
||||
if (
|
||||
item &&
|
||||
recordLooksLikeTool(type, item) &&
|
||||
(lowerType.includes('started') ||
|
||||
lowerType.includes('in_progress') ||
|
||||
lowerType.includes('created'))
|
||||
) {
|
||||
events.push({
|
||||
kind: 'tool_started',
|
||||
name: toolNameFromRecord(item),
|
||||
input: toolInputFromRecord(item),
|
||||
externalMessageId: stringify(item.id ?? event.id),
|
||||
});
|
||||
}
|
||||
if (
|
||||
item &&
|
||||
recordLooksLikeTool(type, item) &&
|
||||
(lowerType.includes('completed') ||
|
||||
lowerType.includes('done') ||
|
||||
lowerType.includes('finished'))
|
||||
) {
|
||||
events.push({
|
||||
kind: 'tool_completed',
|
||||
name: toolNameFromRecord(item),
|
||||
output: toolOutputFromRecord(item, event.output ?? data?.output),
|
||||
externalMessageId: stringify(item.id ?? event.id),
|
||||
});
|
||||
}
|
||||
const delta = event.delta ?? data?.delta;
|
||||
if (typeof delta === 'string') {
|
||||
events.push({ kind: 'assistant_delta', content: delta });
|
||||
@@ -107,19 +194,43 @@ export const normalizeCodexJsonLine = (
|
||||
text &&
|
||||
(type.includes('message') ||
|
||||
type.includes('response.output_text') ||
|
||||
type.includes('agent_message'))
|
||||
type.includes('agent_message') ||
|
||||
itemType.includes('message') ||
|
||||
itemType.includes('agent_message'))
|
||||
) {
|
||||
events.push({ kind: 'assistant_delta', content: text });
|
||||
}
|
||||
const command = event.command ?? data?.command;
|
||||
const error = event.error ?? item?.error;
|
||||
if (error || itemType === 'error') {
|
||||
events.push({
|
||||
kind: 'error',
|
||||
message: stringify(error ?? item?.message ?? event.message),
|
||||
});
|
||||
}
|
||||
const command =
|
||||
event.command ??
|
||||
data?.command ??
|
||||
(lowerItemType.includes('shell') ? item?.command : undefined);
|
||||
if (typeof command === 'string') {
|
||||
events.push({
|
||||
kind: 'command_executed',
|
||||
command,
|
||||
output: stringify(event.output ?? data?.output),
|
||||
});
|
||||
} else if (Array.isArray(command)) {
|
||||
events.push({
|
||||
kind: 'command_executed',
|
||||
command: command.map((part) => stringify(part)).join(' '),
|
||||
output: stringify(event.output ?? data?.output ?? item?.output),
|
||||
});
|
||||
}
|
||||
const file = event.file ?? event.path ?? data?.file ?? data?.path;
|
||||
const file =
|
||||
event.file ??
|
||||
event.path ??
|
||||
data?.file ??
|
||||
data?.path ??
|
||||
item?.file ??
|
||||
item?.path;
|
||||
if (typeof file === 'string' && type.includes('file')) {
|
||||
events.push({ kind: 'file_edited', path: file });
|
||||
}
|
||||
@@ -129,7 +240,16 @@ export const normalizeCodexJsonLine = (
|
||||
message: stringify(event.message ?? event.error ?? data),
|
||||
});
|
||||
}
|
||||
if (type.includes('completed') || type.includes('turn.done')) {
|
||||
if (
|
||||
type.includes('completed') &&
|
||||
itemType !== 'error' &&
|
||||
!itemType.includes('message') &&
|
||||
!itemType.includes('agent_message') &&
|
||||
!recordLooksLikeTool(type, item)
|
||||
) {
|
||||
events.push({ kind: 'assistant_completed' });
|
||||
}
|
||||
if (type.includes('turn.done')) {
|
||||
events.push({ kind: 'assistant_completed' });
|
||||
}
|
||||
if (events.length === 0) {
|
||||
@@ -188,6 +308,14 @@ export const normalizeOpenCodeEvent = (
|
||||
externalMessageId: stringify(properties.messageID),
|
||||
});
|
||||
}
|
||||
if (type.includes('tool.updated') || type.includes('tool.output')) {
|
||||
events.push({
|
||||
kind: 'tool_completed',
|
||||
name: stringify(properties.tool ?? properties.name ?? 'tool'),
|
||||
output: stringify(properties.output ?? properties.result ?? properties),
|
||||
externalMessageId: stringify(properties.messageID),
|
||||
});
|
||||
}
|
||||
if (type === 'file.edited') {
|
||||
const file = properties.file;
|
||||
if (typeof file === 'string') events.push({ kind: 'file_edited', path: file });
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { chmod, mkdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export const codexContainerWorkspace = '/workspace';
|
||||
export const codexContainerRepo = '/workspace/repo';
|
||||
|
||||
export const prepareCodexWorkspaceFiles = async (args: {
|
||||
workdir: string;
|
||||
repoDir: string;
|
||||
}) => {
|
||||
await mkdir(path.join(args.workdir, '.codex'), { recursive: true });
|
||||
await mkdir(path.join(args.workdir, '.config'), { recursive: true });
|
||||
await mkdir(path.join(args.workdir, '.local', 'share'), { recursive: true });
|
||||
|
||||
await Promise.all([
|
||||
chmod(args.workdir, 0o755),
|
||||
chmod(args.repoDir, 0o755),
|
||||
chmod(path.join(args.workdir, '.codex'), 0o755),
|
||||
chmod(path.join(args.workdir, '.config'), 0o755),
|
||||
chmod(path.join(args.workdir, '.local'), 0o755),
|
||||
chmod(path.join(args.workdir, '.local', 'share'), 0o755),
|
||||
]);
|
||||
|
||||
const projectCodexDir = path.join(args.repoDir, '.codex');
|
||||
const projectConfig = path.join(projectCodexDir, 'config.toml');
|
||||
try {
|
||||
if ((await stat(projectCodexDir)).isDirectory()) {
|
||||
await chmod(projectCodexDir, 0o755);
|
||||
}
|
||||
if ((await stat(projectConfig)).isFile()) {
|
||||
await chmod(projectConfig, 0o644);
|
||||
}
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? 'code' in error : false;
|
||||
if (!code || (error as { code?: string }).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -23,6 +23,7 @@ export const env = {
|
||||
process.env.SPOON_AGENT_CONTAINER_RUNTIME?.trim() ??
|
||||
process.env.SPOON_CONTAINER_RUNTIME?.trim() ??
|
||||
'docker',
|
||||
containerVolumeOptions: process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS?.trim(),
|
||||
containerAccess:
|
||||
process.env.SPOON_AGENT_CONTAINER_ACCESS?.trim() === 'host_port'
|
||||
? 'host_port'
|
||||
|
||||
@@ -17,6 +17,15 @@ const networkArgs = () => (env.network ? ['--network', env.network] : []);
|
||||
|
||||
const containerRuntime = () => env.containerRuntime;
|
||||
|
||||
export const jobWorkspaceVolumeSpec = (workdir: string) => {
|
||||
const volumeOptions =
|
||||
env.containerVolumeOptions ??
|
||||
(containerRuntime().endsWith('podman') ? 'Z' : undefined);
|
||||
return volumeOptions
|
||||
? `${workdir}:/workspace:${volumeOptions}`
|
||||
: `${workdir}:/workspace`;
|
||||
};
|
||||
|
||||
export const runInJobContainer = async (args: {
|
||||
workdir: string;
|
||||
command: string[];
|
||||
@@ -36,7 +45,7 @@ export const runInJobContainer = async (args: {
|
||||
...networkArgs(),
|
||||
...environmentArgs(args.environment),
|
||||
'-v',
|
||||
`${args.workdir}:/workspace`,
|
||||
jobWorkspaceVolumeSpec(args.workdir),
|
||||
'-w',
|
||||
'/workspace/repo',
|
||||
env.jobImage,
|
||||
@@ -87,7 +96,7 @@ export const startWorkspaceContainer = async (args: {
|
||||
: []),
|
||||
...environmentArgs(args.environment),
|
||||
'-v',
|
||||
`${args.workdir}:/workspace`,
|
||||
jobWorkspaceVolumeSpec(args.workdir),
|
||||
'-w',
|
||||
'/workspace/repo',
|
||||
env.jobImage,
|
||||
@@ -168,7 +177,7 @@ export const streamInJobContainer = async (args: {
|
||||
...networkArgs(),
|
||||
...environmentArgs(args.environment),
|
||||
'-v',
|
||||
`${args.workdir}:/workspace`,
|
||||
jobWorkspaceVolumeSpec(args.workdir),
|
||||
'-w',
|
||||
'/workspace/repo',
|
||||
env.jobImage,
|
||||
|
||||
+108
-24
@@ -16,6 +16,11 @@ import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
import type { NormalizedAgentEvent } from './agent-events';
|
||||
import { normalizeCodexJsonLine } from './agent-events';
|
||||
import {
|
||||
codexContainerRepo,
|
||||
codexContainerWorkspace,
|
||||
prepareCodexWorkspaceFiles,
|
||||
} from './codex-runtime';
|
||||
import { env } from './env';
|
||||
import {
|
||||
cloneRepository,
|
||||
@@ -118,7 +123,6 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const client = new ConvexHttpClient(env.convexUrl);
|
||||
const activeWorkspaces = new Map<string, ActiveWorkspace>();
|
||||
const jobContainerWorkspace = '/workspace';
|
||||
|
||||
const appendEvent = async (
|
||||
jobId: Id<'agentJobs'>,
|
||||
@@ -442,6 +446,10 @@ const prepareCodexAuth = async (workspace: ActiveWorkspace) => {
|
||||
if (!secret) {
|
||||
throw new Error('Codex auth profile is missing auth.json contents.');
|
||||
}
|
||||
await prepareCodexWorkspaceFiles({
|
||||
workdir: workspace.workdir,
|
||||
repoDir: workspace.repoDir,
|
||||
});
|
||||
const codexAuthPath = path.join(workspace.workdir, '.codex', 'auth.json');
|
||||
await writeJsonFile(codexAuthPath, secret);
|
||||
|
||||
@@ -688,14 +696,18 @@ const runCodexTurn = async (args: {
|
||||
? commandToShell(
|
||||
`codex exec resume --json --model ${quoteShell(
|
||||
codexModel(workspace.claim),
|
||||
)} ${quoteShell(workspace.codexSessionId)} ${quoteShell(prompt)}`,
|
||||
)} --dangerously-bypass-approvals-and-sandbox ${quoteShell(
|
||||
workspace.codexSessionId,
|
||||
)} ${quoteShell(prompt)}`,
|
||||
)
|
||||
: commandToShell(
|
||||
`codex exec --json --model ${quoteShell(
|
||||
codexModel(workspace.claim),
|
||||
)} --sandbox workspace-write ${quoteShell(prompt)}`,
|
||||
)} --dangerously-bypass-approvals-and-sandbox --cd ${quoteShell(
|
||||
codexContainerRepo,
|
||||
)} ${quoteShell(prompt)}`,
|
||||
);
|
||||
const aiEnv = providerEnvironment(workspace.claim, jobContainerWorkspace);
|
||||
const aiEnv = providerEnvironment(workspace.claim, codexContainerWorkspace);
|
||||
const secretEnv = Object.fromEntries(
|
||||
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
);
|
||||
@@ -958,6 +970,89 @@ const fileChangedType = async (repoDir: string, filePath: string) => {
|
||||
return 'modified' as const;
|
||||
};
|
||||
|
||||
const sensitiveWorkspacePath = (filePath: string) => {
|
||||
const parts = filePath.split('/');
|
||||
if (parts.includes('.git') || parts.includes('.codex')) return true;
|
||||
const name = parts.at(-1) ?? filePath;
|
||||
if (name === '.env') return true;
|
||||
if (name.startsWith('.env.') && name !== '.env.example') return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const changedFilesFromStatus = async (
|
||||
repoDir: string,
|
||||
redact: (value: string) => string,
|
||||
) => {
|
||||
const status = await run('git', ['status', '--short'], {
|
||||
cwd: repoDir,
|
||||
redact,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
if (status.exitCode !== 0) return [];
|
||||
return status.output
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
if (line.length < 4) return null;
|
||||
const code = line.slice(0, 2);
|
||||
const rawPath = line.slice(3).trim();
|
||||
if (!rawPath) return null;
|
||||
const filePath = rawPath.includes(' -> ')
|
||||
? rawPath.split(' -> ').at(-1)?.trim()
|
||||
: rawPath;
|
||||
if (!filePath || sensitiveWorkspacePath(filePath)) return null;
|
||||
const changeType = code.includes('D')
|
||||
? 'deleted'
|
||||
: code.includes('R')
|
||||
? 'renamed'
|
||||
: code.includes('A') || code.includes('?')
|
||||
? 'added'
|
||||
: 'modified';
|
||||
return {
|
||||
path: filePath,
|
||||
changeType,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
value,
|
||||
): value is {
|
||||
path: string;
|
||||
changeType: 'added' | 'modified' | 'deleted' | 'renamed';
|
||||
} => Boolean(value),
|
||||
);
|
||||
};
|
||||
|
||||
const recordChangedFiles = async (
|
||||
workspace: ActiveWorkspace,
|
||||
source: 'agent' | 'command',
|
||||
diff: string,
|
||||
) => {
|
||||
const changes = await changedFilesFromStatus(
|
||||
workspace.repoDir,
|
||||
workspace.redact,
|
||||
);
|
||||
for (const change of changes) {
|
||||
await recordWorkspaceChange({
|
||||
jobId: workspace.claim.job._id,
|
||||
path: change.path,
|
||||
source,
|
||||
changeType: change.changeType,
|
||||
diff: truncate(diff, 50_000),
|
||||
});
|
||||
}
|
||||
if (changes.length > 0) {
|
||||
await appendEvent(
|
||||
workspace.claim.job._id,
|
||||
'info',
|
||||
'edit',
|
||||
`Workspace has ${changes.length} changed file${
|
||||
changes.length === 1 ? '' : 's'
|
||||
}.`,
|
||||
JSON.stringify(changes),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const materializeEnvFile = async (workspace: ActiveWorkspace) => {
|
||||
const { claim, repoDir } = workspace;
|
||||
if (!claim.job.materializeEnvFile || !claim.job.envFilePath) return;
|
||||
@@ -1085,6 +1180,9 @@ const runClaim = async (claim: Claim) => {
|
||||
].filter(Boolean);
|
||||
const redact = createRedactor(secretValues);
|
||||
try {
|
||||
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
|
||||
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
|
||||
}
|
||||
await updateStatus(jobId, 'preparing');
|
||||
await appendEvent(jobId, 'info', 'clone', 'Creating installation token.');
|
||||
if (!claim.github.installationId) {
|
||||
@@ -1251,16 +1349,11 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => {
|
||||
redact: workspace.redact,
|
||||
});
|
||||
await updateStatus(workspace.claim.job._id, 'running');
|
||||
await recordWorkspaceChange({
|
||||
jobId: workspace.claim.job._id,
|
||||
path: '.',
|
||||
source: 'command',
|
||||
changeType: 'modified',
|
||||
diff: truncate(
|
||||
(await getWorktreeDiff(workspace.repoDir, workspace.redact)).output,
|
||||
50_000,
|
||||
),
|
||||
});
|
||||
await recordChangedFiles(
|
||||
workspace,
|
||||
'command',
|
||||
(await getWorktreeDiff(workspace.repoDir, workspace.redact)).output,
|
||||
);
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
@@ -1347,9 +1440,6 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
|
||||
await appendEvent(claim.job._id, 'info', 'plan', 'Sending message to agent.');
|
||||
|
||||
try {
|
||||
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
|
||||
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
|
||||
}
|
||||
workspace.agentTurnActive = true;
|
||||
const assistantMessageId = await appendMessage({
|
||||
jobId: claim.job._id,
|
||||
@@ -1430,13 +1520,7 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
|
||||
content: truncate(diff.output, 200_000),
|
||||
contentType: 'text/x-diff',
|
||||
});
|
||||
await recordWorkspaceChange({
|
||||
jobId: claim.job._id,
|
||||
path: '.',
|
||||
source: 'agent',
|
||||
changeType: 'modified',
|
||||
diff: truncate(diff.output, 50_000),
|
||||
});
|
||||
await recordChangedFiles(workspace, 'agent', diff.output);
|
||||
} catch (error) {
|
||||
workspace.agentTurnActive = false;
|
||||
workspace.resolveTurn?.();
|
||||
|
||||
Reference in New Issue
Block a user