Clean up old stuff & fix ui errors
Build and Push Spoon Images / quality (push) Successful in 2m22s
Build and Push Spoon Images / build-images (push) Successful in 23m10s

This commit is contained in:
Gabriel Brown
2026-06-23 14:57:05 -04:00
parent d207b8b0b8
commit a6f7ea7f78
34 changed files with 1565 additions and 551 deletions
+132 -4
View File
@@ -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 });
+39
View 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;
}
}
};
+1
View File
@@ -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'
+12 -3
View File
@@ -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
View File
@@ -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?.();
@@ -51,6 +51,91 @@ describe('agent event normalization', () => {
).toContainEqual({ kind: 'file_edited', path: 'src/app.ts' });
});
test('normalizes current Codex item events', () => {
expect(
normalizeCodexJsonLine(
JSON.stringify({
type: 'item.completed',
item: {
id: 'item-1',
type: 'agent_message',
text: 'I updated the auth provider.',
},
}),
),
).toContainEqual({
kind: 'assistant_delta',
content: 'I updated the auth provider.',
});
expect(
normalizeCodexJsonLine(
JSON.stringify({
type: 'item.completed',
item: {
id: 'item-2',
type: 'error',
message: 'sandbox failed',
},
}),
),
).toContainEqual({
kind: 'error',
message: 'sandbox failed',
});
expect(
normalizeCodexJsonLine(
JSON.stringify({
type: 'turn.failed',
error: { message: 'request failed' },
}),
),
).toContainEqual({
kind: 'error',
message: '{\n "message": "request failed"\n}',
});
});
test('normalizes Codex tool item lifecycle events', () => {
expect(
normalizeCodexJsonLine(
JSON.stringify({
type: 'item.started',
item: {
id: 'tool-1',
type: 'local_shell_call',
command: ['bash', '-lc', 'rg Authentik'],
},
}),
),
).toContainEqual({
kind: 'tool_started',
name: 'local_shell_call',
input: 'bash -lc rg Authentik',
externalMessageId: 'tool-1',
});
expect(
normalizeCodexJsonLine(
JSON.stringify({
type: 'item.completed',
item: {
id: 'tool-1',
type: 'local_shell_call',
command: ['bash', '-lc', 'rg Authentik'],
output: 'apps/web/auth.ts',
},
}),
),
).toContainEqual({
kind: 'tool_completed',
name: 'local_shell_call',
output: 'apps/web/auth.ts',
externalMessageId: 'tool-1',
});
});
test('normalizes OpenCode assistant, tool, and permission events', () => {
expect(
normalizeOpenCodeEvent({
@@ -93,5 +178,21 @@ describe('agent event normalization', () => {
body: 'Run bun test?',
metadata: '{\n "permissionID": "perm-1",\n "message": "Run bun test?"\n}',
});
expect(
normalizeOpenCodeEvent({
type: 'tool.output',
properties: {
tool: 'read',
output: 'apps/web/auth.ts',
messageID: 'message-2',
},
}),
).toContainEqual({
kind: 'tool_completed',
name: 'read',
output: 'apps/web/auth.ts',
externalMessageId: 'message-2',
});
});
});
@@ -0,0 +1,43 @@
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, test } from 'vitest';
import { prepareCodexWorkspaceFiles } from '../../src/codex-runtime';
const tempDirs: string[] = [];
const mode = async (filePath: string) => (await stat(filePath)).mode & 0o777;
describe('Codex runtime preparation', () => {
afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { force: true, recursive: true })),
);
tempDirs.length = 0;
});
test('prepares writable Codex directories and preserves project config contents', async () => {
const workdir = await mkdtemp(path.join(os.tmpdir(), 'spoon-codex-'));
tempDirs.push(workdir);
const repoDir = path.join(workdir, 'repo');
await mkdir(path.join(repoDir, '.codex'), { recursive: true });
const projectConfig = path.join(repoDir, '.codex', 'config.toml');
await writeFile(projectConfig, '[features]\ncodex_hooks = true\n');
await prepareCodexWorkspaceFiles({ workdir, repoDir });
await expect(readFile(projectConfig, 'utf8')).resolves.toBe(
'[features]\ncodex_hooks = true\n',
);
await expect(mode(workdir)).resolves.toBe(0o755);
await expect(mode(repoDir)).resolves.toBe(0o755);
await expect(mode(path.join(workdir, '.codex'))).resolves.toBe(0o755);
await expect(mode(path.join(workdir, '.config'))).resolves.toBe(0o755);
await expect(mode(path.join(workdir, '.local', 'share'))).resolves.toBe(
0o755,
);
await expect(mode(path.join(repoDir, '.codex'))).resolves.toBe(0o755);
await expect(mode(projectConfig)).resolves.toBe(0o644);
});
});
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
const loadVolumeSpec = async () => {
vi.resetModules();
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
process.env.GITHUB_APP_ID = '123';
process.env.GITHUB_APP_PRIVATE_KEY =
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----';
return await import('../../src/runtime/docker');
};
describe('Docker runtime', () => {
afterEach(() => {
delete process.env.SPOON_AGENT_CONTAINER_RUNTIME;
delete process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS;
vi.resetModules();
});
test('adds SELinux relabel option for Podman workspace mounts by default', async () => {
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'podman';
const { jobWorkspaceVolumeSpec } = await loadVolumeSpec();
expect(jobWorkspaceVolumeSpec('/tmp/spoon-job')).toBe(
'/tmp/spoon-job:/workspace:Z',
);
});
test('does not add Podman volume options for Docker by default', async () => {
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'docker';
const { jobWorkspaceVolumeSpec } = await loadVolumeSpec();
expect(jobWorkspaceVolumeSpec('/tmp/spoon-job')).toBe(
'/tmp/spoon-job:/workspace',
);
});
test('allows explicit workspace mount options', async () => {
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'podman';
process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS = 'z';
const { jobWorkspaceVolumeSpec } = await loadVolumeSpec();
expect(jobWorkspaceVolumeSpec('/tmp/spoon-job')).toBe(
'/tmp/spoon-job:/workspace:z',
);
});
});