2042 lines
61 KiB
TypeScript
2042 lines
61 KiB
TypeScript
import { randomBytes } from 'node:crypto';
|
|
import {
|
|
access,
|
|
mkdir,
|
|
readdir,
|
|
readFile,
|
|
rm,
|
|
stat,
|
|
writeFile,
|
|
} from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { ConvexHttpClient } from 'convex/browser';
|
|
|
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
|
|
|
import type { NormalizedAgentEvent } from './agent-events';
|
|
import type { OpenCodeSession } from './opencode-session';
|
|
import { normalizeCodexJsonLine } from './agent-events';
|
|
import { prepareCodexWorkspaceFiles } from './codex-runtime';
|
|
import { env } from './env';
|
|
import {
|
|
cloneRepository,
|
|
commitAndPush,
|
|
getStatus,
|
|
getWorktreeDiff,
|
|
run,
|
|
} from './git';
|
|
import { getInstallationToken, openDraftPullRequest } from './github';
|
|
import {
|
|
abortOpenCodeSession,
|
|
createOpenCodeSession,
|
|
promptOpenCodeSession,
|
|
replyOpenCodePermission,
|
|
} from './opencode-session';
|
|
import { createRedactor, truncate } from './redact';
|
|
import {
|
|
listWorkspaceContainerNames,
|
|
runExecInContainer,
|
|
startWorkspaceContainer,
|
|
stopWorkspaceContainer,
|
|
streamExecInContainer,
|
|
} from './runtime/docker';
|
|
import {
|
|
acquireUserBox,
|
|
releaseUserBox,
|
|
runningBoxUsernames,
|
|
} from './user-container';
|
|
import { fetchUserEnvironment, materializeUserHome } from './user-environment';
|
|
|
|
type Claim = {
|
|
job: {
|
|
_id: Id<'agentJobs'>;
|
|
prompt: string;
|
|
runtime?: 'openai_direct' | 'opencode';
|
|
jobType?: 'user_change' | 'maintenance_review' | 'conflict_resolution';
|
|
envFilePath?: string;
|
|
materializeEnvFile?: boolean;
|
|
baseBranch: string;
|
|
workBranch: string;
|
|
forkOwner: string;
|
|
forkRepo: string;
|
|
upstreamOwner: string;
|
|
upstreamRepo: string;
|
|
};
|
|
spoon: { name: string };
|
|
openai: {
|
|
apiKey?: string;
|
|
model: string;
|
|
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
|
};
|
|
aiProviderProfile?: {
|
|
id: string;
|
|
name: string;
|
|
provider:
|
|
| 'openai'
|
|
| 'anthropic'
|
|
| 'google'
|
|
| 'openrouter'
|
|
| 'requesty'
|
|
| 'litellm'
|
|
| 'cloudflare_ai_gateway'
|
|
| 'custom_openai_compatible'
|
|
| 'opencode_openai_login';
|
|
authType: 'api_key' | 'opencode_auth_json' | 'none';
|
|
secret?: string;
|
|
baseUrl?: string;
|
|
model: string;
|
|
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
|
};
|
|
github: { installationId?: string };
|
|
agentSettings?: {
|
|
installCommand?: string;
|
|
checkCommand?: string;
|
|
testCommand?: string;
|
|
autoDetectCommands?: boolean;
|
|
} | null;
|
|
secrets: { name: string; value: string }[];
|
|
};
|
|
|
|
type ActiveWorkspace = {
|
|
claim: Claim;
|
|
// Host path of the persistent per-user home (mounted at `containerHome`).
|
|
// Equal to `homeDir`; kept as `workdir` for the container mount source.
|
|
workdir: string;
|
|
homeDir: string;
|
|
username: string;
|
|
// In-container paths: HOME and the thread's checkout (~/Code/{spoon}/{branch}).
|
|
containerHome: string;
|
|
containerRepo: string;
|
|
repoDir: string;
|
|
// Phase 2: the per-user box container this thread execs into.
|
|
boxName: string;
|
|
githubToken: string;
|
|
redact: (value: string) => string;
|
|
runtimeMode?: 'opencode_server' | 'codex_exec' | 'legacy_cli';
|
|
containerName?: string;
|
|
containerId?: string;
|
|
opencodePassword?: string;
|
|
opencodeSession?: OpenCodeSession;
|
|
codexSessionId?: string;
|
|
agentTurnActive?: boolean;
|
|
resolveTurn?: () => void;
|
|
lastRecordedDiffSignature?: string;
|
|
// Captures the most recent Codex `error`/`turn.failed` event for the active
|
|
// turn so the failure surfaces the real reason instead of a generic
|
|
// "no assistant response" message.
|
|
codexTurnError?: string;
|
|
};
|
|
|
|
type FileTreeNode = {
|
|
name: string;
|
|
path: string;
|
|
type: 'file' | 'directory';
|
|
children?: FileTreeNode[];
|
|
};
|
|
|
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
const client = new ConvexHttpClient(env.convexUrl);
|
|
const activeWorkspaces = new Map<string, ActiveWorkspace>();
|
|
|
|
const appendEvent = async (
|
|
jobId: Id<'agentJobs'>,
|
|
level: 'debug' | 'info' | 'warn' | 'error',
|
|
phase:
|
|
| 'queued'
|
|
| 'clone'
|
|
| 'plan'
|
|
| 'edit'
|
|
| 'install'
|
|
| 'check'
|
|
| 'test'
|
|
| 'commit'
|
|
| 'push'
|
|
| 'pr'
|
|
| 'cleanup',
|
|
message: string,
|
|
metadata?: string,
|
|
) =>
|
|
await client.mutation(api.agentJobs.appendEvent, {
|
|
workerToken: env.workerToken,
|
|
workerId: env.workerId,
|
|
jobId,
|
|
level,
|
|
phase,
|
|
message,
|
|
metadata,
|
|
});
|
|
|
|
const updateStatus = async (
|
|
jobId: Id<'agentJobs'>,
|
|
status:
|
|
| 'queued'
|
|
| 'claimed'
|
|
| 'preparing'
|
|
| 'running'
|
|
| 'checks_running'
|
|
| 'changes_ready'
|
|
| 'draft_pr_opened'
|
|
| 'failed'
|
|
| 'cancelled'
|
|
| 'timed_out',
|
|
extra?: { error?: string; summary?: string },
|
|
) =>
|
|
await client.mutation(api.agentJobs.updateStatus, {
|
|
workerToken: env.workerToken,
|
|
workerId: env.workerId,
|
|
jobId,
|
|
status,
|
|
...extra,
|
|
});
|
|
|
|
const addArtifact = async (args: {
|
|
jobId: Id<'agentJobs'>;
|
|
kind: 'plan' | 'diff' | 'test_output' | 'summary' | 'error' | 'pr_body';
|
|
title: string;
|
|
content: string;
|
|
contentType:
|
|
| 'text/markdown'
|
|
| 'text/plain'
|
|
| 'application/json'
|
|
| 'text/x-diff';
|
|
}) =>
|
|
await client.mutation(api.agentJobs.addArtifact, {
|
|
workerToken: env.workerToken,
|
|
workerId: env.workerId,
|
|
...args,
|
|
});
|
|
|
|
const completeWithDraftPr = async (args: {
|
|
jobId: Id<'agentJobs'>;
|
|
commitSha: string;
|
|
pullRequestUrl: string;
|
|
pullRequestNumber: number;
|
|
summary: string;
|
|
}) =>
|
|
await client.mutation(api.agentJobs.completeWithDraftPr, {
|
|
workerToken: env.workerToken,
|
|
workerId: env.workerId,
|
|
...args,
|
|
});
|
|
|
|
const applyMaintenanceDecision = async (
|
|
jobId: Id<'agentJobs'>,
|
|
decision: MaintenanceDecision,
|
|
) =>
|
|
await client.mutation(api.agentJobs.applyMaintenanceDecision, {
|
|
workerToken: env.workerToken,
|
|
workerId: env.workerId,
|
|
jobId,
|
|
...decision,
|
|
});
|
|
|
|
const markWorkspaceActive = async (args: {
|
|
jobId: Id<'agentJobs'>;
|
|
opencodeSessionId?: string;
|
|
containerId?: string;
|
|
workspaceUrl?: string;
|
|
}) =>
|
|
await client.mutation(api.agentJobs.markWorkspaceActive, {
|
|
workerToken: env.workerToken,
|
|
workerId: env.workerId,
|
|
workspaceExpiresAt: Date.now() + 2 * 60 * 60 * 1000,
|
|
...args,
|
|
});
|
|
|
|
const markWorkspaceStopped = async (
|
|
jobId: Id<'agentJobs'>,
|
|
workspaceStatus: 'stopped' | 'expired' | 'failed' = 'stopped',
|
|
) =>
|
|
await client.mutation(api.agentJobs.markWorkspaceStopped, {
|
|
workerToken: env.workerToken,
|
|
workerId: env.workerId,
|
|
jobId,
|
|
workspaceStatus,
|
|
});
|
|
|
|
const appendMessage = async (args: {
|
|
jobId: Id<'agentJobs'>;
|
|
role: 'user' | 'assistant' | 'system' | 'tool';
|
|
content: string;
|
|
status: 'queued' | 'streaming' | 'completed' | 'failed';
|
|
metadata?: string;
|
|
}) =>
|
|
await client.mutation(api.agentJobs.appendMessage, {
|
|
workerToken: env.workerToken,
|
|
workerId: env.workerId,
|
|
...args,
|
|
});
|
|
|
|
const updateMessage = async (args: {
|
|
messageId: Id<'agentJobMessages'>;
|
|
content?: string;
|
|
status?: 'queued' | 'streaming' | 'completed' | 'failed';
|
|
metadata?: string;
|
|
}) =>
|
|
await client.mutation(api.agentJobs.updateMessage, {
|
|
workerToken: env.workerToken,
|
|
workerId: env.workerId,
|
|
...args,
|
|
});
|
|
|
|
const setRuntimeSession = async (args: {
|
|
jobId: Id<'agentJobs'>;
|
|
agentRuntimeMode: 'opencode_server' | 'codex_exec' | 'legacy_cli';
|
|
opencodeSessionId?: string;
|
|
codexSessionId?: string;
|
|
containerId?: string;
|
|
}) =>
|
|
await client.mutation(api.agentJobs.setRuntimeSession, {
|
|
workerToken: env.workerToken,
|
|
workerId: env.workerId,
|
|
...args,
|
|
});
|
|
|
|
const setCodexSessionId = async (
|
|
jobId: Id<'agentJobs'>,
|
|
codexSessionId: string,
|
|
) =>
|
|
await client.mutation(api.agentJobs.setCodexSessionId, {
|
|
workerToken: env.workerToken,
|
|
workerId: env.workerId,
|
|
jobId,
|
|
codexSessionId,
|
|
});
|
|
|
|
const createInteractionRequest = async (args: {
|
|
jobId: Id<'agentJobs'>;
|
|
runtime: 'opencode' | 'codex';
|
|
externalRequestId: string;
|
|
kind: 'question' | 'permission' | 'tool_confirmation';
|
|
title: string;
|
|
body: string;
|
|
options?: string[];
|
|
metadata?: string;
|
|
}) =>
|
|
await client.mutation(api.agentJobs.createInteractionRequest, {
|
|
workerToken: env.workerToken,
|
|
workerId: env.workerId,
|
|
...args,
|
|
});
|
|
|
|
const patchInteractionRequest = async (args: {
|
|
interactionId: Id<'agentInteractionRequests'>;
|
|
status: 'pending' | 'answered' | 'approved' | 'rejected' | 'expired';
|
|
response?: string;
|
|
metadata?: string;
|
|
}) =>
|
|
await client.mutation(api.agentJobs.patchInteractionRequest, {
|
|
workerToken: env.workerToken,
|
|
workerId: env.workerId,
|
|
...args,
|
|
});
|
|
|
|
const recordWorkspaceChange = async (args: {
|
|
jobId: Id<'agentJobs'>;
|
|
path: string;
|
|
source: 'user' | 'agent' | 'command';
|
|
changeType: 'added' | 'modified' | 'deleted' | 'renamed';
|
|
diff?: string;
|
|
}) =>
|
|
await client.mutation(api.agentJobs.recordWorkspaceChange, {
|
|
workerToken: env.workerToken,
|
|
workerId: env.workerId,
|
|
...args,
|
|
});
|
|
|
|
const commandToShell = (command: string) => ['bash', '-lc', command];
|
|
|
|
const workspaceContainerName = (jobId: string) =>
|
|
`spoon-agent-job-${jobId.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
|
|
|
|
const isCodexLoginProfile = (claim: Claim) =>
|
|
claim.aiProviderProfile?.provider === 'opencode_openai_login' ||
|
|
claim.aiProviderProfile?.authType === 'opencode_auth_json';
|
|
|
|
const collectJsonStringValues = (value?: string): string[] => {
|
|
if (!value) return [];
|
|
try {
|
|
const parsed = JSON.parse(value) as unknown;
|
|
const values: string[] = [];
|
|
const visit = (item: unknown) => {
|
|
if (typeof item === 'string') {
|
|
if (item.length >= 12) values.push(item);
|
|
return;
|
|
}
|
|
if (Array.isArray(item)) {
|
|
item.forEach(visit);
|
|
return;
|
|
}
|
|
if (item && typeof item === 'object') {
|
|
Object.values(item).forEach(visit);
|
|
}
|
|
};
|
|
visit(parsed);
|
|
return values;
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const providerEnvironment = (
|
|
claim: Claim,
|
|
workspaceRoot?: string,
|
|
): Record<string, string> => {
|
|
if (isCodexLoginProfile(claim)) {
|
|
if (!workspaceRoot) {
|
|
throw new Error('Codex auth profiles require a prepared workspace.');
|
|
}
|
|
return {
|
|
CODEX_HOME: path.join(workspaceRoot, '.codex'),
|
|
HOME: workspaceRoot,
|
|
XDG_DATA_HOME: path.join(workspaceRoot, '.local', 'share'),
|
|
XDG_CONFIG_HOME: path.join(workspaceRoot, '.config'),
|
|
};
|
|
}
|
|
const profile = claim.aiProviderProfile;
|
|
const secret = profile?.secret ?? claim.openai.apiKey;
|
|
if (!secret) {
|
|
throw new Error('No AI provider credential is configured for this job.');
|
|
}
|
|
const baseUrl: Record<string, string> = profile?.baseUrl
|
|
? { OPENAI_BASE_URL: profile.baseUrl }
|
|
: {};
|
|
if (!profile || profile.provider === 'openai') {
|
|
return { OPENAI_API_KEY: secret, ...baseUrl };
|
|
}
|
|
if (profile.provider === 'anthropic') return { ANTHROPIC_API_KEY: secret };
|
|
if (profile.provider === 'google') return { GOOGLE_API_KEY: secret };
|
|
if (profile.provider === 'openrouter') {
|
|
return { OPENROUTER_API_KEY: secret, ...baseUrl };
|
|
}
|
|
if (profile.provider === 'requesty') {
|
|
return { REQUESTY_API_KEY: secret, ...baseUrl };
|
|
}
|
|
if (profile.provider === 'cloudflare_ai_gateway') {
|
|
return { CLOUDFLARE_API_KEY: secret, ...baseUrl };
|
|
}
|
|
if (
|
|
profile.provider === 'litellm' ||
|
|
profile.provider === 'custom_openai_compatible'
|
|
) {
|
|
return { OPENAI_API_KEY: secret, ...baseUrl };
|
|
}
|
|
throw new Error('Unsupported AI provider profile.');
|
|
};
|
|
|
|
const opencodeModel = (claim: Claim) => {
|
|
const profile = claim.aiProviderProfile;
|
|
const model = profile?.model ?? claim.openai.model;
|
|
if (model.includes('/')) return model;
|
|
if (!profile) return `openai/${model}`;
|
|
if (
|
|
profile.provider === 'custom_openai_compatible' ||
|
|
profile.provider === 'cloudflare_ai_gateway'
|
|
) {
|
|
return model;
|
|
}
|
|
if (profile.provider === 'opencode_openai_login') return `openai/${model}`;
|
|
return `${profile.provider}/${model}`;
|
|
};
|
|
|
|
const codexModel = (claim: Claim) => {
|
|
const model = claim.aiProviderProfile?.model ?? claim.openai.model;
|
|
return model.includes('/') ? (model.split('/').at(-1) ?? model) : model;
|
|
};
|
|
|
|
const codexModelArgs = (claim: Claim) =>
|
|
isCodexLoginProfile(claim) ? [] : ['--model', codexModel(claim)];
|
|
|
|
const writeJsonFile = async (filePath: string, content: string) => {
|
|
let normalized = content.trim();
|
|
try {
|
|
normalized = `${JSON.stringify(JSON.parse(normalized), null, 2)}\n`;
|
|
} catch {
|
|
throw new Error('Codex auth JSON is not valid JSON.');
|
|
}
|
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
await writeFile(filePath, normalized, { mode: 0o600 });
|
|
};
|
|
|
|
const prepareCodexAuth = async (workspace: ActiveWorkspace) => {
|
|
const secret = workspace.claim.aiProviderProfile?.secret;
|
|
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);
|
|
|
|
// Also seed OpenCode's auth location with the saved JSON for forward
|
|
// compatibility if this profile later runs through OpenCode directly.
|
|
const openCodeAuthPath = path.join(
|
|
workspace.workdir,
|
|
'.local',
|
|
'share',
|
|
'opencode',
|
|
'auth.json',
|
|
);
|
|
await writeJsonFile(openCodeAuthPath, secret);
|
|
|
|
await appendEvent(
|
|
workspace.claim.job._id,
|
|
'info',
|
|
'clone',
|
|
'Prepared Codex auth JSON for the isolated workspace.',
|
|
);
|
|
};
|
|
|
|
const agentFailurePrefix = (claim: Claim) =>
|
|
isCodexLoginProfile(claim) ? 'codex failed' : 'opencode failed';
|
|
|
|
const handleAgentEvent = async (args: {
|
|
workspace: ActiveWorkspace;
|
|
event: NormalizedAgentEvent;
|
|
assistantMessageId: Id<'agentJobMessages'>;
|
|
assistantContent: { value: string };
|
|
}) => {
|
|
const { workspace, event, assistantMessageId, assistantContent } = args;
|
|
const jobId = workspace.claim.job._id;
|
|
if (event.kind === 'assistant_delta') {
|
|
assistantContent.value = truncate(
|
|
`${assistantContent.value}${event.content}`,
|
|
40_000,
|
|
);
|
|
await updateMessage({
|
|
messageId: assistantMessageId,
|
|
content: assistantContent.value,
|
|
status: 'streaming',
|
|
metadata: event.externalMessageId
|
|
? JSON.stringify({ externalMessageId: event.externalMessageId })
|
|
: undefined,
|
|
});
|
|
return;
|
|
}
|
|
if (event.kind === 'assistant_completed') {
|
|
workspace.agentTurnActive = false;
|
|
workspace.resolveTurn?.();
|
|
workspace.resolveTurn = undefined;
|
|
if (event.content) {
|
|
assistantContent.value = truncate(
|
|
`${assistantContent.value}${event.content}`,
|
|
40_000,
|
|
);
|
|
}
|
|
await updateMessage({
|
|
messageId: assistantMessageId,
|
|
content: assistantContent.value,
|
|
status: 'completed',
|
|
});
|
|
return;
|
|
}
|
|
if (event.kind === 'session') {
|
|
if (workspace.runtimeMode === 'codex_exec') {
|
|
workspace.codexSessionId = event.sessionId;
|
|
await setCodexSessionId(jobId, event.sessionId);
|
|
}
|
|
return;
|
|
}
|
|
if (event.kind === 'tool_started' || event.kind === 'tool_completed') {
|
|
const detail = event.kind === 'tool_started' ? event.input : event.output;
|
|
await appendMessage({
|
|
jobId,
|
|
role: 'tool',
|
|
status: event.kind === 'tool_started' ? 'streaming' : 'completed',
|
|
content: truncate(
|
|
`${event.name}${detail ? `\n\n${detail}` : ''}`,
|
|
20_000,
|
|
),
|
|
metadata: JSON.stringify({
|
|
kind: event.kind,
|
|
externalMessageId: event.externalMessageId,
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
if (event.kind === 'file_edited') {
|
|
const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);
|
|
await recordWorkspaceChange({
|
|
jobId,
|
|
path: event.path,
|
|
source: 'agent',
|
|
changeType: await fileChangedType(workspace.repoDir, event.path),
|
|
diff: truncate(diff.output, 50_000),
|
|
});
|
|
await appendEvent(jobId, 'info', 'edit', `Agent edited ${event.path}.`);
|
|
return;
|
|
}
|
|
if (event.kind === 'command_executed') {
|
|
await appendEvent(
|
|
jobId,
|
|
event.exitCode && event.exitCode !== 0 ? 'warn' : 'info',
|
|
'check',
|
|
event.command,
|
|
event.output ? truncate(event.output, 10_000) : undefined,
|
|
);
|
|
return;
|
|
}
|
|
if (
|
|
event.kind === 'permission_requested' ||
|
|
event.kind === 'question_requested'
|
|
) {
|
|
await createInteractionRequest({
|
|
jobId,
|
|
runtime: workspace.runtimeMode === 'codex_exec' ? 'codex' : 'opencode',
|
|
externalRequestId: event.externalRequestId,
|
|
kind: event.kind === 'permission_requested' ? 'permission' : 'question',
|
|
title: event.title,
|
|
body: truncate(event.body, 20_000),
|
|
options: event.kind === 'question_requested' ? event.options : undefined,
|
|
metadata: event.metadata,
|
|
});
|
|
await appendMessage({
|
|
jobId,
|
|
role: 'system',
|
|
status: 'completed',
|
|
content: `${event.title}\n\n${truncate(event.body, 20_000)}`,
|
|
metadata: JSON.stringify({ kind: event.kind }),
|
|
});
|
|
return;
|
|
}
|
|
if (event.kind === 'status') {
|
|
await appendEvent(
|
|
jobId,
|
|
'debug',
|
|
'plan',
|
|
event.status,
|
|
event.metadata ? truncate(event.metadata, 10_000) : undefined,
|
|
);
|
|
return;
|
|
}
|
|
// event.kind === 'error'
|
|
// Record the real Codex failure reason on the workspace so the turn can
|
|
// surface it (Codex can emit `error`/`turn.failed` events and still exit 0
|
|
// in some versions, which otherwise looks like an empty response).
|
|
workspace.codexTurnError = event.message;
|
|
await appendEvent(jobId, 'error', 'plan', truncate(event.message, 20_000));
|
|
};
|
|
|
|
const ensureOpenCodeSession = async (workspace: ActiveWorkspace) => {
|
|
if (workspace.opencodeSession) return workspace.opencodeSession;
|
|
const containerName = workspaceContainerName(workspace.claim.job._id);
|
|
const password = randomBytes(24).toString('hex');
|
|
const aiEnv = providerEnvironment(workspace.claim);
|
|
const secretEnv = Object.fromEntries(
|
|
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
|
|
);
|
|
const container = await startWorkspaceContainer({
|
|
workdir: workspace.workdir,
|
|
containerHome: workspace.containerHome,
|
|
containerCwd: workspace.containerRepo,
|
|
containerName,
|
|
environment: {
|
|
...aiEnv,
|
|
...secretEnv,
|
|
OPENCODE_SERVER_PASSWORD: password,
|
|
OPENCODE_SERVER_USERNAME: 'opencode',
|
|
},
|
|
command: ['opencode', 'serve', '--hostname', '0.0.0.0', '--port', '4096'],
|
|
publishTcpPort: env.containerAccess === 'host_port' ? 4096 : undefined,
|
|
});
|
|
const baseUrl =
|
|
env.containerAccess === 'host_port'
|
|
? `http://127.0.0.1:${container.hostPort}`
|
|
: `http://${containerName}:4096`;
|
|
workspace.containerName = container.containerName;
|
|
workspace.containerId = container.containerId;
|
|
workspace.opencodePassword = password;
|
|
workspace.runtimeMode = 'opencode_server';
|
|
await setRuntimeSession({
|
|
jobId: workspace.claim.job._id,
|
|
agentRuntimeMode: 'opencode_server',
|
|
containerId: container.containerId,
|
|
});
|
|
let lastError: unknown;
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
try {
|
|
const session = await createOpenCodeSession({
|
|
baseUrl,
|
|
password,
|
|
directory: workspace.containerRepo,
|
|
title: workspace.claim.job.prompt.slice(0, 80) || 'Spoon workspace',
|
|
onEvent: async (event) => {
|
|
const messageId = workspaceCurrentMessage.get(
|
|
workspace.claim.job._id,
|
|
);
|
|
if (!messageId) return;
|
|
await handleAgentEvent({
|
|
workspace,
|
|
event,
|
|
assistantMessageId: messageId,
|
|
assistantContent: workspaceCurrentContent.get(
|
|
workspace.claim.job._id,
|
|
) ?? {
|
|
value: '',
|
|
},
|
|
});
|
|
},
|
|
});
|
|
workspace.opencodeSession = session;
|
|
await setRuntimeSession({
|
|
jobId: workspace.claim.job._id,
|
|
agentRuntimeMode: 'opencode_server',
|
|
opencodeSessionId: session.sessionId,
|
|
containerId: container.containerId,
|
|
});
|
|
return session;
|
|
} catch (error) {
|
|
lastError = error;
|
|
await sleep(500);
|
|
}
|
|
}
|
|
throw lastError instanceof Error
|
|
? lastError
|
|
: new Error('OpenCode server did not become ready.');
|
|
};
|
|
|
|
const workspaceCurrentMessage = new Map<string, Id<'agentJobMessages'>>();
|
|
const workspaceCurrentContent = new Map<
|
|
string,
|
|
{
|
|
value: string;
|
|
}
|
|
>();
|
|
|
|
// Reading through a function boundary prevents TypeScript from narrowing the
|
|
// field to `undefined` after the synchronous reset in `runCodexTurn`; it is set
|
|
// asynchronously by the stream event handler.
|
|
const readCodexTurnError = (workspace: ActiveWorkspace) =>
|
|
workspace.codexTurnError;
|
|
|
|
const runCodexTurn = async (args: {
|
|
workspace: ActiveWorkspace;
|
|
prompt: string;
|
|
assistantMessageId: Id<'agentJobMessages'>;
|
|
assistantContent: { value: string };
|
|
}) => {
|
|
const { workspace, prompt, assistantMessageId, assistantContent } = args;
|
|
workspace.runtimeMode = 'codex_exec';
|
|
workspace.codexTurnError = undefined;
|
|
await setRuntimeSession({
|
|
jobId: workspace.claim.job._id,
|
|
agentRuntimeMode: 'codex_exec',
|
|
codexSessionId: workspace.codexSessionId,
|
|
});
|
|
const outputFileName = `last-message-${workspace.claim.job._id}.txt`;
|
|
const outputFileHostPath = path.join(
|
|
workspace.workdir,
|
|
'.codex',
|
|
outputFileName,
|
|
);
|
|
const outputFileContainerPath = path.posix.join(
|
|
workspace.containerHome,
|
|
'.codex',
|
|
outputFileName,
|
|
);
|
|
const command = workspace.codexSessionId
|
|
? [
|
|
'codex',
|
|
'exec',
|
|
'resume',
|
|
'--json',
|
|
...codexModelArgs(workspace.claim),
|
|
'--dangerously-bypass-approvals-and-sandbox',
|
|
'--output-last-message',
|
|
outputFileContainerPath,
|
|
workspace.codexSessionId,
|
|
prompt,
|
|
]
|
|
: [
|
|
'codex',
|
|
'exec',
|
|
'--json',
|
|
...codexModelArgs(workspace.claim),
|
|
'--dangerously-bypass-approvals-and-sandbox',
|
|
'--output-last-message',
|
|
outputFileContainerPath,
|
|
'--cd',
|
|
workspace.containerRepo,
|
|
prompt,
|
|
];
|
|
const aiEnv = providerEnvironment(workspace.claim, workspace.containerHome);
|
|
const secretEnv = Object.fromEntries(
|
|
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
|
|
);
|
|
const result = await streamExecInContainer({
|
|
containerName: workspace.boxName,
|
|
containerCwd: workspace.containerRepo,
|
|
command,
|
|
environment: {
|
|
...aiEnv,
|
|
...secretEnv,
|
|
},
|
|
redact: workspace.redact,
|
|
timeoutMs: env.jobTimeoutMs,
|
|
onStdoutLine: async (line) => {
|
|
for (const event of normalizeCodexJsonLine(line)) {
|
|
await handleAgentEvent({
|
|
workspace,
|
|
event,
|
|
assistantMessageId,
|
|
assistantContent,
|
|
});
|
|
}
|
|
},
|
|
onStderrLine: async (line) => {
|
|
const trimmed = line.trim();
|
|
if (
|
|
trimmed &&
|
|
trimmed !== 'Reading additional input from stdin...' &&
|
|
!trimmed.includes('`[features].codex_hooks` is deprecated')
|
|
) {
|
|
await appendEvent(
|
|
workspace.claim.job._id,
|
|
'info',
|
|
'plan',
|
|
truncate(trimmed, 10_000),
|
|
);
|
|
}
|
|
},
|
|
});
|
|
await appendEvent(
|
|
workspace.claim.job._id,
|
|
'info',
|
|
'plan',
|
|
`Codex CLI exited with code ${result.exitCode}; captured output length ${result.output.length}; assistant length ${assistantContent.value.length}.`,
|
|
);
|
|
if (result.exitCode !== 0) {
|
|
throw new Error(`codex failed:\n${result.output}`);
|
|
}
|
|
if (!assistantContent.value.trim()) {
|
|
try {
|
|
const lastMessage = await readFile(outputFileHostPath, 'utf8');
|
|
if (lastMessage.trim()) {
|
|
assistantContent.value = truncate(
|
|
workspace.redact(lastMessage.trim()),
|
|
40_000,
|
|
);
|
|
await updateMessage({
|
|
messageId: assistantMessageId,
|
|
content: assistantContent.value,
|
|
status: 'streaming',
|
|
});
|
|
await appendEvent(
|
|
workspace.claim.job._id,
|
|
'info',
|
|
'plan',
|
|
`Recovered assistant response from Codex output file ${outputFileName}.`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
const code = error && typeof error === 'object' ? 'code' in error : false;
|
|
if (!code || (error as { code?: string }).code !== 'ENOENT') {
|
|
throw error;
|
|
}
|
|
await appendEvent(
|
|
workspace.claim.job._id,
|
|
'warn',
|
|
'plan',
|
|
`Codex output file ${outputFileName} was not created.`,
|
|
);
|
|
}
|
|
}
|
|
// Codex can report a failure via a JSON `error`/`turn.failed` event while
|
|
// still exiting 0. If the turn produced no assistant text but did report an
|
|
// error, surface that real reason rather than a generic empty response.
|
|
// Read through a helper so it is not narrowed away by the reset above (the
|
|
// field is mutated asynchronously inside the stream handler).
|
|
const codexTurnError = readCodexTurnError(workspace);
|
|
if (!assistantContent.value.trim() && codexTurnError) {
|
|
throw new Error(`codex failed:\n${codexTurnError}`);
|
|
}
|
|
};
|
|
|
|
const runOpenCodeTurn = async (args: {
|
|
workspace: ActiveWorkspace;
|
|
prompt: string;
|
|
assistantMessageId: Id<'agentJobMessages'>;
|
|
assistantContent: { value: string };
|
|
}) => {
|
|
const { workspace, prompt, assistantMessageId, assistantContent } = args;
|
|
workspaceCurrentMessage.set(workspace.claim.job._id, assistantMessageId);
|
|
workspaceCurrentContent.set(workspace.claim.job._id, assistantContent);
|
|
const session = await ensureOpenCodeSession(workspace);
|
|
const turnDone = new Promise<void>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
workspace.resolveTurn = undefined;
|
|
reject(new Error('OpenCode turn timed out.'));
|
|
}, env.jobTimeoutMs);
|
|
workspace.resolveTurn = () => {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
};
|
|
});
|
|
await promptOpenCodeSession({
|
|
session,
|
|
prompt,
|
|
model: opencodeModel(workspace.claim),
|
|
directory: workspace.containerRepo,
|
|
});
|
|
await turnDone;
|
|
};
|
|
|
|
const systemPromptForJob = (claim: Claim) => {
|
|
const base = [
|
|
`Spoon: ${claim.spoon.name}`,
|
|
`Fork: ${claim.job.forkOwner}/${claim.job.forkRepo}`,
|
|
`Upstream: ${claim.job.upstreamOwner}/${claim.job.upstreamRepo}`,
|
|
`Selected secret names: ${claim.secrets.map((secret) => secret.name).join(', ') || 'none'}`,
|
|
].join('\n');
|
|
if (claim.job.jobType === 'maintenance_review') {
|
|
return `${base}
|
|
|
|
You are reviewing upstream changes for a maintained fork.
|
|
Determine whether the upstream commits can be safely applied.
|
|
If the fork has no relevant customizations, recommend sync.
|
|
If upstream changes are irrelevant to this fork, recommend ignore and list commit SHAs.
|
|
If changes may affect custom fork commits, explain risks and recommend review PR or manual review.
|
|
Do not claim tests passed unless commands were run.
|
|
End with a JSON maintenance decision in this exact shape:
|
|
{
|
|
"decision": "sync" | "ignore" | "open_review_pr" | "manual_review" | "conflict_resolution" | "unknown",
|
|
"risk": "low" | "medium" | "high" | "unknown",
|
|
"summary": "string",
|
|
"ignoredCommitShas": ["string"],
|
|
"ignoredReason": "string",
|
|
"recommendedAction": "string",
|
|
"requiresUserApproval": true
|
|
}
|
|
|
|
User/system request:
|
|
${claim.job.prompt}`;
|
|
}
|
|
if (claim.job.jobType === 'conflict_resolution') {
|
|
return `${base}
|
|
|
|
You are resolving upstream merge conflicts in a maintained fork.
|
|
Preserve fork customizations unless the user explicitly removed that upstream behavior.
|
|
Prefer small, reviewable changes.
|
|
Produce a draft PR rather than committing to main.
|
|
|
|
Request:
|
|
${claim.job.prompt}`;
|
|
}
|
|
return `${base}
|
|
|
|
You are working on a maintained fork managed by Spoon.
|
|
Make the requested change only.
|
|
Preserve fork-specific customizations.
|
|
Do not commit secrets.
|
|
Use selected environment variables only for running/building/testing.
|
|
Open a draft PR only when instructed by Spoon.
|
|
|
|
Request:
|
|
${claim.job.prompt}`;
|
|
};
|
|
|
|
type MaintenanceDecision = {
|
|
decision:
|
|
| 'sync'
|
|
| 'ignore'
|
|
| 'open_review_pr'
|
|
| 'manual_review'
|
|
| 'conflict_resolution'
|
|
| 'unknown';
|
|
risk: 'low' | 'medium' | 'high' | 'unknown';
|
|
summary: string;
|
|
ignoredCommitShas: string[];
|
|
ignoredReason: string;
|
|
recommendedAction: string;
|
|
requiresUserApproval: boolean;
|
|
};
|
|
|
|
const parseMaintenanceDecision = (
|
|
output: string,
|
|
): MaintenanceDecision | null => {
|
|
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(output)?.[1];
|
|
const candidates = [fenced, output.slice(output.indexOf('{'))].filter(
|
|
Boolean,
|
|
) as string[];
|
|
for (const candidate of candidates) {
|
|
try {
|
|
const parsed = JSON.parse(
|
|
candidate.trim(),
|
|
) as Partial<MaintenanceDecision>;
|
|
if (
|
|
parsed.decision &&
|
|
parsed.risk &&
|
|
typeof parsed.summary === 'string'
|
|
) {
|
|
return {
|
|
decision: parsed.decision,
|
|
risk: parsed.risk,
|
|
summary: parsed.summary,
|
|
ignoredCommitShas: Array.isArray(parsed.ignoredCommitShas)
|
|
? parsed.ignoredCommitShas.filter(
|
|
(sha): sha is string => typeof sha === 'string',
|
|
)
|
|
: [],
|
|
ignoredReason:
|
|
typeof parsed.ignoredReason === 'string'
|
|
? parsed.ignoredReason
|
|
: '',
|
|
recommendedAction:
|
|
typeof parsed.recommendedAction === 'string'
|
|
? parsed.recommendedAction
|
|
: parsed.summary,
|
|
requiresUserApproval: parsed.requiresUserApproval ?? true,
|
|
};
|
|
}
|
|
} catch {
|
|
// Try the next candidate.
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const fileExists = async (filePath: string) => {
|
|
try {
|
|
await access(filePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const runProjectCommand = async (args: {
|
|
command: string;
|
|
phase: 'install' | 'check' | 'test';
|
|
claim: Claim;
|
|
boxName: string;
|
|
containerHome: string;
|
|
containerCwd: string;
|
|
repoDir: string;
|
|
redact: (value: string) => string;
|
|
}) => {
|
|
await appendEvent(args.claim.job._id, 'info', args.phase, args.command);
|
|
const secretEnv = Object.fromEntries(
|
|
args.claim.secrets.map((secret) => [secret.name, secret.value]),
|
|
);
|
|
const result =
|
|
env.runtime === 'docker'
|
|
? await runExecInContainer({
|
|
containerName: args.boxName,
|
|
command: commandToShell(args.command),
|
|
containerCwd: args.containerCwd,
|
|
environment: { HOME: args.containerHome, ...secretEnv },
|
|
redact: args.redact,
|
|
timeoutMs: env.jobTimeoutMs,
|
|
})
|
|
: await run('bash', ['-lc', args.command], {
|
|
cwd: args.repoDir,
|
|
env: secretEnv,
|
|
redact: args.redact,
|
|
timeoutMs: env.jobTimeoutMs,
|
|
});
|
|
await addArtifact({
|
|
jobId: args.claim.job._id,
|
|
kind: args.phase === 'test' ? 'test_output' : 'summary',
|
|
title: args.command,
|
|
content: truncate(result.output, 100_000),
|
|
contentType: 'text/plain',
|
|
});
|
|
if (result.exitCode !== 0) {
|
|
throw new Error(`${args.command} failed:\n${result.output}`);
|
|
}
|
|
};
|
|
|
|
const quoteShell = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
|
|
const resolveWorkspace = (jobId: string) => {
|
|
const workspace = activeWorkspaces.get(jobId);
|
|
if (!workspace) {
|
|
throw new Error('Agent workspace is not active on this worker.');
|
|
}
|
|
return workspace;
|
|
};
|
|
|
|
const safeWorkspacePath = (repoDir: string, filePath: string) => {
|
|
const resolved = path.resolve(repoDir, filePath);
|
|
const root = path.resolve(repoDir);
|
|
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
|
|
throw new Error(`Refusing to access path outside repository: ${filePath}`);
|
|
}
|
|
return resolved;
|
|
};
|
|
|
|
const fileChangedType = async (repoDir: string, filePath: string) => {
|
|
const status = await run('git', ['status', '--short', '--', filePath], {
|
|
cwd: repoDir,
|
|
redact: (value) => value,
|
|
timeoutMs: 60_000,
|
|
});
|
|
const code = status.output.trim().slice(0, 2);
|
|
if (code.includes('D')) return 'deleted' as const;
|
|
if (code.includes('A') || code.includes('?')) return 'added' as const;
|
|
if (code.includes('R')) return 'renamed' as const;
|
|
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,
|
|
);
|
|
const signature = JSON.stringify({ diff, changes });
|
|
if (signature === workspace.lastRecordedDiffSignature) return;
|
|
workspace.lastRecordedDiffSignature = signature;
|
|
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;
|
|
const envPath = safeWorkspacePath(repoDir, claim.job.envFilePath);
|
|
await mkdir(path.dirname(envPath), { recursive: true });
|
|
const content = `${claim.secrets
|
|
.map((secret) => `${secret.name}=${JSON.stringify(secret.value)}`)
|
|
.join('\n')}\n`;
|
|
await writeFile(envPath, content);
|
|
await appendEvent(
|
|
claim.job._id,
|
|
'info',
|
|
'clone',
|
|
`Materialized selected secrets into ${claim.job.envFilePath}.`,
|
|
);
|
|
};
|
|
|
|
const detectPackageCommands = async (
|
|
repoDir: string,
|
|
): Promise<{
|
|
packageManager?: string;
|
|
scripts?: string[];
|
|
install?: string;
|
|
check?: string;
|
|
test?: string;
|
|
build?: string;
|
|
}> => {
|
|
const packageJsonPath = path.join(repoDir, 'package.json');
|
|
try {
|
|
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as {
|
|
packageManager?: string;
|
|
scripts?: Record<string, string>;
|
|
};
|
|
const scripts = packageJson.scripts ?? {};
|
|
const declaredPackageManager = packageJson.packageManager?.split('@')[0];
|
|
const detectedPackageManager = (await fileExists(
|
|
path.join(repoDir, 'bun.lock'),
|
|
))
|
|
? 'bun'
|
|
: (await fileExists(path.join(repoDir, 'pnpm-lock.yaml')))
|
|
? 'pnpm'
|
|
: (await fileExists(path.join(repoDir, 'yarn.lock')))
|
|
? 'yarn'
|
|
: 'npm';
|
|
const packageManager = declaredPackageManager ?? detectedPackageManager;
|
|
const runScript = (script: string) =>
|
|
packageManager === 'npm'
|
|
? `npm run ${script}`
|
|
: `${packageManager} run ${script}`;
|
|
|
|
return {
|
|
packageManager,
|
|
scripts: Object.keys(scripts).sort(),
|
|
install: `${packageManager} install`,
|
|
check: scripts.typecheck
|
|
? runScript('typecheck')
|
|
: scripts.lint
|
|
? runScript('lint')
|
|
: undefined,
|
|
test: scripts.test
|
|
? packageManager === 'npm'
|
|
? 'npm test'
|
|
: `${packageManager} test`
|
|
: undefined,
|
|
build: scripts.build ? runScript('build') : undefined,
|
|
};
|
|
} catch {
|
|
return {};
|
|
}
|
|
};
|
|
|
|
const buildPrBody = (args: {
|
|
prompt: string;
|
|
summary: string;
|
|
commands: string[];
|
|
limitations: string[];
|
|
}) => `## Spoon agent request
|
|
|
|
${args.prompt}
|
|
|
|
## Summary
|
|
|
|
${args.summary}
|
|
|
|
## Validation
|
|
|
|
${
|
|
args.commands.length
|
|
? args.commands.map((command) => `- \`${command}\``).join('\n')
|
|
: '- No validation commands were requested by the agent.'
|
|
}
|
|
|
|
## Limitations
|
|
|
|
${
|
|
args.limitations.length
|
|
? args.limitations.map((item) => `- ${item}`).join('\n')
|
|
: '- No limitations reported.'
|
|
}
|
|
|
|
Generated by Spoon.`;
|
|
|
|
const ensureNoEnvFilesStaged = async (workspace: ActiveWorkspace) => {
|
|
const status = await run('git', ['status', '--short'], {
|
|
cwd: workspace.repoDir,
|
|
redact: workspace.redact,
|
|
timeoutMs: 60_000,
|
|
});
|
|
const envLine = status.output
|
|
.split('\n')
|
|
.find((line) => /\s\.env(?:$|[./-])/.test(line));
|
|
if (envLine) {
|
|
throw new Error(`Refusing to commit env file changes: ${envLine.trim()}`);
|
|
}
|
|
};
|
|
|
|
const slugify = (value: string) =>
|
|
value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._-]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.replace(/-{2,}/g, '-') || 'x';
|
|
|
|
export const planWorkdirCleanup = (args: {
|
|
root: string;
|
|
rootEntries: { name: string; isDirectory: boolean }[];
|
|
homesEntries: Record<string, { name: string; isDirectory: boolean }[]>;
|
|
codeLeaves: Record<string, string[]>;
|
|
activeWorkdirs: Set<string>;
|
|
runningBoxUsernames: Set<string>;
|
|
}): { removeDirs: string[] } => {
|
|
const removeDirs: string[] = [];
|
|
for (const entry of args.rootEntries) {
|
|
if (!entry.isDirectory || entry.name.startsWith('.')) continue;
|
|
if (entry.name === 'homes') continue;
|
|
const abs = path.resolve(args.root, entry.name);
|
|
if (args.activeWorkdirs.has(abs)) continue;
|
|
removeDirs.push(abs);
|
|
}
|
|
for (const [username, leaves] of Object.entries(args.codeLeaves)) {
|
|
if (args.runningBoxUsernames.has(username)) continue;
|
|
for (const leaf of leaves) removeDirs.push(leaf);
|
|
}
|
|
return { removeDirs };
|
|
};
|
|
|
|
const runClaim = async (claim: Claim) => {
|
|
const jobId = claim.job._id;
|
|
const secretValues = [
|
|
claim.openai.apiKey ?? '',
|
|
claim.aiProviderProfile?.secret ?? '',
|
|
...collectJsonStringValues(claim.aiProviderProfile?.secret),
|
|
...claim.secrets.map((secret) => secret.value),
|
|
].filter(Boolean);
|
|
const redact = createRedactor(secretValues);
|
|
let acquiredBoxUser: string | undefined;
|
|
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) {
|
|
throw new Error('GitHub installation ID is missing.');
|
|
}
|
|
const githubToken = await getInstallationToken(claim.github.installationId);
|
|
|
|
// Resolve the persistent per-user home and lay the checkout out as
|
|
// ~/Code/{spoon}/{branch} inside it, so dotfiles/tools persist and every
|
|
// thread shows up as a folder in one home.
|
|
const userEnv = await fetchUserEnvironment(jobId);
|
|
const username = userEnv?.username ?? 'user';
|
|
const homeDir = path.resolve(env.workdir, 'homes', username);
|
|
const containerHome = path.posix.join('/home', username);
|
|
const spoonSlug = slugify(claim.spoon.name);
|
|
const branchSlug = slugify(claim.job.workBranch);
|
|
const checkoutParent = path.join(homeDir, 'Code', spoonSlug);
|
|
const containerRepo = path.posix.join(
|
|
containerHome,
|
|
'Code',
|
|
spoonSlug,
|
|
branchSlug,
|
|
);
|
|
|
|
// Start (or reuse) the persistent per-user box that this thread — and the
|
|
// terminal — exec into. It mounts the home, so the clone below is visible.
|
|
const boxName = await acquireUserBox({
|
|
username,
|
|
workdir: homeDir,
|
|
containerHome,
|
|
});
|
|
acquiredBoxUser = username;
|
|
|
|
const repoDir = await cloneRepository({
|
|
workdir: checkoutParent,
|
|
dirName: branchSlug,
|
|
token: githubToken,
|
|
owner: claim.job.forkOwner,
|
|
repo: claim.job.forkRepo,
|
|
baseBranch: claim.job.baseBranch,
|
|
workBranch: claim.job.workBranch,
|
|
redact,
|
|
timeoutMs: env.jobTimeoutMs,
|
|
});
|
|
const workspace: ActiveWorkspace = {
|
|
claim,
|
|
workdir: homeDir,
|
|
homeDir,
|
|
username,
|
|
containerHome,
|
|
containerRepo,
|
|
repoDir,
|
|
boxName,
|
|
githubToken,
|
|
redact,
|
|
};
|
|
if (userEnv) {
|
|
await appendEvent(
|
|
jobId,
|
|
'info',
|
|
'clone',
|
|
'Applying your dotfiles and environment.',
|
|
);
|
|
await materializeUserHome({
|
|
homeDir,
|
|
containerHome,
|
|
boxName,
|
|
userEnv,
|
|
redact,
|
|
});
|
|
}
|
|
if (isCodexLoginProfile(claim)) {
|
|
await prepareCodexAuth(workspace);
|
|
}
|
|
await materializeEnvFile(workspace);
|
|
const detected = await detectPackageCommands(repoDir);
|
|
await addArtifact({
|
|
jobId,
|
|
kind: 'summary',
|
|
title: 'Detected project commands',
|
|
content: JSON.stringify(detected, null, 2),
|
|
contentType: 'application/json',
|
|
});
|
|
activeWorkspaces.set(jobId, workspace);
|
|
await markWorkspaceActive({ jobId });
|
|
await updateStatus(jobId, 'running', {
|
|
summary: 'Workspace is active.',
|
|
});
|
|
await appendMessage({
|
|
jobId,
|
|
role: 'system',
|
|
status: 'completed',
|
|
content:
|
|
'Workspace is ready. You can browse files, edit manually, run commands, or send messages to the agent.',
|
|
});
|
|
await appendEvent(jobId, 'info', 'plan', 'Interactive workspace is ready.');
|
|
await appendEvent(
|
|
jobId,
|
|
'info',
|
|
'plan',
|
|
`Worker runtime ${env.workerId} build ${env.buildSha} (${env.buildCreatedAt}).`,
|
|
);
|
|
|
|
await sendWorkspaceMessage(jobId, systemPromptForJob(claim), {
|
|
recordUserMessage: false,
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
await appendEvent(
|
|
jobId,
|
|
'error',
|
|
'cleanup',
|
|
truncate(redact(message), 20_000),
|
|
);
|
|
await addArtifact({
|
|
jobId,
|
|
kind: 'error',
|
|
title: 'Failure',
|
|
content: truncate(redact(message), 50_000),
|
|
contentType: 'text/plain',
|
|
});
|
|
await updateStatus(
|
|
jobId,
|
|
message.toLowerCase().includes('timed out') ? 'timed_out' : 'failed',
|
|
{ error: truncate(redact(message), 10_000) },
|
|
);
|
|
await markWorkspaceStopped(
|
|
jobId,
|
|
message.toLowerCase().includes('timed out') ? 'expired' : 'failed',
|
|
).catch((stopError: unknown) => {
|
|
console.error(stopError);
|
|
});
|
|
if (acquiredBoxUser) releaseUserBox(acquiredBoxUser);
|
|
}
|
|
};
|
|
|
|
export const listWorkspaceTree = async (jobId: string) => {
|
|
const workspace = resolveWorkspace(jobId);
|
|
const buildNode = async (
|
|
absolutePath: string,
|
|
relativePath: string,
|
|
): Promise<FileTreeNode | null> => {
|
|
const basename = path.basename(absolutePath);
|
|
if (['.git', 'node_modules', '.next', 'dist', 'build'].includes(basename)) {
|
|
return null;
|
|
}
|
|
const stats = await stat(absolutePath);
|
|
if (stats.isDirectory()) {
|
|
const entries = await readdir(absolutePath);
|
|
const children = (
|
|
await Promise.all(
|
|
entries
|
|
.sort((a, b) => a.localeCompare(b))
|
|
.map((entry) =>
|
|
buildNode(
|
|
path.join(absolutePath, entry),
|
|
path.join(relativePath, entry),
|
|
),
|
|
),
|
|
)
|
|
).filter((node): node is FileTreeNode => Boolean(node));
|
|
return {
|
|
name: relativePath ? basename : workspace.claim.job.forkRepo,
|
|
path: relativePath,
|
|
type: 'directory',
|
|
children,
|
|
};
|
|
}
|
|
return { name: basename, path: relativePath, type: 'file' };
|
|
};
|
|
return await buildNode(workspace.repoDir, '');
|
|
};
|
|
|
|
export const readWorkspaceFile = async (jobId: string, filePath: string) => {
|
|
const workspace = resolveWorkspace(jobId);
|
|
const target = safeWorkspacePath(workspace.repoDir, filePath);
|
|
return await readFile(target, 'utf8');
|
|
};
|
|
|
|
export const writeWorkspaceFile = async (
|
|
jobId: string,
|
|
filePath: string,
|
|
content: string,
|
|
) => {
|
|
const workspace = resolveWorkspace(jobId);
|
|
const target = safeWorkspacePath(workspace.repoDir, filePath);
|
|
await mkdir(path.dirname(target), { recursive: true });
|
|
await writeFile(target, content);
|
|
const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);
|
|
await recordWorkspaceChange({
|
|
jobId: workspace.claim.job._id,
|
|
path: filePath,
|
|
source: 'user',
|
|
changeType: await fileChangedType(workspace.repoDir, filePath),
|
|
diff: truncate(diff.output, 50_000),
|
|
});
|
|
await appendEvent(
|
|
workspace.claim.job._id,
|
|
'info',
|
|
'edit',
|
|
`Saved ${filePath}.`,
|
|
);
|
|
return { success: true };
|
|
};
|
|
|
|
export const getWorkspaceDiff = async (jobId: string) => {
|
|
const workspace = resolveWorkspace(jobId);
|
|
const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);
|
|
return diff.output;
|
|
};
|
|
|
|
export const runWorkspaceCommand = async (jobId: string, command: string) => {
|
|
const workspace = resolveWorkspace(jobId);
|
|
await updateStatus(workspace.claim.job._id, 'checks_running');
|
|
await runProjectCommand({
|
|
command,
|
|
phase: command.includes('test') ? 'test' : 'check',
|
|
claim: workspace.claim,
|
|
boxName: workspace.boxName,
|
|
containerHome: workspace.containerHome,
|
|
containerCwd: workspace.containerRepo,
|
|
repoDir: workspace.repoDir,
|
|
redact: workspace.redact,
|
|
});
|
|
await updateStatus(workspace.claim.job._id, 'running');
|
|
await recordChangedFiles(
|
|
workspace,
|
|
'command',
|
|
(await getWorktreeDiff(workspace.repoDir, workspace.redact)).output,
|
|
);
|
|
return { success: true };
|
|
};
|
|
|
|
// Non-throwing accessor for the interactive terminal: returns the active
|
|
// workspace's mount info, or null when the workspace is not active here.
|
|
export const getTerminalWorkspace = (jobId: string) => {
|
|
const workspace = activeWorkspaces.get(jobId);
|
|
if (!workspace) return null;
|
|
return {
|
|
workdir: workspace.workdir,
|
|
containerHome: workspace.containerHome,
|
|
containerRepo: workspace.containerRepo,
|
|
username: workspace.username,
|
|
secrets: workspace.claim.secrets,
|
|
};
|
|
};
|
|
|
|
export const getWorkspaceAgentStatus = (jobId: string) => {
|
|
const workspace = resolveWorkspace(jobId);
|
|
return {
|
|
runtimeMode: workspace.runtimeMode ?? 'legacy_cli',
|
|
opencodeSessionId: workspace.opencodeSession?.sessionId,
|
|
codexSessionId: workspace.codexSessionId,
|
|
containerId: workspace.containerId,
|
|
active: Boolean(workspace.agentTurnActive),
|
|
};
|
|
};
|
|
|
|
export const abortWorkspaceAgent = async (jobId: string) => {
|
|
const workspace = resolveWorkspace(jobId);
|
|
if (workspace.opencodeSession) {
|
|
await abortOpenCodeSession(workspace.opencodeSession);
|
|
workspace.agentTurnActive = false;
|
|
workspace.resolveTurn?.();
|
|
workspace.resolveTurn = undefined;
|
|
await appendEvent(
|
|
workspace.claim.job._id,
|
|
'warn',
|
|
'cleanup',
|
|
'Agent turn aborted.',
|
|
);
|
|
return { success: true };
|
|
}
|
|
if (workspace.runtimeMode === 'codex_exec') {
|
|
throw new Error('Codex agent turns cannot be aborted from Spoon yet.');
|
|
}
|
|
return { success: true };
|
|
};
|
|
|
|
export const replyToInteraction = async (
|
|
jobId: string,
|
|
args: {
|
|
interactionId: Id<'agentInteractionRequests'>;
|
|
externalRequestId: string;
|
|
response: string;
|
|
},
|
|
) => {
|
|
const workspace = resolveWorkspace(jobId);
|
|
if (workspace.runtimeMode === 'codex_exec') {
|
|
throw new Error('Codex interaction replies are not supported yet.');
|
|
}
|
|
if (!workspace.opencodeSession) {
|
|
throw new Error('OpenCode session is not active.');
|
|
}
|
|
const mapped =
|
|
args.response === 'reject'
|
|
? 'reject'
|
|
: args.response === 'always'
|
|
? 'always'
|
|
: 'once';
|
|
await replyOpenCodePermission({
|
|
session: workspace.opencodeSession,
|
|
permissionId: args.externalRequestId,
|
|
response: mapped,
|
|
directory: workspace.containerRepo,
|
|
});
|
|
await patchInteractionRequest({
|
|
interactionId: args.interactionId,
|
|
status: mapped === 'reject' ? 'rejected' : 'approved',
|
|
response: mapped,
|
|
});
|
|
await appendMessage({
|
|
jobId: workspace.claim.job._id,
|
|
role: 'system',
|
|
status: 'completed',
|
|
content: `Interaction ${mapped === 'reject' ? 'rejected' : 'approved'}.`,
|
|
});
|
|
return { success: true };
|
|
};
|
|
|
|
export const sendWorkspaceMessage = async (
|
|
jobId: string,
|
|
prompt: string,
|
|
options: { recordUserMessage?: boolean } = {},
|
|
) => {
|
|
const workspace = resolveWorkspace(jobId);
|
|
const { claim, redact } = workspace;
|
|
if (workspace.agentTurnActive) {
|
|
throw new Error('Wait for the current agent turn to finish or abort it.');
|
|
}
|
|
if (options.recordUserMessage ?? true) {
|
|
await appendMessage({
|
|
jobId: claim.job._id,
|
|
role: 'user',
|
|
status: 'completed',
|
|
content: prompt,
|
|
});
|
|
}
|
|
await appendEvent(claim.job._id, 'info', 'plan', 'Sending message to agent.');
|
|
|
|
let assistantMessageId: Id<'agentJobMessages'> | undefined;
|
|
try {
|
|
workspace.agentTurnActive = true;
|
|
assistantMessageId = await appendMessage({
|
|
jobId: claim.job._id,
|
|
role: 'assistant',
|
|
status: 'streaming',
|
|
content: '',
|
|
});
|
|
const assistantContent = { value: '' };
|
|
if (isCodexLoginProfile(claim)) {
|
|
await appendEvent(
|
|
claim.job._id,
|
|
'info',
|
|
'plan',
|
|
'Starting Codex CLI turn with the configured login profile.',
|
|
);
|
|
await runCodexTurn({
|
|
workspace,
|
|
prompt,
|
|
assistantMessageId,
|
|
assistantContent,
|
|
});
|
|
console.log(
|
|
`Codex turn completed for job ${claim.job._id}; response length=${assistantContent.value.length}`,
|
|
);
|
|
} else if (env.runtime === 'docker') {
|
|
await appendEvent(
|
|
claim.job._id,
|
|
'info',
|
|
'plan',
|
|
'Starting OpenCode server turn with the configured API provider.',
|
|
);
|
|
await runOpenCodeTurn({
|
|
workspace,
|
|
prompt,
|
|
assistantMessageId,
|
|
assistantContent,
|
|
});
|
|
} else {
|
|
const aiEnv = providerEnvironment(claim);
|
|
const secretEnv = Object.fromEntries(
|
|
claim.secrets.map((secret) => [secret.name, secret.value]),
|
|
);
|
|
const result = await run(
|
|
'bash',
|
|
[
|
|
'-lc',
|
|
`opencode run --format json --model ${quoteShell(opencodeModel(claim))} ${quoteShell(prompt)}`,
|
|
],
|
|
{
|
|
cwd: workspace.repoDir,
|
|
env: {
|
|
...aiEnv,
|
|
...secretEnv,
|
|
},
|
|
redact,
|
|
timeoutMs: env.jobTimeoutMs,
|
|
},
|
|
);
|
|
await updateMessage({
|
|
messageId: assistantMessageId,
|
|
status: result.exitCode === 0 ? 'completed' : 'failed',
|
|
content: truncate(result.output, 40_000),
|
|
});
|
|
if (result.exitCode !== 0) {
|
|
throw new Error(`${agentFailurePrefix(claim)}:\n${result.output}`);
|
|
}
|
|
}
|
|
if (isCodexLoginProfile(claim)) {
|
|
if (!assistantContent.value.trim()) {
|
|
console.error(
|
|
`Codex completed without producing an assistant response for job ${claim.job._id}.`,
|
|
);
|
|
throw new Error(
|
|
workspace.codexTurnError
|
|
? `Codex failed: ${workspace.codexTurnError}`
|
|
: 'Codex completed without producing an assistant response.',
|
|
);
|
|
}
|
|
await updateMessage({
|
|
messageId: assistantMessageId,
|
|
status: 'completed',
|
|
content: assistantContent.value,
|
|
});
|
|
workspace.agentTurnActive = false;
|
|
}
|
|
workspace.agentTurnActive = false;
|
|
if (claim.job.jobType === 'maintenance_review') {
|
|
const decision = parseMaintenanceDecision(assistantContent.value);
|
|
if (decision) {
|
|
await addArtifact({
|
|
jobId: claim.job._id,
|
|
kind: 'summary',
|
|
title: 'Maintenance decision',
|
|
content: JSON.stringify(decision, null, 2),
|
|
contentType: 'application/json',
|
|
});
|
|
await applyMaintenanceDecision(claim.job._id, decision);
|
|
} else {
|
|
await updateStatus(claim.job._id, 'changes_ready', {
|
|
summary:
|
|
'The agent completed the review, but Spoon could not parse a structured maintenance decision.',
|
|
});
|
|
}
|
|
}
|
|
const diff = await getWorktreeDiff(workspace.repoDir, redact);
|
|
await addArtifact({
|
|
jobId: claim.job._id,
|
|
kind: 'diff',
|
|
title: 'Git diff',
|
|
content: truncate(diff.output, 200_000),
|
|
contentType: 'text/x-diff',
|
|
});
|
|
await recordChangedFiles(workspace, 'agent', diff.output);
|
|
} catch (error) {
|
|
workspace.agentTurnActive = false;
|
|
workspace.resolveTurn?.();
|
|
workspace.resolveTurn = undefined;
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(`Agent turn failed for job ${claim.job._id}: ${message}`);
|
|
await appendEvent(
|
|
claim.job._id,
|
|
'error',
|
|
'cleanup',
|
|
truncate(redact(message), 20_000),
|
|
);
|
|
if (assistantMessageId) {
|
|
await updateMessage({
|
|
messageId: assistantMessageId,
|
|
status: 'failed',
|
|
content: truncate(redact(message), 40_000),
|
|
});
|
|
} else {
|
|
await appendMessage({
|
|
jobId: claim.job._id,
|
|
role: 'assistant',
|
|
status: 'failed',
|
|
content: truncate(redact(message), 40_000),
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export const openWorkspacePullRequest = async (jobId: string) => {
|
|
const workspace = resolveWorkspace(jobId);
|
|
const { claim, repoDir, redact } = workspace;
|
|
await ensureNoEnvFilesStaged(workspace);
|
|
const status = await getStatus(repoDir, redact);
|
|
if (!status.output.trim()) {
|
|
throw new Error('No changes are ready for a draft PR.');
|
|
}
|
|
const diff = await getWorktreeDiff(repoDir, redact);
|
|
const settings = claim.agentSettings;
|
|
const detected = await detectPackageCommands(repoDir);
|
|
const installCommand = settings?.installCommand ?? detected.install;
|
|
const checkCommand = settings?.checkCommand ?? detected.check;
|
|
const testCommand = settings?.testCommand ?? detected.test;
|
|
const prBody = buildPrBody({
|
|
prompt: claim.job.prompt,
|
|
summary: 'Interactive Spoon agent workspace changes.',
|
|
commands: [installCommand, checkCommand, testCommand].filter(
|
|
(command): command is string => Boolean(command),
|
|
),
|
|
limitations: ['Review the draft PR before merging.'],
|
|
});
|
|
await addArtifact({
|
|
jobId: claim.job._id,
|
|
kind: 'diff',
|
|
title: 'Final Git diff',
|
|
content: truncate(diff.output, 200_000),
|
|
contentType: 'text/x-diff',
|
|
});
|
|
await addArtifact({
|
|
jobId: claim.job._id,
|
|
kind: 'pr_body',
|
|
title: 'Draft PR body',
|
|
content: prBody,
|
|
contentType: 'text/markdown',
|
|
});
|
|
const commitSha = await commitAndPush({
|
|
repoDir,
|
|
workBranch: claim.job.workBranch,
|
|
message: `Agent: ${claim.job.prompt.slice(0, 72)}`,
|
|
redact,
|
|
timeoutMs: env.jobTimeoutMs,
|
|
});
|
|
if (!claim.github.installationId) {
|
|
throw new Error('GitHub installation ID is missing.');
|
|
}
|
|
const pullRequest = await openDraftPullRequest({
|
|
installationId: claim.github.installationId,
|
|
forkOwner: claim.job.forkOwner,
|
|
forkRepo: claim.job.forkRepo,
|
|
baseBranch: claim.job.baseBranch,
|
|
workBranch: claim.job.workBranch,
|
|
title: `Agent: ${claim.job.prompt.slice(0, 64)}`,
|
|
body: prBody,
|
|
});
|
|
await completeWithDraftPr({
|
|
jobId: claim.job._id,
|
|
commitSha,
|
|
pullRequestUrl: pullRequest.html_url,
|
|
pullRequestNumber: pullRequest.number,
|
|
summary: 'Draft PR opened from interactive workspace.',
|
|
});
|
|
await markWorkspaceStopped(claim.job._id);
|
|
workspace.opencodeSession?.close();
|
|
if (workspace.containerName) {
|
|
await stopWorkspaceContainer(workspace.containerName);
|
|
}
|
|
activeWorkspaces.delete(jobId);
|
|
// The persistent per-user home + ~/Code checkouts survive across sessions;
|
|
// release the box (reaped once no other thread/terminal holds it).
|
|
releaseUserBox(workspace.username);
|
|
return {
|
|
pullRequestUrl: pullRequest.html_url,
|
|
pullRequestNumber: pullRequest.number,
|
|
};
|
|
};
|
|
|
|
export const stopWorkspace = async (jobId: string) => {
|
|
const workspace = resolveWorkspace(jobId);
|
|
await markWorkspaceStopped(workspace.claim.job._id);
|
|
workspace.opencodeSession?.close();
|
|
if (workspace.containerName) {
|
|
await stopWorkspaceContainer(workspace.containerName);
|
|
}
|
|
activeWorkspaces.delete(jobId);
|
|
// The persistent per-user home + ~/Code checkouts survive across sessions;
|
|
// release the box (reaped once no other thread/terminal holds it).
|
|
releaseUserBox(workspace.username);
|
|
return { success: true };
|
|
};
|
|
|
|
export const getWorkerHealth = async () => {
|
|
const active = [...activeWorkspaces.entries()].map(([jobId, workspace]) => ({
|
|
jobId,
|
|
runtimeMode: workspace.runtimeMode ?? 'legacy_cli',
|
|
containerName: workspace.containerName,
|
|
workdir: workspace.workdir,
|
|
agentTurnActive: Boolean(workspace.agentTurnActive),
|
|
}));
|
|
const jobContainers = await listWorkspaceContainerNames('spoon-agent-job-');
|
|
const boxContainers = await listWorkspaceContainerNames('spoon-box-');
|
|
return {
|
|
ok: true,
|
|
buildSha: env.buildSha,
|
|
buildCreatedAt: env.buildCreatedAt,
|
|
workerId: env.workerId,
|
|
convexUrl: env.convexUrl,
|
|
runtime: env.runtime,
|
|
containerRuntime: env.containerRuntime,
|
|
containerAccess: env.containerAccess,
|
|
jobImage: env.jobImage,
|
|
workdir: env.workdir,
|
|
hostWorkdir: env.hostWorkdir,
|
|
network: env.network,
|
|
httpPort: env.httpPort,
|
|
maxConcurrentJobs: env.maxConcurrentJobs,
|
|
jobTimeoutMs: env.jobTimeoutMs,
|
|
activeWorkspaceCount: active.length,
|
|
activeWorkspaces: active,
|
|
workspaceContainers: jobContainers,
|
|
boxContainers,
|
|
};
|
|
};
|
|
|
|
export const cleanupOrphanedWorkspaces = async () => {
|
|
const activeContainers = new Set(
|
|
[...activeWorkspaces.values()]
|
|
.map((workspace) => workspace.containerName)
|
|
.filter((value): value is string => Boolean(value)),
|
|
);
|
|
const activeWorkdirs = new Set(
|
|
[...activeWorkspaces.values()].map((workspace) =>
|
|
path.resolve(workspace.workdir),
|
|
),
|
|
);
|
|
const removedContainers: string[] = [];
|
|
for (const containerName of await listWorkspaceContainerNames(
|
|
'spoon-agent-job-',
|
|
)) {
|
|
if (activeContainers.has(containerName)) continue;
|
|
await stopWorkspaceContainer(containerName);
|
|
removedContainers.push(containerName);
|
|
}
|
|
|
|
const root = path.resolve(env.workdir);
|
|
const removedWorkdirs: string[] = [];
|
|
try {
|
|
const rootDirents = await readdir(root, { withFileTypes: true });
|
|
const rootEntries = rootDirents.map((d) => ({
|
|
name: d.name,
|
|
isDirectory: d.isDirectory(),
|
|
}));
|
|
const homesRoot = path.join(root, 'homes');
|
|
const homesEntries: Record<
|
|
string,
|
|
{ name: string; isDirectory: boolean }[]
|
|
> = {};
|
|
const codeLeaves: Record<string, string[]> = {};
|
|
const homesDirents = await readdir(homesRoot, {
|
|
withFileTypes: true,
|
|
}).catch(() => []);
|
|
for (const userDir of homesDirents) {
|
|
if (!userDir.isDirectory()) continue;
|
|
const username = userDir.name;
|
|
const codeRoot = path.join(homesRoot, username, 'Code');
|
|
homesEntries[username] = [];
|
|
const leaves: string[] = [];
|
|
const spoons = await readdir(codeRoot, { withFileTypes: true }).catch(
|
|
() => [],
|
|
);
|
|
for (const spoonDir of spoons) {
|
|
if (!spoonDir.isDirectory()) continue;
|
|
const spoonRoot = path.join(codeRoot, spoonDir.name);
|
|
const branches = await readdir(spoonRoot, {
|
|
withFileTypes: true,
|
|
}).catch(() => []);
|
|
for (const branchDir of branches) {
|
|
if (branchDir.isDirectory()) {
|
|
leaves.push(path.join(spoonRoot, branchDir.name));
|
|
}
|
|
}
|
|
}
|
|
codeLeaves[username] = leaves;
|
|
}
|
|
const { removeDirs } = planWorkdirCleanup({
|
|
root,
|
|
rootEntries,
|
|
homesEntries,
|
|
codeLeaves,
|
|
activeWorkdirs,
|
|
runningBoxUsernames: runningBoxUsernames(),
|
|
});
|
|
for (const target of removeDirs) {
|
|
await rm(target, { recursive: true, force: true });
|
|
removedWorkdirs.push(target);
|
|
}
|
|
} catch (error) {
|
|
const code = error && typeof error === 'object' ? 'code' in error : false;
|
|
if (!code || (error as { code?: string }).code !== 'ENOENT') {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const boxContainers = await listWorkspaceContainerNames('spoon-box-');
|
|
return { success: true, removedContainers, removedWorkdirs, boxContainers };
|
|
};
|
|
|
|
export const startWorker = async () => {
|
|
console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`);
|
|
for (;;) {
|
|
try {
|
|
const claim = await client.action(api.agentJobsNode.claimNextForWorker, {
|
|
workerId: env.workerId,
|
|
workerToken: env.workerToken,
|
|
});
|
|
if (!claim) {
|
|
await sleep(env.pollMs);
|
|
continue;
|
|
}
|
|
await runClaim(claim);
|
|
} catch (error) {
|
|
console.error(error);
|
|
await sleep(env.pollMs);
|
|
}
|
|
}
|
|
};
|