Add agent workflows & stuff
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
import { access, readFile, rm } 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,
|
||||
commitAndPush,
|
||||
getStatus,
|
||||
getWorktreeDiff,
|
||||
run,
|
||||
} from './git';
|
||||
import { getInstallationToken, openDraftPullRequest } from './github';
|
||||
import { createRedactor, truncate } from './redact';
|
||||
import { runInJobContainer } from './runtime/docker';
|
||||
|
||||
type Claim = {
|
||||
job: {
|
||||
_id: Id<'agentJobs'>;
|
||||
prompt: string;
|
||||
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';
|
||||
};
|
||||
github: { installationId?: string };
|
||||
agentSettings?: {
|
||||
installCommand?: string;
|
||||
checkCommand?: string;
|
||||
testCommand?: string;
|
||||
} | null;
|
||||
secrets: { name: string; value: string }[];
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const client = new ConvexHttpClient(env.convexUrl);
|
||||
|
||||
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 commandToShell = (command: string) => ['bash', '-lc', command];
|
||||
|
||||
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;
|
||||
workdir: string;
|
||||
repoDir: string;
|
||||
redact: (value: string) => string;
|
||||
}) => {
|
||||
await appendEvent(args.claim.job._id, 'info', args.phase, args.command);
|
||||
const result =
|
||||
env.runtime === 'docker'
|
||||
? await runInJobContainer({
|
||||
workdir: args.workdir,
|
||||
command: commandToShell(args.command),
|
||||
environment: Object.fromEntries(
|
||||
args.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
),
|
||||
redact: args.redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
})
|
||||
: await run('bash', ['-lc', args.command], {
|
||||
cwd: args.repoDir,
|
||||
env: Object.fromEntries(
|
||||
args.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
),
|
||||
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 detectPackageCommands = async (
|
||||
repoDir: string,
|
||||
): Promise<{ install?: string; check?: string; test?: string }> => {
|
||||
const packageJsonPath = path.join(repoDir, 'package.json');
|
||||
try {
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as {
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
const scripts = packageJson.scripts ?? {};
|
||||
return {
|
||||
install: (await fileExists(path.join(repoDir, 'bun.lock')))
|
||||
? 'bun install'
|
||||
: (await fileExists(path.join(repoDir, 'pnpm-lock.yaml')))
|
||||
? 'pnpm install'
|
||||
: (await fileExists(path.join(repoDir, 'yarn.lock')))
|
||||
? 'yarn install'
|
||||
: 'npm install',
|
||||
check: scripts.typecheck
|
||||
? 'npm run typecheck'
|
||||
: scripts.lint
|
||||
? 'npm run lint'
|
||||
: undefined,
|
||||
test: scripts.test ? 'npm test' : 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 runClaim = async (claim: Claim) => {
|
||||
const jobId = claim.job._id;
|
||||
const workdir = path.resolve(env.workdir, jobId);
|
||||
const secretValues = [
|
||||
claim.openai.apiKey,
|
||||
...claim.secrets.map((secret) => secret.value),
|
||||
];
|
||||
const redact = createRedactor(secretValues);
|
||||
try {
|
||||
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);
|
||||
const repoDir = await cloneRepository({
|
||||
workdir,
|
||||
token: githubToken,
|
||||
owner: claim.job.forkOwner,
|
||||
repo: claim.job.forkRepo,
|
||||
baseBranch: claim.job.baseBranch,
|
||||
workBranch: claim.job.workBranch,
|
||||
redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
});
|
||||
await updateStatus(jobId, 'running');
|
||||
await appendEvent(jobId, 'info', 'plan', 'Gathering repo context.');
|
||||
const edit = await runOpenAiEdit({
|
||||
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)}`,
|
||||
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 addArtifact({
|
||||
jobId,
|
||||
kind: 'pr_body',
|
||||
title: 'Draft PR body',
|
||||
content: prBody,
|
||||
contentType: 'text/markdown',
|
||||
});
|
||||
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,
|
||||
});
|
||||
await completeWithDraftPr({
|
||||
jobId,
|
||||
commitSha,
|
||||
pullRequestUrl: pullRequest.html_url,
|
||||
pullRequestNumber: pullRequest.number,
|
||||
summary: edit.summary,
|
||||
});
|
||||
await appendEvent(jobId, 'info', 'cleanup', 'Agent job completed.');
|
||||
} 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) },
|
||||
);
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user