294 lines
8.9 KiB
TypeScript
294 lines
8.9 KiB
TypeScript
import { api } from "@code/backend/convex/_generated/api";
|
|
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
|
import { parseAgentEnv } from "@code/env/agent";
|
|
import {
|
|
decodeIssueWorkspaceResult,
|
|
makeIssueWorkspacePlan,
|
|
} from "@code/primitives/project-workspace";
|
|
import { createSandboxSessionEnv } from "@flue/runtime";
|
|
import type { FileStat, SandboxApi, SandboxFactory } from "@flue/runtime";
|
|
import { ConvexHttpClient } from "convex/browser";
|
|
import { Effect } from "effect";
|
|
import * as v from "valibot";
|
|
|
|
const execResultSchema = v.object({
|
|
exitCode: v.number(),
|
|
stderr: v.string(),
|
|
stdout: v.string(),
|
|
});
|
|
|
|
const statResultSchema = v.object({
|
|
isDirectory: v.boolean(),
|
|
isSymbolicLink: v.boolean(),
|
|
mtimeMs: v.number(),
|
|
size: v.number(),
|
|
});
|
|
|
|
interface ExecuteOptions {
|
|
readonly signal?: AbortSignal;
|
|
readonly timeoutMs?: number;
|
|
}
|
|
|
|
class AgentOsSandboxApi implements SandboxApi {
|
|
readonly #actorKey: string[];
|
|
readonly #client: ConvexHttpClient;
|
|
readonly #daemonId: string;
|
|
|
|
constructor(
|
|
client: ConvexHttpClient,
|
|
daemonId: string,
|
|
actorKey: readonly string[]
|
|
) {
|
|
this.#client = client;
|
|
this.#daemonId = daemonId;
|
|
this.#actorKey = [...actorKey];
|
|
}
|
|
|
|
async #execute(
|
|
method: string,
|
|
args: unknown[],
|
|
options: ExecuteOptions = {}
|
|
): Promise<unknown> {
|
|
const commandId = await this.#client.mutation(api.daemonCommands.enqueue, {
|
|
actorKey: this.#actorKey,
|
|
args,
|
|
daemonId: this.#daemonId,
|
|
method,
|
|
});
|
|
const timeoutMs = options.timeoutMs ?? 120_000;
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
while (Date.now() < deadline) {
|
|
if (options.signal?.aborted) {
|
|
// oxlint-disable-next-line no-await-in-loop -- cancellation must settle before the poll exits.
|
|
await this.#client.mutation(api.daemonCommands.cancel, { commandId });
|
|
throw options.signal.reason ?? new Error("AgentOS command aborted");
|
|
}
|
|
// oxlint-disable-next-line no-await-in-loop -- each query observes the next durable command state.
|
|
const command = await this.#client.query(api.daemonCommands.get, {
|
|
commandId,
|
|
});
|
|
if (!command) {
|
|
throw new Error(`AgentOS command ${commandId} disappeared`);
|
|
}
|
|
if (command.status === "succeeded") {
|
|
return command.result;
|
|
}
|
|
if (command.status === "failed" || command.status === "cancelled") {
|
|
throw new Error(
|
|
command.error ?? `AgentOS command ${method} ${command.status}`
|
|
);
|
|
}
|
|
const { promise, resolve } = Promise.withResolvers<null>();
|
|
setTimeout(() => resolve(null), 200);
|
|
// oxlint-disable-next-line no-await-in-loop -- polling intentionally waits between sequential reads.
|
|
await promise;
|
|
}
|
|
|
|
await this.#client.mutation(api.daemonCommands.cancel, { commandId });
|
|
throw new Error(`AgentOS command ${method} timed out after ${timeoutMs}ms`);
|
|
}
|
|
|
|
async readFile(path: string): Promise<string> {
|
|
return new TextDecoder().decode(await this.readFileBuffer(path));
|
|
}
|
|
|
|
async readFileBuffer(path: string): Promise<Uint8Array> {
|
|
const result = await this.#execute("readFile", [path]);
|
|
if (result instanceof ArrayBuffer) {
|
|
return new Uint8Array(result);
|
|
}
|
|
if (ArrayBuffer.isView(result)) {
|
|
return new Uint8Array(
|
|
result.buffer.slice(
|
|
result.byteOffset,
|
|
result.byteOffset + result.byteLength
|
|
)
|
|
);
|
|
}
|
|
throw new TypeError(
|
|
`AgentOS readFile returned non-binary data for ${path}`
|
|
);
|
|
}
|
|
|
|
async writeFile(path: string, content: string | Uint8Array): Promise<void> {
|
|
const serialized =
|
|
typeof content === "string" ? content : Uint8Array.from(content).buffer;
|
|
await this.#execute("writeFile", [path, serialized]);
|
|
}
|
|
|
|
async stat(path: string): Promise<FileStat> {
|
|
const result = v.parse(
|
|
statResultSchema,
|
|
await this.#execute("stat", [path])
|
|
);
|
|
return {
|
|
isDirectory: result.isDirectory,
|
|
isFile: !result.isDirectory && !result.isSymbolicLink,
|
|
isSymbolicLink: result.isSymbolicLink,
|
|
mtime: new Date(result.mtimeMs),
|
|
size: result.size,
|
|
};
|
|
}
|
|
|
|
async readdir(path: string): Promise<string[]> {
|
|
return v.parse(v.array(v.string()), await this.#execute("readdir", [path]));
|
|
}
|
|
|
|
async exists(path: string): Promise<boolean> {
|
|
return v.parse(v.boolean(), await this.#execute("exists", [path]));
|
|
}
|
|
|
|
async mkdir(
|
|
path: string,
|
|
options?: { readonly recursive?: boolean }
|
|
): Promise<void> {
|
|
if (!options?.recursive) {
|
|
await this.#execute("mkdir", [path]);
|
|
return;
|
|
}
|
|
const quotedPath = `'${path.replaceAll("'", `'"'"'`)}'`;
|
|
const result = v.parse(
|
|
execResultSchema,
|
|
await this.#execute("exec", [`mkdir -p ${quotedPath}`])
|
|
);
|
|
if (result.exitCode !== 0) {
|
|
throw new Error(result.stderr || `Could not create ${path}`);
|
|
}
|
|
}
|
|
|
|
async rm(
|
|
path: string,
|
|
options?: { readonly force?: boolean; readonly recursive?: boolean }
|
|
): Promise<void> {
|
|
if (options?.force && !(await this.exists(path))) {
|
|
return;
|
|
}
|
|
await this.#execute("deleteFile", [
|
|
path,
|
|
{ recursive: options?.recursive },
|
|
]);
|
|
}
|
|
|
|
async exec(
|
|
command: string,
|
|
options?: {
|
|
readonly cwd?: string;
|
|
readonly env?: Record<string, string>;
|
|
readonly signal?: AbortSignal;
|
|
readonly timeoutMs?: number;
|
|
}
|
|
): Promise<{ exitCode: number; stderr: string; stdout: string }> {
|
|
return v.parse(
|
|
execResultSchema,
|
|
await this.#execute(
|
|
"exec",
|
|
[
|
|
command,
|
|
{
|
|
...(options?.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
...(options?.env === undefined ? {} : { env: options.env }),
|
|
},
|
|
],
|
|
{ signal: options?.signal, timeoutMs: options?.timeoutMs }
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
export const agentOs = (
|
|
runtimeEnv: Record<string, string | undefined>
|
|
): SandboxFactory => {
|
|
const env = parseAgentEnv(runtimeEnv);
|
|
const client = new ConvexHttpClient(env.CONVEX_URL);
|
|
|
|
return {
|
|
async createSessionEnv({ id }) {
|
|
// Flue controls this id from the issue document selected by the web app.
|
|
const issueId = id as Id<"projectIssues">;
|
|
const run = await client.mutation(api.agentWorkspace.ensureRun, {
|
|
daemonId: env.DAEMON_ID,
|
|
issueId,
|
|
token: env.FLUE_DB_TOKEN,
|
|
});
|
|
if (!run) {
|
|
throw new Error(`Could not initialize AgentOS work run for ${id}`);
|
|
}
|
|
const sandbox = new AgentOsSandboxApi(
|
|
client,
|
|
env.DAEMON_ID,
|
|
run.actorKey
|
|
);
|
|
const context = await client.query(api.agentWorkspace.get, {
|
|
issueId,
|
|
token: env.FLUE_DB_TOKEN,
|
|
});
|
|
|
|
const plan = await Effect.runPromise(
|
|
makeIssueWorkspacePlan({
|
|
artifacts: context.artifacts.map((artifact) => ({
|
|
content: artifact.content,
|
|
path: artifact.path,
|
|
})),
|
|
branchName: context.run?.branchName,
|
|
checkoutPath: context.run?.checkoutPath,
|
|
contextFiles: context.contextDocuments.map((document) => ({
|
|
content: document.content,
|
|
kind: document.kind,
|
|
path: document.path,
|
|
})),
|
|
defaultBranch:
|
|
context.run?.baseBranch ?? context.source?.defaultBranch,
|
|
issueBody: context.issue.body,
|
|
issueId: String(context.issue._id),
|
|
issueNumber: context.issue.number,
|
|
issueTitle: context.issue.title,
|
|
sourceUrl: context.run?.sourceUrl ?? context.source?.url,
|
|
})
|
|
);
|
|
|
|
const [controlDirectory, checkoutOperation, ...stagingOperations] =
|
|
plan.operations;
|
|
if (!controlDirectory || controlDirectory._tag !== "Mkdir") {
|
|
throw new Error(
|
|
"Issue workspace plan must start with a control directory"
|
|
);
|
|
}
|
|
await sandbox.mkdir(controlDirectory.path, { recursive: true });
|
|
if (!checkoutOperation || checkoutOperation._tag !== "Exec") {
|
|
throw new Error("Issue workspace plan must include a checkout command");
|
|
}
|
|
const checkoutCommandResult = await sandbox.exec(
|
|
checkoutOperation.command,
|
|
{
|
|
cwd: checkoutOperation.cwd,
|
|
timeoutMs: checkoutOperation.timeoutMs,
|
|
}
|
|
);
|
|
|
|
await Promise.all(
|
|
stagingOperations
|
|
.filter((operation) => operation._tag === "Mkdir")
|
|
.map((operation) =>
|
|
sandbox.mkdir(operation.path, { recursive: true })
|
|
)
|
|
);
|
|
await Promise.all(
|
|
stagingOperations
|
|
.filter((operation) => operation._tag === "WriteFile")
|
|
.map((operation) =>
|
|
sandbox.writeFile(operation.path, operation.content)
|
|
)
|
|
);
|
|
|
|
await Effect.runPromise(
|
|
decodeIssueWorkspaceResult({
|
|
command: checkoutCommandResult,
|
|
plan,
|
|
})
|
|
);
|
|
return createSandboxSessionEnv(sandbox, "/workspace");
|
|
},
|
|
};
|
|
};
|