Move to threads based system.
This commit is contained in:
+675
-113
@@ -1,11 +1,18 @@
|
||||
import { access, readFile, rm } from 'node:fs/promises';
|
||||
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 { runOpenAiEdit } from './agent';
|
||||
import { env } from './env';
|
||||
import {
|
||||
cloneRepository,
|
||||
@@ -22,6 +29,10 @@ 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;
|
||||
@@ -31,7 +42,26 @@ type Claim = {
|
||||
};
|
||||
spoon: { name: string };
|
||||
openai: {
|
||||
apiKey: string;
|
||||
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';
|
||||
};
|
||||
@@ -40,13 +70,30 @@ type Claim = {
|
||||
installCommand?: string;
|
||||
checkCommand?: string;
|
||||
testCommand?: string;
|
||||
autoDetectCommands?: boolean;
|
||||
} | null;
|
||||
secrets: { name: string; value: string }[];
|
||||
};
|
||||
|
||||
type ActiveWorkspace = {
|
||||
claim: Claim;
|
||||
workdir: string;
|
||||
repoDir: string;
|
||||
githubToken: string;
|
||||
redact: (value: string) => 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'>,
|
||||
@@ -129,8 +176,232 @@ const completeWithDraftPr = async (args: {
|
||||
...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 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 providerEnvironment = (claim: Claim): Record<string, string> => {
|
||||
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(
|
||||
'OpenCode login profiles are saved but need auth-file injection before execution.',
|
||||
);
|
||||
};
|
||||
|
||||
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 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);
|
||||
@@ -180,28 +451,91 @@ const runProjectCommand = async (args: {
|
||||
}
|
||||
};
|
||||
|
||||
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 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<{ install?: string; check?: string; test?: 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 packageManager = (await fileExists(path.join(repoDir, 'bun.lock')))
|
||||
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')
|
||||
@@ -213,6 +547,7 @@ const detectPackageCommands = async (
|
||||
? 'npm test'
|
||||
: `${packageManager} test`
|
||||
: undefined,
|
||||
build: scripts.build ? runScript('build') : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
@@ -250,13 +585,28 @@ ${
|
||||
|
||||
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 runClaim = async (claim: Claim) => {
|
||||
const jobId = claim.job._id;
|
||||
const workdir = path.resolve(env.workdir, jobId);
|
||||
const secretValues = [
|
||||
claim.openai.apiKey,
|
||||
claim.openai.apiKey ?? '',
|
||||
claim.aiProviderProfile?.secret ?? '',
|
||||
...claim.secrets.map((secret) => secret.value),
|
||||
];
|
||||
].filter(Boolean);
|
||||
const redact = createRedactor(secretValues);
|
||||
try {
|
||||
await updateStatus(jobId, 'preparing');
|
||||
@@ -275,118 +625,37 @@ const runClaim = async (claim: Claim) => {
|
||||
redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
});
|
||||
await updateStatus(jobId, 'running');
|
||||
await appendEvent(jobId, 'info', 'plan', 'Gathering repo context.');
|
||||
const edit = await runOpenAiEdit({
|
||||
const workspace: ActiveWorkspace = {
|
||||
claim,
|
||||
workdir,
|
||||
repoDir,
|
||||
apiKey: claim.openai.apiKey,
|
||||
model: claim.openai.model,
|
||||
reasoningEffort: claim.openai.reasoningEffort,
|
||||
prompt: claim.job.prompt,
|
||||
secretNames: claim.secrets.map((secret) => secret.name),
|
||||
spoonName: claim.spoon.name,
|
||||
upstreamFullName: `${claim.job.upstreamOwner}/${claim.job.upstreamRepo}`,
|
||||
forkFullName: `${claim.job.forkOwner}/${claim.job.forkRepo}`,
|
||||
});
|
||||
await addArtifact({
|
||||
jobId,
|
||||
kind: 'plan',
|
||||
title: 'Agent plan',
|
||||
content: edit.summary,
|
||||
contentType: 'text/markdown',
|
||||
});
|
||||
const status = await getStatus(repoDir, redact);
|
||||
if (!status.output.trim()) {
|
||||
throw new Error('No changes produced by the agent.');
|
||||
}
|
||||
const diff = await getWorktreeDiff(repoDir, redact);
|
||||
await addArtifact({
|
||||
jobId,
|
||||
kind: 'diff',
|
||||
title: 'Git diff',
|
||||
content: truncate(diff.output, 200_000),
|
||||
contentType: 'text/x-diff',
|
||||
});
|
||||
await updateStatus(jobId, 'checks_running');
|
||||
const detected = await detectPackageCommands(repoDir);
|
||||
const settings = claim.agentSettings;
|
||||
const installCommand = settings?.installCommand ?? detected.install;
|
||||
const checkCommand = settings?.checkCommand ?? detected.check;
|
||||
const testCommand = settings?.testCommand ?? detected.test;
|
||||
if (installCommand) {
|
||||
await runProjectCommand({
|
||||
command: installCommand,
|
||||
phase: 'install',
|
||||
claim,
|
||||
workdir,
|
||||
repoDir,
|
||||
redact,
|
||||
});
|
||||
}
|
||||
if (checkCommand) {
|
||||
await runProjectCommand({
|
||||
command: checkCommand,
|
||||
phase: 'check',
|
||||
claim,
|
||||
workdir,
|
||||
repoDir,
|
||||
redact,
|
||||
});
|
||||
}
|
||||
if (testCommand) {
|
||||
await runProjectCommand({
|
||||
command: testCommand,
|
||||
phase: 'test',
|
||||
claim,
|
||||
workdir,
|
||||
repoDir,
|
||||
redact,
|
||||
});
|
||||
}
|
||||
await appendEvent(jobId, 'info', 'commit', 'Committing changes.');
|
||||
const commitSha = await commitAndPush({
|
||||
repoDir,
|
||||
workBranch: claim.job.workBranch,
|
||||
message: `Agent: ${claim.job.prompt.slice(0, 72)}`,
|
||||
githubToken,
|
||||
redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
});
|
||||
const prBody = buildPrBody({
|
||||
prompt: claim.job.prompt,
|
||||
summary: edit.summary,
|
||||
commands: [
|
||||
installCommand,
|
||||
checkCommand,
|
||||
testCommand,
|
||||
...edit.commands,
|
||||
].filter((command): command is string => Boolean(command)),
|
||||
limitations: edit.limitations,
|
||||
});
|
||||
};
|
||||
await materializeEnvFile(workspace);
|
||||
const detected = await detectPackageCommands(repoDir);
|
||||
await addArtifact({
|
||||
jobId,
|
||||
kind: 'pr_body',
|
||||
title: 'Draft PR body',
|
||||
content: prBody,
|
||||
contentType: 'text/markdown',
|
||||
kind: 'summary',
|
||||
title: 'Detected project commands',
|
||||
content: JSON.stringify(detected, null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
await appendEvent(jobId, 'info', 'pr', 'Opening draft pull request.');
|
||||
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,
|
||||
activeWorkspaces.set(jobId, workspace);
|
||||
await markWorkspaceActive({ jobId });
|
||||
await updateStatus(jobId, 'running', {
|
||||
summary: 'Workspace is active.',
|
||||
});
|
||||
await completeWithDraftPr({
|
||||
await appendMessage({
|
||||
jobId,
|
||||
commitSha,
|
||||
pullRequestUrl: pullRequest.html_url,
|
||||
pullRequestNumber: pullRequest.number,
|
||||
summary: edit.summary,
|
||||
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', 'cleanup', 'Agent job completed.');
|
||||
await appendEvent(jobId, 'info', 'plan', 'Interactive workspace is ready.');
|
||||
|
||||
await sendWorkspaceMessage(jobId, systemPromptForJob(claim));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await appendEvent(
|
||||
@@ -407,11 +676,304 @@ const runClaim = async (claim: Claim) => {
|
||||
message.toLowerCase().includes('timed out') ? 'timed_out' : 'failed',
|
||||
{ error: truncate(redact(message), 10_000) },
|
||||
);
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
await markWorkspaceStopped(
|
||||
jobId,
|
||||
message.toLowerCase().includes('timed out') ? 'expired' : 'failed',
|
||||
).catch((stopError: unknown) => {
|
||||
console.error(stopError);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
workdir: workspace.workdir,
|
||||
repoDir: workspace.repoDir,
|
||||
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,
|
||||
),
|
||||
});
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
|
||||
const workspace = resolveWorkspace(jobId);
|
||||
const { claim, repoDir, redact, workdir } = workspace;
|
||||
await appendMessage({
|
||||
jobId: claim.job._id,
|
||||
role: 'user',
|
||||
status: 'completed',
|
||||
content: prompt,
|
||||
});
|
||||
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.');
|
||||
}
|
||||
const model = opencodeModel(claim);
|
||||
const aiEnv = providerEnvironment(claim);
|
||||
const secretEnv = Object.fromEntries(
|
||||
claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
);
|
||||
const result =
|
||||
env.runtime === 'docker'
|
||||
? await runInJobContainer({
|
||||
workdir,
|
||||
command: commandToShell(
|
||||
`opencode run --model ${quoteShell(model)} ${quoteShell(prompt)}`,
|
||||
),
|
||||
environment: {
|
||||
...aiEnv,
|
||||
...secretEnv,
|
||||
},
|
||||
redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
})
|
||||
: await run(
|
||||
'bash',
|
||||
[
|
||||
'-lc',
|
||||
`opencode run --model ${quoteShell(model)} ${quoteShell(prompt)}`,
|
||||
],
|
||||
{
|
||||
cwd: repoDir,
|
||||
env: {
|
||||
...aiEnv,
|
||||
...secretEnv,
|
||||
},
|
||||
redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
},
|
||||
);
|
||||
await appendMessage({
|
||||
jobId: claim.job._id,
|
||||
role: 'assistant',
|
||||
status: result.exitCode === 0 ? 'completed' : 'failed',
|
||||
content: truncate(result.output, 40_000),
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`opencode failed:\n${result.output}`);
|
||||
}
|
||||
if (claim.job.jobType === 'maintenance_review') {
|
||||
const decision = parseMaintenanceDecision(result.output);
|
||||
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:
|
||||
'OpenCode completed the review, but Spoon could not parse a structured maintenance decision.',
|
||||
});
|
||||
}
|
||||
}
|
||||
const diff = await getWorktreeDiff(repoDir, redact);
|
||||
await addArtifact({
|
||||
jobId: claim.job._id,
|
||||
kind: 'diff',
|
||||
title: 'Git diff',
|
||||
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),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await appendEvent(
|
||||
claim.job._id,
|
||||
'error',
|
||||
'cleanup',
|
||||
truncate(redact(message), 20_000),
|
||||
);
|
||||
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);
|
||||
activeWorkspaces.delete(jobId);
|
||||
await rm(workspace.workdir, { recursive: true, force: true });
|
||||
return {
|
||||
pullRequestUrl: pullRequest.html_url,
|
||||
pullRequestNumber: pullRequest.number,
|
||||
};
|
||||
};
|
||||
|
||||
export const stopWorkspace = async (jobId: string) => {
|
||||
const workspace = resolveWorkspace(jobId);
|
||||
await markWorkspaceStopped(workspace.claim.job._id);
|
||||
activeWorkspaces.delete(jobId);
|
||||
await rm(workspace.workdir, { recursive: true, force: true });
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const startWorker = async () => {
|
||||
console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`);
|
||||
for (;;) {
|
||||
|
||||
Reference in New Issue
Block a user