Add agent workflows & stuff
Build and Push Next App / quality (push) Failing after 48s
Build and Push Next App / build-next (push) Has been skipped

This commit is contained in:
Gabriel Brown
2026-06-21 21:15:15 -05:00
parent cf7ff2ee4e
commit 2dfa97ee4f
102 changed files with 8488 additions and 161 deletions
+423
View File
@@ -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);
}
}
};