996 lines
28 KiB
TypeScript
996 lines
28 KiB
TypeScript
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 { 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;
|
|
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;
|
|
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'>,
|
|
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 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);
|
|
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 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<{
|
|
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 runClaim = async (claim: Claim) => {
|
|
const jobId = claim.job._id;
|
|
const workdir = path.resolve(env.workdir, jobId);
|
|
const secretValues = [
|
|
claim.openai.apiKey ?? '',
|
|
claim.aiProviderProfile?.secret ?? '',
|
|
...claim.secrets.map((secret) => secret.value),
|
|
].filter(Boolean);
|
|
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,
|
|
});
|
|
const workspace: ActiveWorkspace = {
|
|
claim,
|
|
workdir,
|
|
repoDir,
|
|
githubToken,
|
|
redact,
|
|
};
|
|
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 sendWorkspaceMessage(jobId, systemPromptForJob(claim));
|
|
} 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);
|
|
});
|
|
}
|
|
};
|
|
|
|
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 (;;) {
|
|
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);
|
|
}
|
|
}
|
|
};
|