Apply the bracketed Convex API module references required by the kebab-case module rename

This commit is contained in:
sai karthik
2026-07-23 00:50:08 +05:30
parent 74a209a807
commit b0aef54249
24 changed files with 1330 additions and 239 deletions

View File

@@ -12,6 +12,7 @@
"run:zopu": "bun --env-file=../../.env flue run zopu"
},
"dependencies": {
"@code/backend": "workspace:*",
"@code/env": "workspace:*",
"@flue/runtime": "latest",
"convex": "catalog:",

View File

@@ -0,0 +1,30 @@
import { parseAgentEnv } from "@code/env/agent";
import { defineAgent } from "@flue/runtime";
import type { AgentRouteHandler } from "@flue/runtime";
import { agentOs } from "../sandboxes/agent-os";
import { createProjectTools } from "../tools/project";
export const description =
"Works one repository issue inside an isolated AgentOS workspace.";
export const route: AgentRouteHandler = (_context, next) => next();
export default defineAgent(({ env, id }) => {
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
return {
cwd: "/workspace",
description,
instructions: `You are the issue-scoped project manager and coding agent.
Start every run by calling lookup_issue_context, reading issue.md, project.md, business.md, design.md, agent.md, work.md, steps.md, artifacts.md, signals.md, agent-manager.md, context.md, and card.md from the AgentOS workspace. Call report_work_status with working before editing.
Work only on the bound issue. Inspect existing files before changing them. If a missing product decision makes safe progress impossible, publish the current work.md and steps.md, report needs-input, then ask one focused question. Otherwise implement the complete issue, run the relevant command or scenario in AgentOS, and preserve command evidence.
Before finishing, update the local work.md, steps.md, artifacts.md, and context.md files and publish each changed canonical artifact with publish_project_artifact. Report completed only after verification. On an unrecoverable error, report failed with the exact blocker. Never claim a repository change or command result you did not observe.`,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
sandbox: agentOs(env),
tools: createProjectTools(id, env),
};
});

View File

@@ -0,0 +1,234 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { parseAgentEnv } from "@code/env/agent";
import { createSandboxSessionEnv } from "@flue/runtime";
import type { FileStat, SandboxApi, SandboxFactory } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser";
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,
});
await sandbox.mkdir("/workspace", { recursive: true });
await Promise.all(
context.artifacts.map((artifact) =>
sandbox.writeFile(`/workspace/${artifact.path}`, artifact.content)
)
);
await sandbox.writeFile(
"/workspace/issue.md",
`# Issue ${context.issue.number}: ${context.issue.title}\n\n${context.issue.body}\n`
);
return createSandboxSessionEnv(sandbox, "/workspace");
},
};
};

View File

@@ -0,0 +1,70 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { ARTIFACT_PATHS } from "@code/backend/convex/artifactModel";
import { parseAgentEnv } from "@code/env/agent";
import { defineTool } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser";
import * as v from "valibot";
export const createProjectTools = (
issueAgentId: string,
runtimeEnv: Record<string, string | undefined>
) => {
const env = parseAgentEnv(runtimeEnv);
const client = new ConvexHttpClient(env.CONVEX_URL);
// Flue receives this controlled id from the issue card that starts the run.
const issueId = issueAgentId as Id<"projectIssues">;
return [
defineTool({
description:
"Read the repository, issue, and canonical project artifacts bound to this agent.",
name: "lookup_issue_context",
async run() {
return await client.query(api.agentWorkspace.get, {
issueId,
token: env.FLUE_DB_TOKEN,
});
},
}),
defineTool({
description:
"Publish the complete current content of one canonical project markdown artifact after changing it in AgentOS.",
input: v.object({
content: v.pipe(v.string(), v.maxLength(200_000)),
path: v.picklist(ARTIFACT_PATHS),
}),
name: "publish_project_artifact",
async run({ input }) {
const revision = await client.mutation(
api.agentWorkspace.updateArtifact,
{
content: input.content,
issueId,
path: input.path,
token: env.FLUE_DB_TOKEN,
}
);
return { path: input.path, revision };
},
}),
defineTool({
description:
"Report the durable issue state. Use needs-input before asking a blocking question and completed only after verification.",
input: v.object({
status: v.picklist(["working", "needs-input", "completed", "failed"]),
summary: v.pipe(v.string(), v.maxLength(4000)),
}),
name: "report_work_status",
async run({ input }) {
await client.mutation(api.agentWorkspace.setStatus, {
issueId,
status: input.status,
summary: input.summary,
token: env.FLUE_DB_TOKEN,
});
return { recorded: true, status: input.status };
},
}),
];
};