Convex-only Slice 1: clients talk to Convex, Flue is a private worker
Normalize the topology so web/desktop/mobile clients communicate only with Convex. Convex admits durable commands, dispatches private Flue turns through FLUE_URL, and stores the product-facing result before clients observe it. - Normalized relational schema: organizations, projects, conversations/turns/ messages/attachments, signals/sources/constraints, works/events/attachments. - Conversation turns queued in Convex; the agent runAction dispatches to Flue and persists assistant response, signals, and proposed work back into Convex. - Browser chat moved off the Flue transport: use-chat-agent now reads reactive Convex rows and sends through conversationMessages.send. - Fixed signed-in blank screen: gate all product queries on useConvexAuth (isAuthenticated && !isRefreshing) plus personal-org bootstrap. - Pinned react/react-dom to 19.2.8 via catalog to fix SSR dual-React crash. - Removed ~16.6k net lines: daemon, AgentOS/Orb, project issues/runs/artifacts, todos, browser Flue transport, mobile execution views, smoke scripts. Verified end-to-end on cheaptricks: connect repo, send actionable message, Flue turn completes, Signal + proposed Work created with exact provenance, persists across refresh.
This commit is contained in:
@@ -9,29 +9,20 @@
|
||||
"dev": "bun --env-file=../../.env flue dev",
|
||||
"dev:tailscale": "bun --env-file=../../.env flue dev",
|
||||
"run": "bun --env-file=../../.env flue run",
|
||||
"run:zopu": "bun --env-file=../../.env flue run zopu",
|
||||
"run:zopu-dev": "bun --env-file=../../.env flue run zopu-dev"
|
||||
"run:zopu": "bun --env-file=../../.env flue run zopu"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentos-software/opencode": "0.2.7",
|
||||
"@code/backend": "workspace:*",
|
||||
"@code/env": "workspace:*",
|
||||
"@code/primitives": "workspace:*",
|
||||
"@flue/runtime": "latest",
|
||||
"@rivet-dev/agentos-core": "catalog:",
|
||||
"convex": "catalog:",
|
||||
"dockerode": "^5.0.1",
|
||||
"effect": "catalog:",
|
||||
"get-port": "^7.2.0",
|
||||
"hono": "4.12.31",
|
||||
"sandbox-agent": "0.4.2",
|
||||
"valibot": "^1.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@code/config": "workspace:*",
|
||||
"@flue/cli": "latest",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/dockerode": "^4.0.1",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* Smoke test: exercise the agentos sandbox adapter through Flue's
|
||||
* createSandboxSessionEnv wrapper — the same path the zopu agent uses.
|
||||
*
|
||||
* Run: bun --env-file=../../.env packages/agents/scripts/agentos-adapter-test.ts
|
||||
*/
|
||||
import { agentos } from "../src/sandboxes/agentos";
|
||||
|
||||
const main = async () => {
|
||||
const factory = agentos();
|
||||
|
||||
// Flue calls this once per harness initialization.
|
||||
const env = await factory.createSessionEnv({ id: "smoke-test" });
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
const assert = (label: string, condition: boolean) => {
|
||||
if (condition) {
|
||||
pass += 1;
|
||||
console.log(` ✓ ${label}`);
|
||||
} else {
|
||||
fail += 1;
|
||||
console.log(` ✗ ${label}`);
|
||||
}
|
||||
};
|
||||
|
||||
console.log("\n=== File operations ===");
|
||||
|
||||
await env.writeFile("test.txt", "hello from Flue adapter");
|
||||
assert("writeFile", true);
|
||||
|
||||
const content = await env.readFile("test.txt");
|
||||
assert(
|
||||
"readFile returns written content",
|
||||
content === "hello from Flue adapter"
|
||||
);
|
||||
|
||||
const stat = await env.stat("test.txt");
|
||||
assert("stat isFile", stat.isFile === true);
|
||||
assert("stat size", stat.size === "hello from Flue adapter".length);
|
||||
|
||||
assert("exists true", (await env.exists("test.txt")) === true);
|
||||
assert("exists false for missing", (await env.exists("nope.txt")) === false);
|
||||
|
||||
console.log("\n=== Directory operations ===");
|
||||
|
||||
await env.mkdir("subdir", { recursive: true });
|
||||
await env.writeFile("subdir/a.ts", "export const a = 1;");
|
||||
await env.writeFile("subdir/b.ts", "export const b = 2;");
|
||||
const entries = await env.readdir("subdir");
|
||||
assert(
|
||||
"readdir returns names",
|
||||
entries.length === 2 && entries.includes("a.ts")
|
||||
);
|
||||
|
||||
await env.rm("subdir/b.ts");
|
||||
const after = await env.readdir("subdir");
|
||||
assert("rm removes file", after.length === 1 && after[0] === "a.ts");
|
||||
|
||||
await env.rm("subdir", { recursive: true });
|
||||
assert("rm recursive removes dir", (await env.exists("subdir")) === false);
|
||||
|
||||
console.log("\n=== Shell exec ===");
|
||||
|
||||
const echoRes = await env.exec("echo 'adapter shell works'");
|
||||
assert("exec echo exit code", echoRes.exitCode === 0);
|
||||
assert("exec echo stdout", echoRes.stdout.trim() === "adapter shell works");
|
||||
|
||||
const pipeRes = await env.exec("echo 'hello' | tr a-z A-Z");
|
||||
assert("exec pipe", pipeRes.stdout.trim() === "HELLO");
|
||||
|
||||
const lsRes = await env.exec("ls", { cwd: "/workspace" });
|
||||
assert("exec with cwd", lsRes.stdout.includes("test.txt"));
|
||||
|
||||
const envRes = await env.exec("echo $TEST_VAR", {
|
||||
env: { TEST_VAR: "injected" },
|
||||
});
|
||||
assert("exec with env", envRes.stdout.trim() === "injected");
|
||||
|
||||
console.log(`\n=== ${pass} passed, ${fail} failed ===`);
|
||||
if (fail > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* Reference: agentOS VM as a sandbox backend for Flue.
|
||||
*
|
||||
* This script demonstrates the core idea behind a Flue sandbox adapter that
|
||||
* delegates file/shell operations to an agentOS VM. It does NOT wire into the
|
||||
* Flue agent yet — it proves the primitives work end-to-end so you can see
|
||||
* exactly how the adapter will map calls.
|
||||
*
|
||||
* Run: bun --env-file=../../.env packages/agents/scripts/agentos-sandbox-ref.ts
|
||||
*
|
||||
* Architecture:
|
||||
*
|
||||
* Flue agent (host) agentOS VM (isolated Wasm+V8)
|
||||
* ┌─────────────────────┐ ┌──────────────────────┐
|
||||
* │ defineAgent(...) │ │ Linux-like sandbox │
|
||||
* │ sandbox: agentOs()│── readFile ──► │ persistent FS │
|
||||
* │ │── writeFile ─► │ shell (sh, coreutils)│
|
||||
* │ │── exec ──────► │ ~6ms cold start │
|
||||
* └─────────────────────┘ └──────────────────────┘
|
||||
*
|
||||
* The Flue SandboxApi maps 1:1 to AgentOs methods:
|
||||
*
|
||||
* Flue SandboxApi AgentOs (core SDK)
|
||||
* ───────────────────────── ──────────────────────────
|
||||
* readFile(path) vm.readFile(path)
|
||||
* readFileBuffer(path) vm.readFile(path)
|
||||
* writeFile(path, content) vm.writeFile(path, content)
|
||||
* stat(path) vm.stat(path)
|
||||
* readdir(path) vm.readdir(path)
|
||||
* exists(path) vm.exists(path)
|
||||
* mkdir(path) vm.mkdir(path)
|
||||
* rm(path) vm.remove(path)
|
||||
* exec(command, opts) vm.exec(command, opts)
|
||||
*/
|
||||
|
||||
import { AgentOs } from "@rivet-dev/agentos-core";
|
||||
import type { VirtualStat } from "@rivet-dev/agentos-core";
|
||||
|
||||
const formatStat = (stat: VirtualStat): string =>
|
||||
JSON.stringify({
|
||||
isDirectory: stat.isDirectory,
|
||||
isSymbolicLink: stat.isSymbolicLink,
|
||||
mtime: new Date(stat.mtimeMs).toISOString(),
|
||||
size: stat.size,
|
||||
});
|
||||
|
||||
const main = async () => {
|
||||
console.log("=== Booting agentOS VM ===\n");
|
||||
|
||||
// AgentOs.create() boots a local VM with the default software bundle
|
||||
// (sh + coreutils). The VM sleeps when idle and wakes on the next action.
|
||||
const vm = await AgentOs.create();
|
||||
console.log("VM booted.\n");
|
||||
|
||||
// ── File operations ──────────────────────────────────────────────
|
||||
|
||||
console.log("=== writeFile ===");
|
||||
await vm.writeFile("/workspace/hello.txt", "Hello from agentOS VM!");
|
||||
console.log("wrote /workspace/hello.txt\n");
|
||||
|
||||
console.log("=== readFile ===");
|
||||
const content = await vm.readFile("/workspace/hello.txt");
|
||||
console.log("content:", new TextDecoder().decode(content), "\n");
|
||||
|
||||
console.log("=== stat ===");
|
||||
const stat = await vm.stat("/workspace/hello.txt");
|
||||
console.log("stat:", formatStat(stat), "\n");
|
||||
|
||||
console.log("=== exists ===");
|
||||
console.log(
|
||||
"exists /workspace/hello.txt:",
|
||||
await vm.exists("/workspace/hello.txt")
|
||||
);
|
||||
console.log(
|
||||
"exists /workspace/nope.txt:",
|
||||
await vm.exists("/workspace/nope.txt"),
|
||||
"\n"
|
||||
);
|
||||
|
||||
console.log("=== mkdir + readdir ===");
|
||||
await vm.mkdir("/workspace/src", { recursive: true });
|
||||
await vm.writeFile("/workspace/src/a.ts", "export const a = 1;");
|
||||
await vm.writeFile("/workspace/src/b.ts", "export const b = 2;");
|
||||
console.log(
|
||||
"readdir /workspace/src:",
|
||||
await vm.readdir("/workspace/src"),
|
||||
"\n"
|
||||
);
|
||||
|
||||
// ── Shell exec ───────────────────────────────────────────────────
|
||||
|
||||
console.log("=== exec: echo ===");
|
||||
const echoResult = await vm.exec("echo 'shell works!'");
|
||||
console.log(
|
||||
"stdout:",
|
||||
echoResult.stdout.trim(),
|
||||
`(exit ${echoResult.exitCode})\n`
|
||||
);
|
||||
|
||||
console.log("=== exec: pipe + coreutils ===");
|
||||
const pipeResult = await vm.exec("echo 'hello world' | tr a-z A-Z | wc -w");
|
||||
console.log(
|
||||
"stdout:",
|
||||
pipeResult.stdout.trim(),
|
||||
`(exit ${pipeResult.exitCode})\n`
|
||||
);
|
||||
|
||||
console.log("=== exec: with cwd + env ===");
|
||||
await vm.mkdir("/workspace/project", { recursive: true });
|
||||
await vm.writeFile("/workspace/project/file.txt", "data");
|
||||
const cwdResult = await vm.exec("pwd && ls", {
|
||||
cwd: "/workspace/project",
|
||||
env: { CUSTOM_VAR: "from-env" },
|
||||
});
|
||||
console.log(
|
||||
"stdout:",
|
||||
cwdResult.stdout.trim(),
|
||||
`(exit ${cwdResult.exitCode})\n`
|
||||
);
|
||||
|
||||
// ── Remove ───────────────────────────────────────────────────────
|
||||
|
||||
console.log("=== remove ===");
|
||||
await vm.remove("/workspace/src/b.ts");
|
||||
console.log(
|
||||
"after remove, readdir:",
|
||||
await vm.readdir("/workspace/src"),
|
||||
"\n"
|
||||
);
|
||||
|
||||
// ── Cleanup ──────────────────────────────────────────────────────
|
||||
|
||||
await vm.dispose();
|
||||
console.log("=== VM disposed. All primitives work. ===");
|
||||
};
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import { exec, execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineAction } from "@flue/runtime";
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import * as v from "valibot";
|
||||
|
||||
import {
|
||||
createGiteaHttpTransport,
|
||||
runPostRunGiteaLifecycle,
|
||||
} from "../git/gitea";
|
||||
import { AgentOsSandboxApi } from "../sandboxes/agent-os";
|
||||
import {
|
||||
clonePublicRepository,
|
||||
mirrorSandboxToHostCheckout,
|
||||
} from "../sandboxes/host-repository-bridge";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const GIT_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
const createHostGitRunner = () => ({
|
||||
async run(
|
||||
command: string,
|
||||
options?: { cwd?: string; env?: Record<string, string> }
|
||||
) {
|
||||
try {
|
||||
const result = await execAsync(command, {
|
||||
cwd: options?.cwd,
|
||||
env: options?.env,
|
||||
maxBuffer: GIT_OUTPUT_LIMIT_BYTES,
|
||||
});
|
||||
return {
|
||||
exitCode: 0,
|
||||
stderr: String(result.stderr),
|
||||
stdout: String(result.stdout),
|
||||
};
|
||||
} catch (error) {
|
||||
const failure = error as Error & {
|
||||
readonly code?: number;
|
||||
readonly stderr?: string;
|
||||
readonly stdout?: string;
|
||||
};
|
||||
return {
|
||||
exitCode:
|
||||
typeof failure.code === "number" && failure.code > 0
|
||||
? failure.code
|
||||
: 1,
|
||||
stderr: failure.stderr ?? failure.message,
|
||||
stdout: failure.stdout ?? "",
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const makeAuthenticatedRepositoryUrl = (
|
||||
repositoryUrl: string,
|
||||
repositoryPath: string,
|
||||
token: string
|
||||
): string => {
|
||||
const [owner] = repositoryPath.split("/");
|
||||
if (!owner) {
|
||||
throw new Error("Gitea repository path has no owner");
|
||||
}
|
||||
const url = new URL(repositoryUrl);
|
||||
url.username = owner;
|
||||
url.password = token;
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const pullRequestOutput = v.object({
|
||||
baseBranch: v.string(),
|
||||
branch: v.string(),
|
||||
number: v.number(),
|
||||
status: v.picklist(["open", "closed", "merged"]),
|
||||
url: v.string(),
|
||||
});
|
||||
|
||||
const output = v.object({
|
||||
baseBranch: v.string(),
|
||||
branch: v.string(),
|
||||
commitSha: v.optional(v.string()),
|
||||
pullRequest: v.optional(pullRequestOutput),
|
||||
status: v.picklist([
|
||||
"no_changes",
|
||||
"committed",
|
||||
"pushed",
|
||||
"pull_request_open",
|
||||
"failed",
|
||||
]),
|
||||
});
|
||||
|
||||
export const createFinalizeGiteaLifecycle = (
|
||||
issueAgentId: string,
|
||||
runtimeEnv: Record<string, string | undefined>
|
||||
) => {
|
||||
const env = parseAgentEnv(runtimeEnv);
|
||||
const client = new ConvexHttpClient(env.CONVEX_URL);
|
||||
const issueId = issueAgentId as Id<"projectIssues">;
|
||||
|
||||
return defineAction({
|
||||
description:
|
||||
"After verification, inspect the issue workspace, commit and push its work branch, and create an open Gitea pull request. Never merge.",
|
||||
input: v.object({
|
||||
commitMessage: v.optional(v.pipe(v.string(), v.maxLength(120))),
|
||||
verified: v.boolean(),
|
||||
}),
|
||||
name: "finalize_gitea_lifecycle",
|
||||
output,
|
||||
async run({ input, log }) {
|
||||
const context = await client.query(api.agentWorkspace.get, {
|
||||
issueId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
if (!context.source) {
|
||||
throw new Error("Project has no Git source configured");
|
||||
}
|
||||
if (!context.source.defaultBranch) {
|
||||
throw new Error("Project Git source has no default branch");
|
||||
}
|
||||
if (!env.GITEA_TOKEN) {
|
||||
throw new Error(
|
||||
"GITEA_TOKEN is required for Gitea pull request creation"
|
||||
);
|
||||
}
|
||||
if (!context.run) {
|
||||
throw new Error("Project work run is not initialized");
|
||||
}
|
||||
|
||||
const sandbox = new AgentOsSandboxApi(
|
||||
client,
|
||||
env.DAEMON_ID,
|
||||
context.run.actorKey
|
||||
);
|
||||
const checkout = await clonePublicRepository({
|
||||
branchName:
|
||||
context.run.branchName ?? `work/issue-${context.issue.number}`,
|
||||
repositoryUrl: context.source.url,
|
||||
});
|
||||
const runner = createHostGitRunner();
|
||||
const transport = createGiteaHttpTransport({
|
||||
baseUrl: env.GITEA_URL,
|
||||
token: env.GITEA_TOKEN,
|
||||
});
|
||||
|
||||
try {
|
||||
await mirrorSandboxToHostCheckout({
|
||||
checkoutDirectory: checkout.checkoutDirectory,
|
||||
sandbox,
|
||||
sandboxDirectory: context.run.checkoutPath ?? "/workspace/repository",
|
||||
});
|
||||
await execFileAsync(
|
||||
"git",
|
||||
[
|
||||
"remote",
|
||||
"set-url",
|
||||
"origin",
|
||||
makeAuthenticatedRepositoryUrl(
|
||||
context.source.url,
|
||||
context.source.repositoryPath,
|
||||
env.GITEA_TOKEN
|
||||
),
|
||||
],
|
||||
{
|
||||
cwd: checkout.checkoutDirectory,
|
||||
maxBuffer: GIT_OUTPUT_LIMIT_BYTES,
|
||||
}
|
||||
);
|
||||
const result = await runPostRunGiteaLifecycle({
|
||||
baseBranch: context.source.defaultBranch,
|
||||
body: `Verified changes for project issue #${context.issue.number}. Merge remains a manual review action.`,
|
||||
commitMessage: input.commitMessage,
|
||||
issueNumber: context.issue.number,
|
||||
issueTitle: context.issue.title,
|
||||
repositoryPath: context.source.repositoryPath,
|
||||
runner,
|
||||
title: `Issue #${context.issue.number}: ${context.issue.title}`,
|
||||
transport,
|
||||
verification: input.verified ? "passed" : "failed",
|
||||
workspace: checkout.checkoutDirectory,
|
||||
});
|
||||
await client.mutation(api.agentWorkspace.recordGiteaLifecycle, {
|
||||
baseBranch: result.baseBranch,
|
||||
branch: result.branch,
|
||||
...(result.commitSha === undefined
|
||||
? {}
|
||||
: { commitSha: result.commitSha }),
|
||||
issueId,
|
||||
...(result.pullRequest === undefined
|
||||
? {}
|
||||
: { pullRequest: result.pullRequest }),
|
||||
status: result.status,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
log.info("Gitea lifecycle completed", {
|
||||
branch: result.branch,
|
||||
status: result.status,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
const branchResult = await runner.run("git branch --show-current", {
|
||||
cwd: checkout.checkoutDirectory,
|
||||
});
|
||||
const branch = branchResult.stdout.trim() || "unknown";
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await client.mutation(api.agentWorkspace.recordGiteaLifecycle, {
|
||||
baseBranch: context.source.defaultBranch,
|
||||
branch,
|
||||
error: message,
|
||||
issueId,
|
||||
status: "failed",
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
await checkout.cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineAgent } from "@flue/runtime";
|
||||
|
||||
import { createFinalizeGiteaLifecycle } from "../actions/finalize-gitea-lifecycle";
|
||||
import { agentOs } from "../sandboxes/agent-os";
|
||||
import { createProjectTools } from "../tools/project";
|
||||
|
||||
export const description =
|
||||
"Works one repository issue inside an isolated AgentOS workspace.";
|
||||
|
||||
export default defineAgent(({ env, id }) => {
|
||||
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
|
||||
|
||||
return {
|
||||
actions: [createFinalizeGiteaLifecycle(id, env)],
|
||||
cwd: "/workspace/repository",
|
||||
description,
|
||||
instructions: `You are the issue-scoped project manager and coding agent.
|
||||
|
||||
Start every run by calling lookup_issue_context, reading /workspace/control/issue.md, the canonical context files under /workspace/control/context, and the operational artifacts under /workspace/control/artifacts. Call report_work_status with working before editing.
|
||||
|
||||
Work only on the bound issue. The repository checkout is the current working directory; inspect existing source files before changing them. Git metadata stays on the trusted host bridge, so do not run git commands inside AgentOS—the final lifecycle action mirrors verified files into the host checkout before committing and pushing. 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 install, edit, test, or scenario command in AgentOS, and preserve command evidence.
|
||||
|
||||
Before finishing, update the operational files under /workspace/control/artifacts and publish each changed canonical artifact with publish_project_artifact. After verification, call finalize_gitea_lifecycle with verified=true; it owns the post-run Git/Gitea lifecycle and never merges. Report completed only after that action succeeds or reports no_changes. 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),
|
||||
};
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineAgent } from "@flue/runtime";
|
||||
import { local } from "@flue/runtime/node";
|
||||
|
||||
import { createGitRemoteTools, makeGitRemoteConfig } from "../tools/git-remote";
|
||||
import { createWorkflowTools } from "../tools/workflow";
|
||||
|
||||
const INSTRUCTIONS = `You are Zopu Dev, the development agent for the canonical zopu-code repository (self-hosted Gitea: puter/zopu-code).
|
||||
|
||||
## Your job
|
||||
|
||||
You talk with the user and turn agreed-upon work into tracked issues, then kick off autonomous implementation. You operate on exactly one repository.
|
||||
|
||||
## Loop
|
||||
|
||||
1. Understand the request. Ask one focused clarifying question only when genuinely ambiguous. Do not create work from casual chat.
|
||||
|
||||
2. Check for duplicates. Call list_issues to see what already exists.
|
||||
|
||||
3. Create the issue. Call create_issue with a clear title and a body that captures the acceptance criteria. Report the issue number and URL back to the user.
|
||||
|
||||
4. Start work only on explicit confirmation. When the user says to start (e.g. "go", "start", "work on it"), call start_workflow with the issue number. This spins up a Codex agent in an isolated worktree that implements, verifies, commits, and opens a pull request.
|
||||
|
||||
5. Report outcomes plainly: "Created issue #N: <title> — <url>" and "Started run <id> (status <status>)".
|
||||
|
||||
## Rules
|
||||
|
||||
- Never invent issue numbers. Always create or list first.
|
||||
- Never start work without explicit user confirmation.
|
||||
- Titles are concise; bodies carry the acceptance criteria.
|
||||
- If a tool returns an error with a reason, surface it and suggest next steps instead of retrying blindly.`;
|
||||
|
||||
export default defineAgent(({ env }) => {
|
||||
const agentEnv = parseAgentEnv(env);
|
||||
const gitConfig = makeGitRemoteConfig(agentEnv);
|
||||
|
||||
return {
|
||||
description:
|
||||
"Development agent that creates issues on the canonical zopu-code repo and starts autonomous Codex work runs.",
|
||||
instructions: INSTRUCTIONS,
|
||||
model: `${agentEnv.AGENT_MODEL_PROVIDER}/${agentEnv.AGENT_MODEL_NAME}`,
|
||||
sandbox: local({ cwd: process.cwd() }),
|
||||
tools: [
|
||||
...createGitRemoteTools(gitConfig),
|
||||
...createWorkflowTools({
|
||||
convexUrl: agentEnv.CONVEX_URL,
|
||||
token: agentEnv.FLUE_DB_TOKEN,
|
||||
}),
|
||||
],
|
||||
};
|
||||
});
|
||||
@@ -49,8 +49,8 @@ When a user sends a message, follow this decision flow:
|
||||
- Proposed Work is the only Work status in this slice.`;
|
||||
|
||||
export {
|
||||
authenticatedAgentRoute as attachments,
|
||||
authenticatedAgentRoute as route,
|
||||
convexAgentRoute as attachments,
|
||||
convexAgentRoute as route,
|
||||
} from "../auth";
|
||||
|
||||
export default defineAgent(({ env, id }) => {
|
||||
|
||||
@@ -3,8 +3,6 @@ import { registerProvider } from "@flue/runtime";
|
||||
import { flue } from "@flue/runtime/routing";
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { projectRequestRoute } from "./project-request";
|
||||
|
||||
const agentEnv = parseAgentEnv(process.env);
|
||||
|
||||
registerProvider(agentEnv.AGENT_MODEL_PROVIDER, {
|
||||
@@ -27,7 +25,6 @@ registerProvider(agentEnv.AGENT_MODEL_PROVIDER, {
|
||||
});
|
||||
|
||||
const app = new Hono();
|
||||
app.post("/project-requests", projectRequestRoute);
|
||||
app.route("/", flue() as unknown as Hono);
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -1,321 +1,18 @@
|
||||
/**
|
||||
* Authenticated Flue route middleware for the organization-scoped global agent.
|
||||
*
|
||||
* Every request to the Zopu agent routes through here. The middleware:
|
||||
*
|
||||
* 1. Requires a Bearer access token (the Better Auth/Convex JWT).
|
||||
* 2. Creates a fresh, request-scoped {@link ConvexHttpClient} and sets the
|
||||
* token on it — never on a shared/global client. Auth is per-request.
|
||||
* 3. Resolves (ensuring) the caller's current personal organization.
|
||||
* 4. Requires the agent instance id (`:id`) to equal that organization id, so
|
||||
* one organization cannot use or observe another's agent instance.
|
||||
* 5. Authorizes GET/SSE observation routes as strictly as POST.
|
||||
* 6. For POST only: captures exact user-message evidence *before* Flue
|
||||
* admission (`beginUserMessage`, status `admitting`), then patches it to
|
||||
* `admitted` with the Flue receipt submission id, or `failed` on error.
|
||||
* Observation requests (GET/HEAD) never create evidence.
|
||||
*
|
||||
* The organization id, conversation id, message id, role, and timestamp are
|
||||
* never trusted from the client or an agent tool — they are resolved
|
||||
* server-side. Only the raw message text travels from the user, and it is
|
||||
* stored verbatim (never trimmed or rewritten).
|
||||
*/
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import type { AgentRouteHandler } from "@flue/runtime";
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typed function references (no generated backend bindings imported).
|
||||
// ---------------------------------------------------------------------------
|
||||
/** Only Convex may invoke or observe the organization-scoped Flue agent. */
|
||||
export const convexAgentRoute: AgentRouteHandler = async (context, next) => {
|
||||
const env = parseAgentEnv(process.env);
|
||||
const authorization = context.req.header("authorization");
|
||||
if (authorization !== `Bearer ${env.FLUE_DB_TOKEN}`) {
|
||||
return context.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
interface OrganizationView {
|
||||
readonly _id: string;
|
||||
readonly _creationTime: number;
|
||||
readonly name: string;
|
||||
readonly kind: "personal" | "team";
|
||||
readonly createdBy: string;
|
||||
readonly createdAt: number;
|
||||
}
|
||||
const organizationId = context.req.header("x-zopu-organization-id");
|
||||
if (!organizationId || organizationId !== context.req.param("id")) {
|
||||
return context.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
interface ConversationMessageView {
|
||||
readonly _id: string;
|
||||
readonly _creationTime: number;
|
||||
readonly organizationId: string;
|
||||
readonly conversationId: string;
|
||||
readonly messageId: string;
|
||||
readonly submissionId: string | null;
|
||||
readonly clientRequestId: string;
|
||||
readonly role: "user";
|
||||
readonly rawText: string;
|
||||
readonly status: "admitting" | "admitted" | "failed";
|
||||
readonly createdAt: number;
|
||||
}
|
||||
|
||||
const ensurePersonalOrganization = makeFunctionReference<
|
||||
"mutation",
|
||||
Record<string, never>,
|
||||
OrganizationView
|
||||
>("organizations:ensurePersonalOrganization");
|
||||
|
||||
const beginUserMessage = makeFunctionReference<
|
||||
"mutation",
|
||||
{
|
||||
readonly organizationId: string;
|
||||
readonly clientRequestId: string;
|
||||
readonly rawText: string;
|
||||
},
|
||||
ConversationMessageView
|
||||
>("conversationMessages:beginUserMessage");
|
||||
|
||||
const markAdmitted = makeFunctionReference<
|
||||
"mutation",
|
||||
{
|
||||
readonly organizationId: string;
|
||||
readonly clientRequestId: string;
|
||||
readonly submissionId: string;
|
||||
},
|
||||
ConversationMessageView
|
||||
>("conversationMessages:markAdmitted");
|
||||
|
||||
const markFailed = makeFunctionReference<
|
||||
"mutation",
|
||||
{
|
||||
readonly organizationId: string;
|
||||
readonly clientRequestId: string;
|
||||
},
|
||||
ConversationMessageView
|
||||
>("conversationMessages:markFailed");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request-scoped authenticated Convex client.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { CONVEX_URL } = parseAgentEnv(process.env);
|
||||
|
||||
/**
|
||||
* A short-lived authenticated Convex client bound to one HTTP request. The
|
||||
* caller's JWT is set on this instance only and discarded when the request
|
||||
* ends. We never mutate auth on a shared/global Convex client.
|
||||
*/
|
||||
export const createAuthenticatedClient = (
|
||||
accessToken: string
|
||||
): ConvexHttpClient => {
|
||||
const client = new ConvexHttpClient(CONVEX_URL);
|
||||
client.setAuth(accessToken);
|
||||
return client;
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal structural shape the header helpers read from an incoming request.
|
||||
* Using this avoids a structural clash between the global (Bun) `Request`/
|
||||
* `Headers` and the undici types that Hono's context exposes, while staying
|
||||
* type-safe.
|
||||
*/
|
||||
interface HeaderSource {
|
||||
readonly headers: {
|
||||
readonly get: (name: string) => string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and validate the Bearer access token from the request. Returns null
|
||||
* when absent or malformed so the caller can produce a clean 401.
|
||||
*/
|
||||
export const extractBearerToken = (request: HeaderSource): string | null => {
|
||||
const header = request.headers.get("authorization");
|
||||
if (!header) {
|
||||
return null;
|
||||
}
|
||||
const match = /^Bearer\s+(?<token>\S+)$/u.exec(header);
|
||||
return match?.groups?.token ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the stable per-send request id supplied by the web client. This is
|
||||
* generated once when the user sends a message and reused across fetch/stream
|
||||
* retries, so it is a reliable idempotency key for evidence capture.
|
||||
*/
|
||||
const extractClientRequestId = (request: HeaderSource): string | null => {
|
||||
const header = request.headers.get("x-zopu-request-id");
|
||||
if (!header || header.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return header;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely parse the direct agent payload to extract the exact user message
|
||||
* text. Mirrors Flue's `DirectAgentPayload` shape `{ message, images? }`.
|
||||
* Returns null for non-JSON or missing `message` so the request is rejected
|
||||
* with 400 rather than crashing the middleware.
|
||||
*/
|
||||
const extractMessageText = async (
|
||||
request: HeaderSource & { readonly json: () => Promise<unknown> }
|
||||
): Promise<string | null> => {
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const body = (await request.json()) as unknown;
|
||||
if (
|
||||
typeof body !== "object" ||
|
||||
body === null ||
|
||||
!("message" in body) ||
|
||||
typeof (body as { message?: unknown }).message !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (body as { message: string }).message;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the Flue admission receipt submission id from the response Flue
|
||||
* produced after `next()`. The response body is `202 { streamUrl, offset,
|
||||
* submissionId }`. Returns null when the body has no submission id (e.g. an
|
||||
* error response).
|
||||
*/
|
||||
const extractSubmissionId = async (
|
||||
response: HeaderSource & {
|
||||
readonly clone: () => { readonly json: () => Promise<unknown> };
|
||||
}
|
||||
): Promise<string | null> => {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const body = (await response.clone().json()) as unknown;
|
||||
if (
|
||||
typeof body === "object" &&
|
||||
body !== null &&
|
||||
"submissionId" in body &&
|
||||
typeof (body as { submissionId?: unknown }).submissionId === "string"
|
||||
) {
|
||||
return (body as { submissionId: string }).submissionId;
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON or unreadable body: no submission id to record.
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Admit a direct submission through Flue and record the admission outcome on
|
||||
* the normalized evidence row. On admission failure, the evidence is marked
|
||||
* failed and the original error is rethrown — never swallowed. On success,
|
||||
* the Flue receipt submission id (if present) patches the evidence to
|
||||
* admitted.
|
||||
*/
|
||||
const admitAndRecordEvidence = async (
|
||||
continueAdmission: () => Promise<void>,
|
||||
client: ConvexHttpClient,
|
||||
getResponse: () => Response | undefined,
|
||||
evidence: {
|
||||
readonly organizationId: string;
|
||||
readonly clientRequestId: string;
|
||||
}
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await continueAdmission();
|
||||
} catch (error) {
|
||||
try {
|
||||
await client.mutation(markFailed, evidence);
|
||||
} catch {
|
||||
// Best-effort: the original error is the primary concern.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Patch the evidence with the Flue receipt submission id. If the response
|
||||
// carries no submission id (unexpected), leave the evidence in its begun
|
||||
// state rather than guessing.
|
||||
const response = getResponse();
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
const submissionId = await extractSubmissionId(response);
|
||||
if (!submissionId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.mutation(markAdmitted, {
|
||||
...evidence,
|
||||
submissionId,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort: the admission already succeeded and the response is
|
||||
// finalized. A failure to patch metadata must not break the request.
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The authenticated Flue route middleware. Authorizes every method, and for
|
||||
* POST captures normalized user-message evidence around Flue admission.
|
||||
*/
|
||||
export const authenticatedAgentRoute: AgentRouteHandler = async (c, next) => {
|
||||
const accessToken = extractBearerToken(c.req.raw);
|
||||
if (!accessToken) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const client = createAuthenticatedClient(accessToken);
|
||||
|
||||
// Resolve (ensuring) the caller's current personal organization.
|
||||
let organization: OrganizationView;
|
||||
try {
|
||||
organization = await client.mutation(ensurePersonalOrganization, {});
|
||||
} catch {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
// The agent instance id must be the caller's organization id. This is the
|
||||
// hard tenancy boundary: one organization cannot use or observe another's
|
||||
// agent instance.
|
||||
const instanceId = c.req.param("id") ?? "";
|
||||
if (instanceId !== organization._id) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
// Observation routes (GET/HEAD) are authorized but never create evidence.
|
||||
if (c.req.method !== "POST") {
|
||||
return next();
|
||||
}
|
||||
|
||||
// --- Evidence capture (POST only) -------------------------------------
|
||||
|
||||
const clientRequestId = extractClientRequestId(c.req.raw);
|
||||
const messageText = await extractMessageText(c.req.raw.clone());
|
||||
|
||||
if (!clientRequestId) {
|
||||
return c.json({ error: "Missing x-zopu-request-id" }, 400);
|
||||
}
|
||||
if (messageText === null) {
|
||||
return c.json({ error: "Invalid message payload" }, 400);
|
||||
}
|
||||
|
||||
// Begin evidence before Flue admission so the message is durable even if
|
||||
// admission fails or the process is interrupted.
|
||||
try {
|
||||
await client.mutation(beginUserMessage, {
|
||||
clientRequestId,
|
||||
organizationId: organization._id,
|
||||
rawText: messageText,
|
||||
});
|
||||
} catch {
|
||||
return c.json({ error: "Evidence capture failed" }, 500);
|
||||
}
|
||||
|
||||
// Admit through Flue. After next(), c.res holds the admission response.
|
||||
// Evidence is patched from the admission receipt, or marked failed without
|
||||
// swallowing the original error.
|
||||
await admitAndRecordEvidence(next, client, () => c.res, {
|
||||
clientRequestId,
|
||||
organizationId: organization._id,
|
||||
});
|
||||
return c.res;
|
||||
return await next().then(() => context.res);
|
||||
};
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createGiteaHttpTransport, runPostRunGiteaLifecycle } from "./gitea";
|
||||
import type { GitCommandRunner, GiteaTransport } from "./gitea";
|
||||
|
||||
const repository = {
|
||||
cloneUrl: "https://git.openputer.com/puter/zopu-code.git",
|
||||
defaultBranch: "main",
|
||||
htmlUrl: "https://git.openputer.com/puter/zopu-code",
|
||||
name: "zopu-code",
|
||||
sshUrl: "ssh://git@git.openputer.com:2222/puter/zopu-code.git",
|
||||
};
|
||||
|
||||
const makeRunner = (): GitCommandRunner & { readonly commands: string[] } => {
|
||||
const commands: string[] = [];
|
||||
let statusCalls = 0;
|
||||
return {
|
||||
commands,
|
||||
run(command) {
|
||||
commands.push(command);
|
||||
if (command === "git branch --show-current") {
|
||||
return Promise.resolve({
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
stdout: "work/issue-5\n",
|
||||
});
|
||||
}
|
||||
if (command === "git status --porcelain=v1") {
|
||||
statusCalls += 1;
|
||||
return Promise.resolve({
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
stdout: statusCalls === 1 ? " M src/gitea.ts\n" : "",
|
||||
});
|
||||
}
|
||||
if (command === "git diff --no-ext-diff --binary") {
|
||||
return Promise.resolve({
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
stdout: "diff --git ...",
|
||||
});
|
||||
}
|
||||
if (command === "git rev-parse HEAD") {
|
||||
return Promise.resolve({
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
stdout: "abc123\n",
|
||||
});
|
||||
}
|
||||
if (command.includes("git status --porcelain=v1")) {
|
||||
return Promise.resolve({ exitCode: 0, stderr: "", stdout: "" });
|
||||
}
|
||||
return Promise.resolve({ exitCode: 0, stderr: "", stdout: "" });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe("Gitea lifecycle adapter", () => {
|
||||
test("inspects, commits, pushes, and creates an open PR through mocked boundaries", async () => {
|
||||
const runner = makeRunner();
|
||||
const pullRequests: unknown[] = [];
|
||||
const transport: GiteaTransport = {
|
||||
createPullRequest(input) {
|
||||
pullRequests.push(input);
|
||||
return Promise.resolve({
|
||||
base: { ref: input.base },
|
||||
head: { ref: input.head },
|
||||
htmlUrl: "https://git.openputer.com/puter/zopu-code/pulls/5",
|
||||
number: 5,
|
||||
state: "open",
|
||||
});
|
||||
},
|
||||
getRepository() {
|
||||
return Promise.resolve(repository);
|
||||
},
|
||||
};
|
||||
|
||||
const result = await runPostRunGiteaLifecycle({
|
||||
baseBranch: "main",
|
||||
body: "Generated by the verified project run.",
|
||||
issueNumber: 5,
|
||||
issueTitle: "Publish verified changes",
|
||||
repositoryPath: "puter/zopu-code",
|
||||
runner,
|
||||
transport,
|
||||
verification: "passed",
|
||||
workspace: "/workspace",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
branch: "work/issue-5",
|
||||
commitSha: "abc123",
|
||||
pullRequest: {
|
||||
baseBranch: "main",
|
||||
branch: "work/issue-5",
|
||||
number: 5,
|
||||
status: "open",
|
||||
},
|
||||
status: "pull_request_open",
|
||||
});
|
||||
expect(runner.commands).toEqual([
|
||||
"git branch --show-current",
|
||||
"git status --porcelain=v1",
|
||||
"git diff --no-ext-diff --binary",
|
||||
"git add --all && git commit -m 'feat(issue-5): Publish verified changes'",
|
||||
"git rev-parse HEAD",
|
||||
"git status --porcelain=v1",
|
||||
"git push origin HEAD:'work/issue-5'",
|
||||
]);
|
||||
expect(pullRequests).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("mocks the HTTP Gitea boundary without calling the network", async () => {
|
||||
const requests: Request[] = [];
|
||||
const transport = createGiteaHttpTransport({
|
||||
baseUrl: "https://git.openputer.com",
|
||||
fetch: (input, init) => {
|
||||
const request = new Request(String(input), init);
|
||||
requests.push(request);
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
request.method === "GET"
|
||||
? JSON.stringify(repository)
|
||||
: JSON.stringify({
|
||||
base: { ref: "main" },
|
||||
head: { ref: "work/issue-5" },
|
||||
html_url: "https://git.openputer.com/puter/zopu-code/pulls/5",
|
||||
number: 5,
|
||||
state: "open",
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } }
|
||||
)
|
||||
);
|
||||
},
|
||||
token: "scoped-test-token",
|
||||
});
|
||||
|
||||
await transport.getRepository("puter/zopu-code");
|
||||
await transport.createPullRequest({
|
||||
base: "main",
|
||||
body: "body",
|
||||
head: "work/issue-5",
|
||||
repositoryPath: "puter/zopu-code",
|
||||
title: "title",
|
||||
});
|
||||
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests[0]?.url).toBe(
|
||||
"https://git.openputer.com/api/v1/repos/puter/zopu-code"
|
||||
);
|
||||
expect(requests[1]?.headers.get("authorization")).toBe(
|
||||
"token scoped-test-token"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,307 +0,0 @@
|
||||
import {
|
||||
decideGitLifecycle,
|
||||
GitLifecycleError,
|
||||
inspectGitWorkspace,
|
||||
makeCommitMessage,
|
||||
validatePullRequestMetadata,
|
||||
} from "@code/primitives/git";
|
||||
import type {
|
||||
GitLifecycleDecisionResult,
|
||||
GitLifecycleResult,
|
||||
GitWorkspaceInspection,
|
||||
} from "@code/primitives/git";
|
||||
import { Effect } from "effect";
|
||||
|
||||
export interface GitCommandResult {
|
||||
readonly exitCode: number;
|
||||
readonly stderr: string;
|
||||
readonly stdout: string;
|
||||
}
|
||||
|
||||
export interface GitCommandRunner {
|
||||
readonly run: (
|
||||
command: string,
|
||||
options?: {
|
||||
readonly cwd?: string;
|
||||
readonly env?: Record<string, string>;
|
||||
}
|
||||
) => Promise<GitCommandResult>;
|
||||
}
|
||||
|
||||
export interface GiteaRepository {
|
||||
readonly cloneUrl: string;
|
||||
readonly defaultBranch: string;
|
||||
readonly htmlUrl: string;
|
||||
readonly name: string;
|
||||
readonly sshUrl: string;
|
||||
}
|
||||
|
||||
export interface GiteaPullRequest {
|
||||
readonly base: { readonly ref: string };
|
||||
readonly head: { readonly ref: string };
|
||||
readonly htmlUrl: string;
|
||||
readonly number: number;
|
||||
readonly state: "open" | "closed" | "merged";
|
||||
}
|
||||
|
||||
export interface GiteaTransport {
|
||||
readonly createPullRequest: (input: {
|
||||
readonly base: string;
|
||||
readonly body: string;
|
||||
readonly head: string;
|
||||
readonly repositoryPath: string;
|
||||
readonly title: string;
|
||||
}) => Promise<GiteaPullRequest>;
|
||||
readonly getRepository: (repositoryPath: string) => Promise<GiteaRepository>;
|
||||
}
|
||||
|
||||
export interface GiteaHttpTransportOptions {
|
||||
readonly baseUrl: string;
|
||||
readonly fetch?: (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit
|
||||
) => Promise<Response>;
|
||||
readonly token: string;
|
||||
}
|
||||
|
||||
const shellQuote = (value: string): string =>
|
||||
`'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
|
||||
const providerFailure = (
|
||||
message: string,
|
||||
reason: "CommandFailed" | "RemoteRejected"
|
||||
) => new GitLifecycleError({ message, reason });
|
||||
|
||||
const runChecked = async (
|
||||
runner: GitCommandRunner,
|
||||
command: string,
|
||||
options?: Parameters<GitCommandRunner["run"]>[1]
|
||||
): Promise<string> => {
|
||||
let result: GitCommandResult;
|
||||
try {
|
||||
result = await runner.run(command, options);
|
||||
} catch (error) {
|
||||
throw new GitLifecycleError({
|
||||
message: `Git command could not run: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
reason: "CommandFailed",
|
||||
});
|
||||
}
|
||||
if (result.exitCode !== 0) {
|
||||
throw providerFailure(
|
||||
result.stderr.trim() || `Git command failed: ${command}`,
|
||||
command.startsWith("git push") ? "RemoteRejected" : "CommandFailed"
|
||||
);
|
||||
}
|
||||
return result.stdout;
|
||||
};
|
||||
|
||||
const toHttpPath = (repositoryPath: string): string =>
|
||||
repositoryPath
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
|
||||
const requestJson = async <T>(
|
||||
options: GiteaHttpTransportOptions,
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
): Promise<T> => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await (options.fetch ?? globalThis.fetch)(
|
||||
`${options.baseUrl.replace(/\/$/u, "")}${path}`,
|
||||
{
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `token ${options.token}`,
|
||||
"Content-Type": "application/json",
|
||||
...init?.headers,
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
throw new GitLifecycleError({
|
||||
message: `Gitea request could not run: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
reason: "RequestFailed",
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new GitLifecycleError({
|
||||
message: `Gitea request failed (${response.status}): ${detail}`,
|
||||
reason:
|
||||
response.status === 401 || response.status === 403
|
||||
? "Authentication"
|
||||
: "RequestFailed",
|
||||
});
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
};
|
||||
|
||||
export const createGiteaHttpTransport = (
|
||||
options: GiteaHttpTransportOptions
|
||||
): GiteaTransport => ({
|
||||
async createPullRequest(input) {
|
||||
const response = await requestJson<{
|
||||
base: { ref: string };
|
||||
head: { ref: string };
|
||||
html_url: string;
|
||||
number: number;
|
||||
state: "open" | "closed" | "merged";
|
||||
}>(options, `/api/v1/repos/${toHttpPath(input.repositoryPath)}/pulls`, {
|
||||
body: JSON.stringify({
|
||||
base: input.base,
|
||||
body: input.body,
|
||||
head: input.head,
|
||||
title: input.title,
|
||||
}),
|
||||
method: "POST",
|
||||
});
|
||||
return {
|
||||
base: response.base,
|
||||
head: response.head,
|
||||
htmlUrl: response.html_url,
|
||||
number: response.number,
|
||||
state: response.state,
|
||||
};
|
||||
},
|
||||
async getRepository(repositoryPath) {
|
||||
const response = await requestJson<{
|
||||
clone_url: string;
|
||||
default_branch: string;
|
||||
html_url: string;
|
||||
name: string;
|
||||
ssh_url: string;
|
||||
}>(options, `/api/v1/repos/${toHttpPath(repositoryPath)}`);
|
||||
return {
|
||||
cloneUrl: response.clone_url,
|
||||
defaultBranch: response.default_branch,
|
||||
htmlUrl: response.html_url,
|
||||
name: response.name,
|
||||
sshUrl: response.ssh_url,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export interface RunPostRunGiteaLifecycleInput {
|
||||
readonly baseBranch: string;
|
||||
readonly body: string;
|
||||
readonly commitMessage?: string;
|
||||
readonly issueNumber: number;
|
||||
readonly issueTitle: string;
|
||||
readonly repositoryPath: string;
|
||||
readonly runner: GitCommandRunner;
|
||||
readonly title?: string;
|
||||
readonly transport: GiteaTransport;
|
||||
readonly verification: "passed" | "failed" | "not-run";
|
||||
readonly workspace: string;
|
||||
}
|
||||
|
||||
const inspect = async (
|
||||
input: RunPostRunGiteaLifecycleInput
|
||||
): Promise<GitWorkspaceInspection> => {
|
||||
const [branch, status, diff] = await Promise.all([
|
||||
runChecked(input.runner, "git branch --show-current", {
|
||||
cwd: input.workspace,
|
||||
}),
|
||||
runChecked(input.runner, "git status --porcelain=v1", {
|
||||
cwd: input.workspace,
|
||||
}),
|
||||
runChecked(input.runner, "git diff --no-ext-diff --binary", {
|
||||
cwd: input.workspace,
|
||||
}),
|
||||
]);
|
||||
return await Effect.runPromise(
|
||||
inspectGitWorkspace({
|
||||
branch: branch.trim(),
|
||||
diff,
|
||||
status,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const runPostRunGiteaLifecycle = async (
|
||||
input: RunPostRunGiteaLifecycleInput
|
||||
): Promise<GitLifecycleResult> => {
|
||||
const inspection = await inspect(input);
|
||||
const initialDecision = (await Effect.runPromise(
|
||||
decideGitLifecycle({
|
||||
baseBranch: input.baseBranch,
|
||||
inspection,
|
||||
pushed: false,
|
||||
verification: input.verification,
|
||||
})
|
||||
)) as GitLifecycleDecisionResult;
|
||||
|
||||
if (initialDecision.decision === "finish") {
|
||||
return {
|
||||
baseBranch: input.baseBranch as GitLifecycleResult["baseBranch"],
|
||||
branch: inspection.branch,
|
||||
commitSha: undefined,
|
||||
pullRequest: undefined,
|
||||
status: "no_changes",
|
||||
};
|
||||
}
|
||||
|
||||
await input.transport.getRepository(input.repositoryPath);
|
||||
|
||||
await runChecked(
|
||||
input.runner,
|
||||
`git add --all && git commit -m ${shellQuote(
|
||||
input.commitMessage ??
|
||||
makeCommitMessage(input.issueNumber, input.issueTitle)
|
||||
)}`,
|
||||
{ cwd: input.workspace }
|
||||
);
|
||||
const commitOutput = await runChecked(input.runner, "git rev-parse HEAD", {
|
||||
cwd: input.workspace,
|
||||
});
|
||||
const commitSha = commitOutput.trim();
|
||||
const cleanStatus = await runChecked(
|
||||
input.runner,
|
||||
"git status --porcelain=v1",
|
||||
{ cwd: input.workspace }
|
||||
);
|
||||
if (cleanStatus.trim().length > 0) {
|
||||
throw providerFailure(
|
||||
"Git workspace remained dirty after commit",
|
||||
"CommandFailed"
|
||||
);
|
||||
}
|
||||
|
||||
await runChecked(
|
||||
input.runner,
|
||||
`git push origin HEAD:${shellQuote(inspection.branch)}`,
|
||||
{ cwd: input.workspace }
|
||||
);
|
||||
|
||||
const pullRequest = await input.transport.createPullRequest({
|
||||
base: input.baseBranch,
|
||||
body: input.body,
|
||||
head: inspection.branch,
|
||||
repositoryPath: input.repositoryPath,
|
||||
title: input.title ?? `Issue #${input.issueNumber}: ${input.issueTitle}`,
|
||||
});
|
||||
const metadata = await Effect.runPromise(
|
||||
validatePullRequestMetadata({
|
||||
baseBranch: pullRequest.base.ref,
|
||||
branch: pullRequest.head.ref,
|
||||
number: pullRequest.number,
|
||||
status: pullRequest.state,
|
||||
url: pullRequest.htmlUrl,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
baseBranch: input.baseBranch as GitLifecycleResult["baseBranch"],
|
||||
branch: inspection.branch,
|
||||
commitSha,
|
||||
pullRequest: metadata,
|
||||
status: "pull_request_open",
|
||||
};
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
# Orb Runtime
|
||||
|
||||
One logical execution workspace for one ProjectIssue run. An Orb bundles an AgentOS actor, an OpenCode harness session, a Docker sandbox, a mounted repository workspace, project context, model-gateway configuration, process/log handling, normalized execution events, and a cleanup lifecycle.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ OrbRuntime │
|
||||
│ creates OrbHandle instances │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ OrbHandle │
|
||||
│ ┌─────────────┐ ┌────────────────────────┐ │
|
||||
│ │ AgentOS VM │ │ SandboxAgent + Docker │ │
|
||||
│ │ (OpenCode │◄──►│ (sandbox-agent server │ │
|
||||
│ │ ACP agent) │ │ in a Docker container)│ │
|
||||
│ └─────────────┘ └────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ session events runProcess / │
|
||||
│ → normalized createProcess │
|
||||
│ OrbEvent │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The AgentOS VM runs the OpenCode ACP adapter (lightweight agent loop, session management, durable identity). Heavy execution — package installs, test suites, builds — runs inside a Docker container hosting a sandbox-agent server. The `DockerSandboxProvider` calls `SandboxAgent.start({ sandbox: docker(...) })`, which starts the server in a Docker container with a dynamically-mapped host port and returns a `SandboxAgent` client whose `baseUrl` both the main process and the AgentOS sidecar subprocess reach over `127.0.0.1`. The sandbox filesystem is mounted into the VM at `/mnt/sandbox`.
|
||||
|
||||
## Domain model
|
||||
|
||||
| Type | Description |
|
||||
| --- | --- |
|
||||
| `OrbId` | Branded identifier for one Orb |
|
||||
| `OrbRunId` | Branded identifier for one run attempt |
|
||||
| `OrbSessionId` | Branded identifier for one OpenCode session |
|
||||
| `OrbIdentity` | `{ projectId, runId, workUnitId }` — derives the actor key |
|
||||
| `OrbState` | `creating → prepared → running → needs-input → completed/failed/cancelled → disposed` |
|
||||
| `RunState` | `queued → provisioning → preparing → running → verifying → succeeded/failed/cancelled` |
|
||||
| `OrbEvent` | Normalized, secret-free execution event |
|
||||
|
||||
Orb state and run state are separate state machines. The VM/sandbox lease state is never conflated with the product work-unit state.
|
||||
|
||||
## Tagged errors
|
||||
|
||||
| Error | Reasons |
|
||||
| --- | --- |
|
||||
| `OrbStateError` | `InvalidTransition`, `AlreadyDisposed`, `NotRunning` |
|
||||
| `OrbSandboxError` | `DockerUnavailable`, `ContainerStart`, `CommandFailed`, `ContainerCleanup` |
|
||||
| `OrbSessionError` | `OpenSession`, `PromptFailed`, `AgentNotInstalled`, `SessionNotFound` |
|
||||
| `OrbConfigurationError` | `MissingGateway`, `MissingIdentity`, `InvalidModelConfig` |
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `ORB_PROOF` | Proof only | Set to `1` to run the proof fixture |
|
||||
| `ORB_GATEWAY_API_KEY` | Proof only | Model gateway API key |
|
||||
| `ORB_GATEWAY_BASE_URL` | Proof only | Model gateway base URL (OpenAI-compatible `/v1`) |
|
||||
| `ORB_GATEWAY_MODEL` | Proof only | Model name |
|
||||
| `ORB_GATEWAY_PROVIDER` | Proof only | Provider identifier |
|
||||
| `ORB_DOCKER_IMAGE` | Optional | Docker image (default: `oven/bun:1.3-debian`) |
|
||||
| `ORB_DOCKER_WORKSPACE` | Optional | Host workspace root (default: `/tmp/orb-workspaces`) |
|
||||
| `RIVET_ENDPOINT` | Optional | AgentOS/RivetKit sidecar endpoint |
|
||||
|
||||
No permanent provider credentials are stored in project files. API keys are injected at runtime via the OpenCode config written to the VM filesystem, and all event output is passed through `redactSecrets`.
|
||||
|
||||
## Docker requirements
|
||||
|
||||
- Docker daemon running and accessible via the Docker socket (`/var/run/docker.sock`)
|
||||
- The sandbox uses `rivetdev/sandbox-agent` as its Docker image by default
|
||||
- The `sandbox-agent/docker` provider creates containers with `AutoRemove` and a dynamically allocated host port
|
||||
- Writable bind mount is the project workspace only (mounted at `/home/sandbox` inside the container)
|
||||
- The AgentOS sidecar subprocess reaches the sandbox-agent server over `127.0.0.1:<hostPort>`
|
||||
|
||||
## Local startup
|
||||
|
||||
```bash
|
||||
# Run the proof fixture
|
||||
ORB_PROOF=1 \
|
||||
ORB_GATEWAY_API_KEY=your-key \
|
||||
ORB_GATEWAY_BASE_URL=https://ai.example.com/v1 \
|
||||
ORB_GATEWAY_MODEL=glm-5.2 \
|
||||
ORB_GATEWAY_PROVIDER=cheaptricks \
|
||||
bun run scripts/orb-proof.ts
|
||||
```
|
||||
|
||||
Stable markers: `ORB_PROOF_PASSED` (exit 0), `ORB_PROOF_BLOCKED` (exit 2), `ORB_PROOF_FAILED` (exit 1).
|
||||
|
||||
## Filesystem layout
|
||||
|
||||
```
|
||||
SandboxAgent container (/home/sandbox = host bind mount)
|
||||
/home/sandbox/repository/ — project checkout
|
||||
/home/sandbox/control/ — issue + context files
|
||||
|
||||
AgentOS VM (host process)
|
||||
/mnt/sandbox/ — sandbox mount (via SandboxAgent baseUrl)
|
||||
/mnt/sandbox/repository/ — repo (via sandbox)
|
||||
/root/.config/opencode/ — OpenCode config (chmod 600)
|
||||
```
|
||||
|
||||
## Current limitations
|
||||
|
||||
- No automatic merge or production deployment capability.
|
||||
- No multi-region support.
|
||||
- Interactive PTY sessions are not wired through the sandbox agent.
|
||||
- The model turn stage depends on a reachable OpenAI-compatible gateway; if the gateway is unreachable the proof reports BLOCKED at that stage but still passes Docker and AgentOS/OpenCode.
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Schema } from "effect";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context pack — assembled from ProjectIssue, evidence, project docs, artifacts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ContextFile = Schema.Struct({
|
||||
content: Schema.String,
|
||||
label: Schema.String,
|
||||
});
|
||||
export type ContextFile = typeof ContextFile.Type;
|
||||
|
||||
export const ContextPackInput = Schema.Struct({
|
||||
artifacts: Schema.Array(
|
||||
Schema.Struct({
|
||||
content: Schema.String,
|
||||
path: Schema.String,
|
||||
})
|
||||
),
|
||||
contextFiles: Schema.Array(ContextFile),
|
||||
evidence: Schema.Array(
|
||||
Schema.Struct({
|
||||
content: Schema.String,
|
||||
source: Schema.String,
|
||||
})
|
||||
),
|
||||
issueBody: Schema.String,
|
||||
issueNumber: Schema.Int,
|
||||
issueTitle: Schema.String,
|
||||
repositoryMetadata: Schema.Struct({
|
||||
baseBranch: Schema.String,
|
||||
repositoryName: Schema.String,
|
||||
repositoryUrl: Schema.String,
|
||||
}),
|
||||
});
|
||||
export type ContextPackInput = typeof ContextPackInput.Type;
|
||||
|
||||
const section = (heading: string, lines: readonly string[]): string => {
|
||||
if (lines.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return `## ${heading}\n\n${lines.join("\n")}`;
|
||||
};
|
||||
|
||||
const OPERATIONAL_INSTRUCTIONS = `## Operational Instructions
|
||||
|
||||
You are working inside an isolated sandbox on a dedicated work branch. The repository checkout is your working directory.
|
||||
|
||||
1. Inspect existing source before changing anything.
|
||||
2. Implement the complete issue scope. Run the project's test or verification command.
|
||||
3. If you need human input to proceed safely, emit exactly one line starting with \`NEEDS_INPUT:\` followed by your question, then stop.
|
||||
4. When implementation and verification are complete, emit exactly one line starting with \`WORK_COMPLETE:\` followed by a one-sentence summary, then stop.
|
||||
5. Do not merge, deploy, or push. The orchestrator handles the Git lifecycle after you signal completion.
|
||||
6. Never claim a change you did not observe. Preserve command evidence.`;
|
||||
|
||||
/**
|
||||
* Build a concise context pack prompt for the OpenCode session. The pack is
|
||||
* sent as the first task and includes: issue details, project docs, evidence,
|
||||
* prior artifacts, repository metadata, and operational instructions with the
|
||||
* needs-input / work-complete marker protocol.
|
||||
*/
|
||||
export const buildContextPack = (input: ContextPackInput): string => {
|
||||
const parts: string[] = [
|
||||
`# Issue #${input.issueNumber}: ${input.issueTitle}\n\n${input.issueBody}`,
|
||||
];
|
||||
|
||||
if (input.contextFiles.length > 0) {
|
||||
parts.push(
|
||||
section(
|
||||
"Project Context",
|
||||
input.contextFiles.map(
|
||||
(file) => `### ${file.label}\n\n\`\`\`\n${file.content}\n\`\`\``
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (input.evidence.length > 0) {
|
||||
parts.push(
|
||||
section(
|
||||
"Supporting Evidence",
|
||||
input.evidence.map((item) => `- **${item.source}**: ${item.content}`)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (input.artifacts.length > 0) {
|
||||
parts.push(
|
||||
section(
|
||||
"Previous Work Artifacts",
|
||||
input.artifacts.map(
|
||||
(artifact) =>
|
||||
`### ${artifact.path}\n\n\`\`\`\n${artifact.content}\n\`\`\``
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
parts.push(
|
||||
section("Repository", [
|
||||
`- Name: ${input.repositoryMetadata.repositoryName}`,
|
||||
`- URL: ${input.repositoryMetadata.repositoryUrl}`,
|
||||
`- Base branch: ${input.repositoryMetadata.baseBranch}`,
|
||||
]),
|
||||
OPERATIONAL_INSTRUCTIONS
|
||||
);
|
||||
|
||||
return parts.filter((part) => part.length > 0).join("\n\n---\n\n");
|
||||
};
|
||||
@@ -1,170 +0,0 @@
|
||||
/* eslint-disable no-console -- diagnostic skip messages in a Docker-guarded suite */
|
||||
import { spawn } from "node:child_process";
|
||||
import { setTimeout as sleepTimer } from "node:timers/promises";
|
||||
|
||||
import { Effect } from "effect";
|
||||
import { describe, expect, it as vitestIt } from "vitest";
|
||||
|
||||
import { DockerSandboxProvider } from "./docker-sandbox";
|
||||
import { OrbSandboxError } from "./domain";
|
||||
|
||||
// Real containers need more than the 5s default; bind a generous timeout here.
|
||||
const it = (name: string, fn: () => Promise<void>): void => {
|
||||
vitestIt(name, fn, 60_000);
|
||||
};
|
||||
|
||||
// Node-compatible Docker availability check.
|
||||
const runCliExit = (args: readonly string[]): Promise<number | null> =>
|
||||
// eslint-disable-next-line promise/avoid-new -- wrapping one-shot child close in a single promise
|
||||
new Promise((resolve) => {
|
||||
const [command = "docker", ...rest] = args;
|
||||
const proc = spawn(command, rest, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
proc.on("error", () => resolve(null));
|
||||
proc.on("close", (code) => resolve(code));
|
||||
});
|
||||
|
||||
const dockerAvailable = async (): Promise<boolean> => {
|
||||
try {
|
||||
const code = await runCliExit([
|
||||
"docker",
|
||||
"version",
|
||||
"--format",
|
||||
"{{.Server.Version}}",
|
||||
]);
|
||||
return code === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Top-level await so describe.skipIf evaluates Docker availability at registration.
|
||||
const HAVE_DOCKER = await dockerAvailable();
|
||||
|
||||
const tmpDir = (): string =>
|
||||
`/tmp/orb-test-${Date.now()}-${Math.trunc(Math.random() * 1e6)}`;
|
||||
|
||||
describe.skipIf(!HAVE_DOCKER)(
|
||||
"DockerSandboxProvider (SandboxAgent + Docker)",
|
||||
() => {
|
||||
it("starts a SandboxAgent server and exposes a reachable baseUrl", async () => {
|
||||
const provider = await Effect.runPromise(
|
||||
DockerSandboxProvider.create({
|
||||
hostWorkspacePath: tmpDir(),
|
||||
})
|
||||
);
|
||||
try {
|
||||
const client = await provider.start();
|
||||
expect(provider.isStarted).toBe(true);
|
||||
|
||||
// The SandboxAgent client must have a baseUrl property — this is what
|
||||
// AgentOS serializes so the sidecar can reach the sandbox.
|
||||
const { baseUrl } = client as unknown as { baseUrl: string };
|
||||
expect(baseUrl).toBeTruthy();
|
||||
expect(baseUrl).toMatch(/^https?:\/\//u);
|
||||
|
||||
// Run a command to verify the sandbox-agent server actually works.
|
||||
const result = await client.runProcess({
|
||||
args: ["-c", "echo hello-orb"],
|
||||
command: "sh",
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("hello-orb");
|
||||
} finally {
|
||||
await provider.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("is idempotent: start returns the same client on repeated calls", async () => {
|
||||
const provider = await Effect.runPromise(
|
||||
DockerSandboxProvider.create({
|
||||
hostWorkspacePath: tmpDir(),
|
||||
})
|
||||
);
|
||||
try {
|
||||
const c1 = await provider.start();
|
||||
const c2 = await provider.start();
|
||||
expect(c1).toBe(c2);
|
||||
} finally {
|
||||
await provider.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("disposes cleanly and frees the Docker container", async () => {
|
||||
const provider = await Effect.runPromise(
|
||||
DockerSandboxProvider.create({
|
||||
hostWorkspacePath: tmpDir(),
|
||||
})
|
||||
);
|
||||
await provider.start();
|
||||
expect(provider.isStarted).toBe(true);
|
||||
|
||||
await provider.dispose();
|
||||
expect(provider.isStarted).toBe(false);
|
||||
|
||||
// Give AutoRemove a moment.
|
||||
// eslint-disable-next-line no-await-in-loop -- single settling wait
|
||||
await sleepTimer(2000);
|
||||
|
||||
// Second dispose is a safe no-op.
|
||||
await expect(provider.dispose()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("binds the host workspace into the sandbox container", async () => {
|
||||
const workspace = tmpDir();
|
||||
const { spawn: nodeSpawn } = await import("node:child_process");
|
||||
// eslint-disable-next-line promise/avoid-new -- one-shot shell write
|
||||
await new Promise<void>((resolve) => {
|
||||
const p = nodeSpawn(
|
||||
"sh",
|
||||
[
|
||||
"-c",
|
||||
`mkdir -p ${workspace} && echo proof-file > ${workspace}/marker.txt`,
|
||||
],
|
||||
{
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}
|
||||
);
|
||||
p.on("close", () => resolve());
|
||||
});
|
||||
|
||||
const provider = await Effect.runPromise(
|
||||
DockerSandboxProvider.create({
|
||||
hostWorkspacePath: workspace,
|
||||
})
|
||||
);
|
||||
try {
|
||||
const client = await provider.start();
|
||||
const result = await client.runProcess({
|
||||
args: ["-c", "cat /home/sandbox/marker.txt"],
|
||||
command: "sh",
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("proof-file");
|
||||
} finally {
|
||||
await provider.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
describe.skipIf(HAVE_DOCKER)("DockerSandboxProvider without Docker", () => {
|
||||
it("create fails with DockerUnavailable", async () => {
|
||||
const error = await Effect.runPromise(
|
||||
Effect.flip(
|
||||
DockerSandboxProvider.create({
|
||||
hostWorkspacePath: "/tmp/orb-test-nodocker",
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(error).toBeInstanceOf(OrbSandboxError);
|
||||
expect(error.reason).toBe("DockerUnavailable");
|
||||
});
|
||||
});
|
||||
|
||||
if (!HAVE_DOCKER) {
|
||||
console.warn(
|
||||
"[docker-sandbox.test.ts] Docker daemon unavailable; SandboxAgent tests skipped."
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import type {
|
||||
AgentOsSandboxClient,
|
||||
AgentOsSandboxProvider,
|
||||
} from "@rivet-dev/agentos-core";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import { OrbSandboxError } from "./domain";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Docker sandbox — AgentOsSandboxProvider backed by sandbox-agent + Docker
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// AgentOS serializes sandbox mounts through getSerializableClientConfig,
|
||||
// which reads client.baseUrl and passes it to the sidecar. An in-process
|
||||
// client object can never satisfy that contract because it has no network
|
||||
// endpoint. The supported boundary is a standard SandboxAgent client:
|
||||
//
|
||||
// SandboxAgent.start({ sandbox: docker({ image, binds }) })
|
||||
//
|
||||
// starts a sandbox-agent server inside a Docker container, dynamically maps
|
||||
// a host port, and returns a SandboxAgent client whose baseUrl the sidecar
|
||||
// can reach over HTTP on 127.0.0.1:<hostPort>. Both the main process and
|
||||
// the sidecar subprocess run on the host, so localhost connectivity works.
|
||||
|
||||
const SANDBOX_AGENT_IMAGE = "rivetdev/sandbox-agent:0.5.0-rc.2-full";
|
||||
const DEFAULT_WORKSPACE = "/home/sandbox";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DockerSandboxProvider — wraps SandboxAgent.start with the docker provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DockerSandboxOptions {
|
||||
readonly containerName?: string;
|
||||
readonly hostWorkspacePath: string;
|
||||
readonly image?: string;
|
||||
readonly workDir?: string;
|
||||
}
|
||||
|
||||
export class DockerSandboxProvider implements AgentOsSandboxProvider {
|
||||
private readonly image: string;
|
||||
private readonly binds: string[];
|
||||
private client: AgentOsSandboxClient | null = null;
|
||||
private disposeFn: (() => Promise<void>) | null = null;
|
||||
|
||||
constructor(options: DockerSandboxOptions) {
|
||||
this.image = options.image ?? SANDBOX_AGENT_IMAGE;
|
||||
// Bind the host workspace read-write into the sandbox container so the
|
||||
// restricted writable area is the project workspace only.
|
||||
this.binds = [
|
||||
`${options.hostWorkspacePath}:${options.workDir ?? DEFAULT_WORKSPACE}`,
|
||||
];
|
||||
}
|
||||
|
||||
static readonly create = Effect.fn("DockerSandboxProvider.create")(
|
||||
function* createProvider(options: DockerSandboxOptions) {
|
||||
// eslint-disable-next-line no-use-before-define -- defined at module bottom
|
||||
yield* checkDockerAvailable();
|
||||
return new DockerSandboxProvider(options);
|
||||
}
|
||||
);
|
||||
|
||||
async start(): Promise<AgentOsSandboxClient> {
|
||||
if (this.client) {
|
||||
return this.client;
|
||||
}
|
||||
const { SandboxAgent } = await import("sandbox-agent");
|
||||
const { docker } = await import("sandbox-agent/docker");
|
||||
|
||||
const sandboxAgent = await SandboxAgent.start({
|
||||
sandbox: docker({
|
||||
binds: this.binds,
|
||||
image: this.image,
|
||||
}),
|
||||
});
|
||||
|
||||
this.client = sandboxAgent as unknown as AgentOsSandboxClient;
|
||||
this.disposeFn = async () => {
|
||||
// destroySandbox permanently tears down the backing Docker container;
|
||||
// dispose() alone only closes the HTTP connection.
|
||||
await sandboxAgent.destroySandbox();
|
||||
};
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
const dispose = this.disposeFn;
|
||||
this.client = null;
|
||||
this.disposeFn = null;
|
||||
if (dispose) {
|
||||
await dispose();
|
||||
}
|
||||
}
|
||||
|
||||
get isStarted(): boolean {
|
||||
return this.client !== null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Docker availability check
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const checkDockerAvailable = Effect.fn("Docker.checkAvailable")(
|
||||
function* checkDockerAvailable() {
|
||||
yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSandboxError({
|
||||
message: `Docker is not available: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "DockerUnavailable",
|
||||
}),
|
||||
try: async () => {
|
||||
const { default: Docker } = await import("dockerode");
|
||||
const docker = new Docker();
|
||||
await docker.ping();
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,132 +0,0 @@
|
||||
import { Effect } from "effect";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
orbActorKey,
|
||||
OrbStateError,
|
||||
transitionOrbState,
|
||||
transitionRunState,
|
||||
} from "./domain";
|
||||
|
||||
const runTransition = (from: string, to: string) =>
|
||||
Effect.runSync(
|
||||
Effect.flip(transitionOrbState({ from: from as never, to: to as never }))
|
||||
);
|
||||
|
||||
const runTransitionOk = (from: string, to: string) =>
|
||||
Effect.runSync(transitionOrbState({ from: from as never, to: to as never }));
|
||||
|
||||
const runRunState = (from: string, to: string) =>
|
||||
Effect.runSync(
|
||||
Effect.flip(transitionRunState({ from: from as never, to: to as never }))
|
||||
);
|
||||
|
||||
const runRunStateOk = (from: string, to: string) =>
|
||||
Effect.runSync(transitionRunState({ from: from as never, to: to as never }));
|
||||
|
||||
describe("orbActorKey", () => {
|
||||
it("produces a stable key from the identity triple", () => {
|
||||
const key = orbActorKey({
|
||||
projectId: "prj-1",
|
||||
runId: "run-1",
|
||||
workUnitId: "wrk-1",
|
||||
});
|
||||
expect(key).toBe("project:prj-1:work:wrk-1:run:run-1");
|
||||
});
|
||||
|
||||
it("preserves order across different identities", () => {
|
||||
expect(orbActorKey({ projectId: "a", runId: "b", workUnitId: "c" })).toBe(
|
||||
"project:a:work:c:run:b"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transitionOrbState", () => {
|
||||
it("allows creating -> prepared", () => {
|
||||
expect(runTransitionOk("creating", "prepared")).toBe("prepared");
|
||||
});
|
||||
|
||||
it("allows prepared -> running", () => {
|
||||
expect(runTransitionOk("prepared", "running")).toBe("running");
|
||||
});
|
||||
|
||||
it("allows running -> needs-input", () => {
|
||||
expect(runTransitionOk("running", "needs-input")).toBe("needs-input");
|
||||
});
|
||||
|
||||
it("allows running -> completed", () => {
|
||||
expect(runTransitionOk("running", "completed")).toBe("completed");
|
||||
});
|
||||
|
||||
it("allows completed -> disposed", () => {
|
||||
expect(runTransitionOk("completed", "disposed")).toBe("disposed");
|
||||
});
|
||||
|
||||
it("allows needs-input -> running (resume)", () => {
|
||||
expect(runTransitionOk("needs-input", "running")).toBe("running");
|
||||
});
|
||||
|
||||
it("rejects disposed -> running", () => {
|
||||
const error = runTransition("disposed", "running");
|
||||
expect(error).toBeInstanceOf(OrbStateError);
|
||||
expect(error.reason).toBe("AlreadyDisposed");
|
||||
});
|
||||
|
||||
it("rejects completed -> creating", () => {
|
||||
const error = runTransition("completed", "creating");
|
||||
expect(error).toBeInstanceOf(OrbStateError);
|
||||
expect(error.reason).toBe("InvalidTransition");
|
||||
});
|
||||
|
||||
it("rejects prepared -> needs-input (skipping running)", () => {
|
||||
const error = runTransition("prepared", "needs-input");
|
||||
expect(error).toBeInstanceOf(OrbStateError);
|
||||
expect(error.reason).toBe("InvalidTransition");
|
||||
});
|
||||
|
||||
it("rejects cancelled -> running", () => {
|
||||
const error = runTransition("cancelled", "running");
|
||||
expect(error).toBeInstanceOf(OrbStateError);
|
||||
expect(error.reason).toBe("InvalidTransition");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transitionRunState", () => {
|
||||
it("allows queued -> provisioning", () => {
|
||||
expect(runRunStateOk("queued", "provisioning")).toBe("provisioning");
|
||||
});
|
||||
|
||||
it("allows provisioning -> preparing", () => {
|
||||
expect(runRunStateOk("provisioning", "preparing")).toBe("preparing");
|
||||
});
|
||||
|
||||
it("allows running -> verifying", () => {
|
||||
expect(runRunStateOk("running", "verifying")).toBe("verifying");
|
||||
});
|
||||
|
||||
it("allows verifying -> succeeded", () => {
|
||||
expect(runRunStateOk("verifying", "succeeded")).toBe("succeeded");
|
||||
});
|
||||
|
||||
it("allows running -> cancelled", () => {
|
||||
expect(runRunStateOk("running", "cancelled")).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("rejects succeeded -> running (terminal)", () => {
|
||||
const error = runRunState("succeeded", "running");
|
||||
expect(error).toBeInstanceOf(OrbStateError);
|
||||
expect(error.reason).toBe("InvalidTransition");
|
||||
});
|
||||
|
||||
it("rejects failed -> running (terminal)", () => {
|
||||
const error = runRunState("failed", "running");
|
||||
expect(error).toBeInstanceOf(OrbStateError);
|
||||
expect(error.reason).toBe("InvalidTransition");
|
||||
});
|
||||
|
||||
it("rejects queued -> succeeded (skipping steps)", () => {
|
||||
const error = runRunState("queued", "succeeded");
|
||||
expect(error).toBeInstanceOf(OrbStateError);
|
||||
expect(error.reason).toBe("InvalidTransition");
|
||||
});
|
||||
});
|
||||
@@ -1,259 +0,0 @@
|
||||
/* eslint-disable max-classes-per-file -- each domain failure has a distinct tagged reason. */
|
||||
import { Effect, Schema } from "effect";
|
||||
|
||||
const MeaningfulString = Schema.String.check(
|
||||
Schema.makeFilter((value) => value.trim().length > 0, {
|
||||
expected: "a non-empty string",
|
||||
})
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Branded identifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const OrbId = MeaningfulString.pipe(Schema.brand("OrbId"));
|
||||
export type OrbId = typeof OrbId.Type;
|
||||
|
||||
export const OrbRunId = MeaningfulString.pipe(Schema.brand("OrbRunId"));
|
||||
export type OrbRunId = typeof OrbRunId.Type;
|
||||
|
||||
export const OrbSessionId = MeaningfulString.pipe(Schema.brand("OrbSessionId"));
|
||||
export type OrbSessionId = typeof OrbSessionId.Type;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actor identity — one Orb per project/issue/run triple
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const OrbIdentity = Schema.Struct({
|
||||
projectId: MeaningfulString,
|
||||
runId: MeaningfulString,
|
||||
workUnitId: MeaningfulString,
|
||||
});
|
||||
export type OrbIdentity = typeof OrbIdentity.Type;
|
||||
|
||||
/** Stable RivetKit actor key derived from the identity triple. */
|
||||
export const orbActorKey = (identity: OrbIdentity): string =>
|
||||
`project:${identity.projectId}:work:${identity.workUnitId}:run:${identity.runId}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State machines — product state stays separate from run state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const OrbState = Schema.Literals([
|
||||
"creating",
|
||||
"prepared",
|
||||
"running",
|
||||
"needs-input",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"disposed",
|
||||
]);
|
||||
export type OrbState = typeof OrbState.Type;
|
||||
|
||||
export const RunState = Schema.Literals([
|
||||
"queued",
|
||||
"provisioning",
|
||||
"preparing",
|
||||
"running",
|
||||
"verifying",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]);
|
||||
export type RunState = typeof RunState.Type;
|
||||
|
||||
const ORB_TRANSITIONS: Readonly<Record<OrbState, readonly OrbState[]>> = {
|
||||
cancelled: ["disposed"],
|
||||
completed: ["disposed"],
|
||||
creating: ["prepared", "running", "failed", "cancelled", "disposed"],
|
||||
disposed: [],
|
||||
failed: ["disposed"],
|
||||
"needs-input": ["running", "completed", "failed", "cancelled", "disposed"],
|
||||
prepared: ["running", "failed", "cancelled", "disposed"],
|
||||
running: ["needs-input", "completed", "failed", "cancelled", "disposed"],
|
||||
};
|
||||
|
||||
const RUN_TRANSITIONS: Readonly<Record<RunState, readonly RunState[]>> = {
|
||||
cancelled: [],
|
||||
failed: [],
|
||||
preparing: ["running", "verifying", "failed", "cancelled"],
|
||||
provisioning: ["preparing", "running", "failed", "cancelled"],
|
||||
queued: ["provisioning", "preparing", "running", "failed", "cancelled"],
|
||||
running: ["verifying", "succeeded", "failed", "cancelled"],
|
||||
succeeded: [],
|
||||
verifying: ["succeeded", "failed", "cancelled"],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tagged errors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const OrbStateErrorReason = Schema.Literals([
|
||||
"InvalidTransition",
|
||||
"AlreadyDisposed",
|
||||
"NotRunning",
|
||||
]);
|
||||
export type OrbStateErrorReason = typeof OrbStateErrorReason.Type;
|
||||
|
||||
export class OrbStateError extends Schema.TaggedErrorClass<OrbStateError>()(
|
||||
"OrbStateError",
|
||||
{
|
||||
from: Schema.String,
|
||||
message: Schema.String,
|
||||
reason: OrbStateErrorReason,
|
||||
to: Schema.String,
|
||||
}
|
||||
) {}
|
||||
|
||||
export const OrbSandboxErrorReason = Schema.Literals([
|
||||
"DockerUnavailable",
|
||||
"ContainerStart",
|
||||
"CommandFailed",
|
||||
"ContainerCleanup",
|
||||
]);
|
||||
export type OrbSandboxErrorReason = typeof OrbSandboxErrorReason.Type;
|
||||
|
||||
export class OrbSandboxError extends Schema.TaggedErrorClass<OrbSandboxError>()(
|
||||
"OrbSandboxError",
|
||||
{
|
||||
message: Schema.String,
|
||||
reason: OrbSandboxErrorReason,
|
||||
}
|
||||
) {}
|
||||
|
||||
export const OrbSessionErrorReason = Schema.Literals([
|
||||
"OpenSession",
|
||||
"PromptFailed",
|
||||
"AgentNotInstalled",
|
||||
"SessionNotFound",
|
||||
"PermissionDenied",
|
||||
]);
|
||||
export type OrbSessionErrorReason = typeof OrbSessionErrorReason.Type;
|
||||
|
||||
export class OrbSessionError extends Schema.TaggedErrorClass<OrbSessionError>()(
|
||||
"OrbSessionError",
|
||||
{
|
||||
message: Schema.String,
|
||||
reason: OrbSessionErrorReason,
|
||||
}
|
||||
) {}
|
||||
|
||||
export const OrbConfigurationErrorReason = Schema.Literals([
|
||||
"MissingGateway",
|
||||
"MissingIdentity",
|
||||
"InvalidModelConfig",
|
||||
]);
|
||||
export type OrbConfigurationErrorReason =
|
||||
typeof OrbConfigurationErrorReason.Type;
|
||||
|
||||
export class OrbConfigurationError extends Schema.TaggedErrorClass<OrbConfigurationError>()(
|
||||
"OrbConfigurationError",
|
||||
{
|
||||
message: Schema.String,
|
||||
reason: OrbConfigurationErrorReason,
|
||||
}
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const OrbModelGatewayConfig = Schema.Struct({
|
||||
apiKey: MeaningfulString,
|
||||
baseUrl: MeaningfulString,
|
||||
model: MeaningfulString,
|
||||
provider: MeaningfulString,
|
||||
});
|
||||
export type OrbModelGatewayConfig = typeof OrbModelGatewayConfig.Type;
|
||||
|
||||
export const OrbProjectContext = Schema.Struct({
|
||||
artifacts: Schema.Array(
|
||||
Schema.Struct({
|
||||
content: Schema.String,
|
||||
path: MeaningfulString,
|
||||
})
|
||||
),
|
||||
contextFiles: Schema.Array(
|
||||
Schema.Struct({
|
||||
content: Schema.String,
|
||||
path: MeaningfulString,
|
||||
})
|
||||
),
|
||||
issueBody: MeaningfulString,
|
||||
issueTitle: MeaningfulString,
|
||||
repositoryUrl: Schema.UndefinedOr(MeaningfulString),
|
||||
});
|
||||
export type OrbProjectContext = typeof OrbProjectContext.Type;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transition helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const transitionOrbState = Effect.fn("Orb.transitionState")(
|
||||
function* transitionOrbState(input: {
|
||||
readonly from: OrbState;
|
||||
readonly to: OrbState;
|
||||
}) {
|
||||
if (input.from === input.to) {
|
||||
return input.to;
|
||||
}
|
||||
if (input.from === "disposed") {
|
||||
return yield* Effect.fail(
|
||||
new OrbStateError({
|
||||
from: input.from,
|
||||
message: "Orb is already disposed",
|
||||
reason: "AlreadyDisposed",
|
||||
to: input.to,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!ORB_TRANSITIONS[input.from].includes(input.to)) {
|
||||
return yield* Effect.fail(
|
||||
new OrbStateError({
|
||||
from: input.from,
|
||||
message: `Cannot transition orb from ${input.from} to ${input.to}`,
|
||||
reason: "InvalidTransition",
|
||||
to: input.to,
|
||||
})
|
||||
);
|
||||
}
|
||||
return input.to;
|
||||
}
|
||||
);
|
||||
|
||||
export const transitionRunState = Effect.fn("Orb.transitionRunState")(
|
||||
function* transitionRunState(input: {
|
||||
readonly from: RunState;
|
||||
readonly to: RunState;
|
||||
}) {
|
||||
if (input.from === input.to) {
|
||||
return input.to;
|
||||
}
|
||||
if (
|
||||
input.from === "succeeded" ||
|
||||
input.from === "failed" ||
|
||||
input.from === "cancelled"
|
||||
) {
|
||||
return yield* Effect.fail(
|
||||
new OrbStateError({
|
||||
from: input.from,
|
||||
message: `Run is already in terminal state ${input.from}`,
|
||||
reason: "InvalidTransition",
|
||||
to: input.to,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!RUN_TRANSITIONS[input.from].includes(input.to)) {
|
||||
return yield* Effect.fail(
|
||||
new OrbStateError({
|
||||
from: input.from,
|
||||
message: `Cannot transition run from ${input.from} to ${input.to}`,
|
||||
reason: "InvalidTransition",
|
||||
to: input.to,
|
||||
})
|
||||
);
|
||||
}
|
||||
return input.to;
|
||||
}
|
||||
);
|
||||
@@ -1,134 +0,0 @@
|
||||
/* eslint-disable no-non-null-assertion -- test assertions on defined events */
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { makeOrbEvent, normalizeSessionEvent, redactSecrets } from "./events";
|
||||
|
||||
describe("redactSecrets", () => {
|
||||
it("redacts api_key patterns", () => {
|
||||
const input = "Using api_key=sk-abc123def456ghi789jkl012mno345";
|
||||
const result = redactSecrets(input);
|
||||
expect(result).not.toContain("sk-abc123");
|
||||
expect(result).toContain("[REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts Bearer tokens", () => {
|
||||
const input = "Authorization: Bearer eyJhbGciOiJIUzI1";
|
||||
const result = redactSecrets(input);
|
||||
expect(result).toContain("Bearer [REDACTED]");
|
||||
expect(result).not.toContain("eyJhbGciOiJIUzI1");
|
||||
});
|
||||
|
||||
it("redacts token= patterns", () => {
|
||||
const input = 'token: "my-secret-token-value"';
|
||||
const result = redactSecrets(input);
|
||||
expect(result).not.toContain("my-secret-token-value");
|
||||
});
|
||||
|
||||
it("preserves non-secret text", () => {
|
||||
expect(redactSecrets("just regular text")).toBe("just regular text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeOrbEvent", () => {
|
||||
it("creates an event with sequence and timestamp", () => {
|
||||
const event = makeOrbEvent(5, "session_opened", { text: "opened" });
|
||||
expect(event.sequence).toBe(5);
|
||||
expect(event.type).toBe("session_opened");
|
||||
expect(event.text).toBe("opened");
|
||||
expect(event.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it("creates an event with command and exitCode", () => {
|
||||
const event = makeOrbEvent(3, "command_executed", {
|
||||
command: "bun test",
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(event.command).toBe("bun test");
|
||||
expect(event.exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeSessionEvent", () => {
|
||||
it("normalizes agent_message_chunk", () => {
|
||||
const event = normalizeSessionEvent({
|
||||
rawText: "Working on the fix",
|
||||
sequence: 5,
|
||||
sessionId: "sess-1",
|
||||
timestamp: "2026-07-24T12:00:00Z",
|
||||
type: "agent_message_chunk",
|
||||
});
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.type).toBe("agent_message_chunk");
|
||||
expect(event!.text).toBe("Working on the fix");
|
||||
expect(event!.sequence).toBe(5);
|
||||
});
|
||||
|
||||
it("normalizes agent_message to agent_message_completed", () => {
|
||||
const event = normalizeSessionEvent({
|
||||
content: [{ text: "Done", type: "text" }],
|
||||
sequence: 10,
|
||||
sessionId: "sess-1",
|
||||
timestamp: "2026-07-24T12:00:05Z",
|
||||
type: "agent_message",
|
||||
});
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.type).toBe("agent_message_completed");
|
||||
expect(event!.text).toBe("Done");
|
||||
});
|
||||
|
||||
it("normalizes tool_call", () => {
|
||||
const event = normalizeSessionEvent({
|
||||
sequence: 3,
|
||||
sessionId: "sess-1",
|
||||
timestamp: "2026-07-24T12:00:01Z",
|
||||
title: "bash",
|
||||
toolCallId: "tc-1",
|
||||
type: "tool_call",
|
||||
});
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.type).toBe("tool_call_started");
|
||||
expect(event!.toolName).toBe("bash");
|
||||
expect(event!.toolCallId).toBe("tc-1");
|
||||
});
|
||||
|
||||
it("normalizes permission_request", () => {
|
||||
const event = normalizeSessionEvent({
|
||||
sequence: 7,
|
||||
sessionId: "sess-1",
|
||||
timestamp: "2026-07-24T12:00:03Z",
|
||||
type: "permission_request",
|
||||
});
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.type).toBe("permission_requested");
|
||||
});
|
||||
|
||||
it("returns undefined for unmapped types", () => {
|
||||
const event = normalizeSessionEvent({
|
||||
sequence: 1,
|
||||
sessionId: "sess-1",
|
||||
timestamp: "2026-07-24T12:00:00Z",
|
||||
type: "session_config",
|
||||
});
|
||||
expect(event).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when type is missing", () => {
|
||||
const event = normalizeSessionEvent({
|
||||
sequence: 1,
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
expect(event).toBeUndefined();
|
||||
});
|
||||
|
||||
it("redacts secrets in persisted event copies", () => {
|
||||
const event = normalizeSessionEvent({
|
||||
rawText: "Using api_key=sk-secret123456789012345678",
|
||||
sequence: 2,
|
||||
sessionId: "sess-1",
|
||||
timestamp: "2026-07-24T12:00:00Z",
|
||||
type: "agent_message_chunk",
|
||||
});
|
||||
expect(event).toBeDefined();
|
||||
expect(event!.text).not.toContain("sk-secret");
|
||||
});
|
||||
});
|
||||
@@ -1,216 +0,0 @@
|
||||
import { Schema } from "effect";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalized Orb events — domain-meaningful, secret-free
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const OrbEventVariant = Schema.Literals([
|
||||
"session_opened",
|
||||
"agent_message_chunk",
|
||||
"agent_message_completed",
|
||||
"agent_thought_chunk",
|
||||
"tool_call_started",
|
||||
"tool_call_completed",
|
||||
"permission_requested",
|
||||
"permission_denied",
|
||||
"command_executed",
|
||||
"session_failed",
|
||||
"session_closed",
|
||||
"vm_booted",
|
||||
"vm_shutdown",
|
||||
]);
|
||||
export type OrbEventVariant = typeof OrbEventVariant.Type;
|
||||
|
||||
export const OrbEvent = Schema.Struct({
|
||||
command: Schema.UndefinedOr(Schema.String),
|
||||
exitCode: Schema.UndefinedOr(Schema.Int),
|
||||
sequence: Schema.Number,
|
||||
text: Schema.UndefinedOr(Schema.String),
|
||||
timestamp: Schema.String,
|
||||
toolCallId: Schema.UndefinedOr(Schema.String),
|
||||
toolName: Schema.UndefinedOr(Schema.String),
|
||||
type: OrbEventVariant,
|
||||
});
|
||||
export type OrbEvent = typeof OrbEvent.Type;
|
||||
|
||||
/** Construct an OrbEvent with auto-incrementing sequence and timestamp. */
|
||||
export const makeOrbEvent = (
|
||||
sequence: number,
|
||||
type: OrbEventVariant,
|
||||
fields?: Partial<Omit<OrbEvent, "sequence" | "timestamp" | "type">>
|
||||
): OrbEvent =>
|
||||
({
|
||||
command: fields?.command ?? undefined,
|
||||
exitCode: fields?.exitCode ?? undefined,
|
||||
sequence,
|
||||
text: fields?.text ?? undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
toolCallId: fields?.toolCallId ?? undefined,
|
||||
toolName: fields?.toolName ?? undefined,
|
||||
type,
|
||||
}) as unknown as OrbEvent;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Secret redaction — applied only to persisted/logged event copies,
|
||||
// never to prompts sent to the model.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const REDACT_PATTERNS = [
|
||||
/(?:api[_-]?key|token|secret|password|credential)["'\s:=]+(?<value>[^\s"'},]+)/giu,
|
||||
/sk-(?<key>[a-zA-Z0-9]{20,})/gu,
|
||||
/Bearer\s+[a-zA-Z0-9._-]+/gu,
|
||||
];
|
||||
|
||||
export const redactSecrets = (text: string): string => {
|
||||
let result = text;
|
||||
for (const pattern of REDACT_PATTERNS) {
|
||||
result = result.replaceAll(pattern, (match) =>
|
||||
match.toLowerCase().includes("bearer")
|
||||
? "Bearer [REDACTED]"
|
||||
: "[REDACTED]"
|
||||
);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Translation from AgentOS session stream entries to normalized OrbEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AcpSessionUpdateLike {
|
||||
readonly type?: string;
|
||||
readonly sessionUpdate?: string;
|
||||
readonly rawText?: string;
|
||||
readonly text?: string;
|
||||
readonly content?: unknown;
|
||||
readonly toolCallId?: string | null;
|
||||
readonly toolCallStatus?: string;
|
||||
readonly title?: string | null;
|
||||
readonly sessionId?: string;
|
||||
}
|
||||
|
||||
interface SessionStreamEntryLike {
|
||||
readonly afterSequence?: number;
|
||||
readonly content?: unknown;
|
||||
readonly durability?: string;
|
||||
readonly rawText?: string;
|
||||
readonly sequence?: number;
|
||||
readonly sessionId?: string;
|
||||
readonly sessionUpdate?: string;
|
||||
readonly text?: string;
|
||||
readonly timestamp?: string;
|
||||
readonly title?: string | null;
|
||||
readonly toolCallId?: string | null;
|
||||
readonly type?: string;
|
||||
}
|
||||
|
||||
const IGNORED_TYPES = new Set([
|
||||
"session_config",
|
||||
"agent_description",
|
||||
"agent_capability",
|
||||
]);
|
||||
|
||||
const MESSAGE_TYPES = new Set([
|
||||
"agent_message_chunk",
|
||||
"agent_message",
|
||||
"agent_thought_chunk",
|
||||
]);
|
||||
|
||||
const TOOL_CALL_TYPES = new Set([
|
||||
"tool_call",
|
||||
"tool_call_status",
|
||||
"tool_call_update",
|
||||
]);
|
||||
|
||||
const extractText = (entry: AcpSessionUpdateLike): string | undefined => {
|
||||
if (entry.rawText !== undefined) {
|
||||
return entry.rawText;
|
||||
}
|
||||
if (entry.text !== undefined) {
|
||||
return entry.text;
|
||||
}
|
||||
if (typeof entry.content === "string") {
|
||||
return entry.content;
|
||||
}
|
||||
if (Array.isArray(entry.content)) {
|
||||
const texts = entry.content
|
||||
.filter(
|
||||
(block): block is { readonly type: string; readonly text?: unknown } =>
|
||||
typeof block === "object" && block !== null && "type" in block
|
||||
)
|
||||
.map((block) => (typeof block.text === "string" ? block.text : undefined))
|
||||
.filter((text): text is string => text !== undefined);
|
||||
return texts.length > 0 ? texts.join("") : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const optionalToolFields = (
|
||||
raw: SessionStreamEntryLike
|
||||
): { toolCallId?: string; toolName?: string } => ({
|
||||
...(raw.toolCallId === null || raw.toolCallId === undefined
|
||||
? {}
|
||||
: { toolCallId: raw.toolCallId }),
|
||||
...(raw.title === null || raw.title === undefined
|
||||
? {}
|
||||
: { toolName: raw.title }),
|
||||
});
|
||||
|
||||
const fromMessage = (
|
||||
type: string,
|
||||
sequence: number,
|
||||
raw: SessionStreamEntryLike
|
||||
): OrbEvent | undefined => {
|
||||
const text = extractText(raw);
|
||||
if (text === undefined) {
|
||||
// agent_message completes even without text; chunk/thought do not.
|
||||
return type === "agent_message"
|
||||
? makeOrbEvent(sequence, "agent_message_completed", {})
|
||||
: undefined;
|
||||
}
|
||||
const variant = (
|
||||
type === "agent_message" ? "agent_message_completed" : type
|
||||
) as OrbEventVariant;
|
||||
return makeOrbEvent(sequence, variant, { text: redactSecrets(text) });
|
||||
};
|
||||
|
||||
const fromToolCall = (
|
||||
type: string,
|
||||
sequence: number,
|
||||
raw: SessionStreamEntryLike
|
||||
): OrbEvent =>
|
||||
makeOrbEvent(
|
||||
sequence,
|
||||
type === "tool_call" ? "tool_call_started" : "tool_call_completed",
|
||||
optionalToolFields(raw)
|
||||
);
|
||||
|
||||
/**
|
||||
* Translate one AgentOS SessionStreamEntry into zero or one normalized OrbEvent.
|
||||
* Returns undefined for event types that have no domain-meaningful mapping yet.
|
||||
* Secret redaction is applied so persisted event copies never leak credentials.
|
||||
*/
|
||||
export const normalizeSessionEvent = (
|
||||
raw: SessionStreamEntryLike
|
||||
): OrbEvent | undefined => {
|
||||
const type = raw.type ?? raw.sessionUpdate;
|
||||
if (type === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const sequence = raw.sequence ?? 0;
|
||||
if (IGNORED_TYPES.has(type)) {
|
||||
return undefined;
|
||||
}
|
||||
if (type === "permission_request") {
|
||||
return makeOrbEvent(sequence, "permission_requested", {
|
||||
text: "Permission requested",
|
||||
});
|
||||
}
|
||||
if (MESSAGE_TYPES.has(type)) {
|
||||
return fromMessage(type, sequence, raw);
|
||||
}
|
||||
if (TOOL_CALL_TYPES.has(type)) {
|
||||
return fromToolCall(type, sequence, raw);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -1,81 +0,0 @@
|
||||
import { runPostRunGiteaLifecycle } from "../git/gitea";
|
||||
import type { GitCommandRunner, GiteaTransport } from "../git/gitea";
|
||||
import type {
|
||||
GitLifecyclePort,
|
||||
GitLifecycleResult,
|
||||
GitPublishInput,
|
||||
OrbRunPort,
|
||||
} from "./ports";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OrbGitCommandRunner — runs git commands inside the Orb sandbox
|
||||
//
|
||||
// Adapts the OrbRunPort.executeCommand interface to the GitCommandRunner
|
||||
// shape expected by runPostRunGiteaLifecycle. Commands execute inside the
|
||||
// Docker sandbox where OpenCode made its changes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const createOrbGitRunner = (orb: OrbRunPort): GitCommandRunner => ({
|
||||
run(
|
||||
command: string,
|
||||
options?: { readonly cwd?: string; readonly env?: Record<string, string> }
|
||||
) {
|
||||
const cwd = options?.cwd ?? "/mnt/sandbox/repository";
|
||||
return orb.executeCommand(command, cwd);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Git lifecycle adapter factory
|
||||
//
|
||||
// Creates a GitLifecyclePort bound to one Orb run. The command runner executes
|
||||
// git inside that Orb's sandbox; the Gitea transport creates PRs via HTTP.
|
||||
// This is the application-layer entry point — no interactive Flue shell needed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const createGitLifecyclePort = (
|
||||
orb: OrbRunPort,
|
||||
transport: GiteaTransport
|
||||
): GitLifecyclePort => {
|
||||
const runner = createOrbGitRunner(orb);
|
||||
|
||||
return {
|
||||
async publish(input: GitPublishInput): Promise<GitLifecycleResult> {
|
||||
const result = await runPostRunGiteaLifecycle({
|
||||
baseBranch: input.baseBranch,
|
||||
body: `Verified changes for project issue #${input.issueNumber}. Merge remains a manual review action.`,
|
||||
...(input.commitMessage === undefined
|
||||
? {}
|
||||
: { commitMessage: input.commitMessage }),
|
||||
issueNumber: input.issueNumber,
|
||||
issueTitle: input.issueTitle,
|
||||
repositoryPath: input.repositoryPath,
|
||||
runner,
|
||||
title: `Issue #${input.issueNumber}: ${input.issueTitle}`,
|
||||
transport,
|
||||
verification: input.verification,
|
||||
workspace: input.workspace,
|
||||
});
|
||||
|
||||
return {
|
||||
baseBranch: result.baseBranch,
|
||||
branch: result.branch,
|
||||
...(result.commitSha === undefined
|
||||
? {}
|
||||
: { commitSha: result.commitSha }),
|
||||
...(result.pullRequest === undefined
|
||||
? {}
|
||||
: {
|
||||
pullRequest: {
|
||||
baseBranch: result.pullRequest.baseBranch,
|
||||
branch: result.pullRequest.branch,
|
||||
number: result.pullRequest.number,
|
||||
status: result.pullRequest.status,
|
||||
url: result.pullRequest.url,
|
||||
},
|
||||
}),
|
||||
status: result.status,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
// oxlint-disable-next-line no-barrel-file -- The Orb module exposes its public surface here.
|
||||
export * from "./domain";
|
||||
export * from "./events";
|
||||
export * from "./docker-sandbox";
|
||||
export * from "./opencode-config";
|
||||
export * from "./permission-policy";
|
||||
export * from "./runtime";
|
||||
export * from "./ports";
|
||||
export * from "./project-events";
|
||||
export * from "./context-pack";
|
||||
export * from "./orb-project-manager";
|
||||
export * from "./orb-adapter";
|
||||
export * from "./git-adapter";
|
||||
@@ -1,82 +0,0 @@
|
||||
/* eslint-disable no-non-null-assertion -- test assertions on defined objects */
|
||||
import { Effect } from "effect";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { OrbConfigurationError } from "./domain";
|
||||
import {
|
||||
opencodeSetupCommands,
|
||||
prepareOpenCodeConfig,
|
||||
} from "./opencode-config";
|
||||
|
||||
const validGateway = {
|
||||
apiKey: "test-key-123",
|
||||
baseUrl: "https://gateway.example.com/v1",
|
||||
model: "test-model",
|
||||
provider: "test-provider",
|
||||
};
|
||||
|
||||
const validContext = {
|
||||
artifacts: [],
|
||||
contextFiles: [],
|
||||
issueBody: "Fix the bug",
|
||||
issueTitle: "Bug fix",
|
||||
repositoryUrl: undefined,
|
||||
};
|
||||
|
||||
describe("prepareOpenCodeConfig", () => {
|
||||
it("produces valid config JSON with provider and model", () => {
|
||||
const result = Effect.runSync(
|
||||
prepareOpenCodeConfig({
|
||||
context: validContext,
|
||||
gateway: validGateway,
|
||||
})
|
||||
);
|
||||
const parsed = JSON.parse(result.configJson);
|
||||
expect(parsed.model).toBe("test-provider/test-model");
|
||||
expect(parsed.provider["test-provider"].baseUrl).toBe(
|
||||
"https://gateway.example.com/v1"
|
||||
);
|
||||
expect(parsed.provider["test-provider"].apiKey).toBe("test-key-123");
|
||||
});
|
||||
|
||||
it("sets config path under opencode config directory", () => {
|
||||
const result = Effect.runSync(
|
||||
prepareOpenCodeConfig({
|
||||
context: validContext,
|
||||
gateway: validGateway,
|
||||
})
|
||||
);
|
||||
expect(result.configPath).toContain("opencode");
|
||||
expect(result.configPath).toContain("config.json");
|
||||
expect(result.instructionsPath).toBe("/workspace/control/issue.md");
|
||||
});
|
||||
|
||||
it("rejects empty base URL", () => {
|
||||
const error = Effect.runSync(
|
||||
Effect.flip(
|
||||
prepareOpenCodeConfig({
|
||||
context: validContext,
|
||||
gateway: { ...validGateway, baseUrl: " " },
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(error).toBeInstanceOf(OrbConfigurationError);
|
||||
expect(error.reason).toBe("InvalidModelConfig");
|
||||
});
|
||||
});
|
||||
|
||||
describe("opencodeSetupCommands", () => {
|
||||
it("produces mkdir, write, and chmod commands", () => {
|
||||
const config = Effect.runSync(
|
||||
prepareOpenCodeConfig({
|
||||
context: validContext,
|
||||
gateway: validGateway,
|
||||
})
|
||||
);
|
||||
const commands = opencodeSetupCommands(config);
|
||||
expect(commands.length).toBe(3);
|
||||
expect(commands[0]).toContain("mkdir");
|
||||
expect(commands[1]).toContain("cat >");
|
||||
expect(commands[2]).toContain("chmod 600");
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Effect } from "effect";
|
||||
|
||||
import { OrbConfigurationError } from "./domain";
|
||||
import type { OrbModelGatewayConfig, OrbProjectContext } from "./domain";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenCode configuration — prepared inside the AgentOS VM filesystem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OPENCODE_CONFIG_DIR = "/root/.config/opencode";
|
||||
const OPENCODE_CONFIG_PATH = `${OPENCODE_CONFIG_DIR}/config.json`;
|
||||
const AGENT_INSTRUCTIONS_PATH = "/workspace/control/issue.md";
|
||||
|
||||
export interface PreparedOpenCodeConfig {
|
||||
readonly configJson: string;
|
||||
readonly configPath: string;
|
||||
readonly instructionsPath: string;
|
||||
}
|
||||
|
||||
const validateGateway = (
|
||||
gateway: OrbModelGatewayConfig
|
||||
): Effect.Effect<void, OrbConfigurationError> =>
|
||||
gateway.baseUrl.trim().length === 0
|
||||
? Effect.fail(
|
||||
new OrbConfigurationError({
|
||||
message: "Model gateway base URL must not be empty",
|
||||
reason: "InvalidModelConfig",
|
||||
})
|
||||
)
|
||||
: Effect.void;
|
||||
|
||||
/**
|
||||
* Build the OpenCode configuration JSON and file layout for one Orb run.
|
||||
* The configuration points OpenCode at the model gateway with run-scoped
|
||||
* credentials injected at runtime — never committed to project files.
|
||||
*/
|
||||
export const prepareOpenCodeConfig = Effect.fn("Orb.prepareOpenCodeConfig")(
|
||||
function* prepareOpenCodeConfig(input: {
|
||||
readonly context: OrbProjectContext;
|
||||
readonly gateway: OrbModelGatewayConfig;
|
||||
}) {
|
||||
yield* validateGateway(input.gateway);
|
||||
|
||||
const config = {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
model: `${input.gateway.provider}/${input.gateway.model}`,
|
||||
provider: {
|
||||
[input.gateway.provider]: {
|
||||
apiKey: input.gateway.apiKey,
|
||||
baseUrl: input.gateway.baseUrl,
|
||||
models: {
|
||||
[input.gateway.model]: {
|
||||
name: input.gateway.model,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const configJson = JSON.stringify(config, null, 2);
|
||||
|
||||
return {
|
||||
configJson,
|
||||
configPath: OPENCODE_CONFIG_PATH,
|
||||
instructionsPath: AGENT_INSTRUCTIONS_PATH,
|
||||
} satisfies PreparedOpenCodeConfig;
|
||||
}
|
||||
);
|
||||
|
||||
/** Shell commands that stage the OpenCode config directory inside a VM. */
|
||||
export const opencodeSetupCommands = (config: PreparedOpenCodeConfig) =>
|
||||
[
|
||||
`mkdir -p ${OPENCODE_CONFIG_DIR}`,
|
||||
`cat > ${config.configPath} << 'ORB_EOF'\n${config.configJson}\nORB_EOF`,
|
||||
`chmod 600 ${config.configPath}`,
|
||||
] as const;
|
||||
@@ -1,132 +0,0 @@
|
||||
import { Effect } from "effect";
|
||||
|
||||
import type { OrbEvent } from "./events";
|
||||
import type {
|
||||
CommandResult,
|
||||
OrbAdapter,
|
||||
OrbCreatePortInput,
|
||||
OrbRunPort,
|
||||
PrepareRepoInput,
|
||||
} from "./ports";
|
||||
import { OrbRuntime } from "./runtime";
|
||||
import type { OrbEnv, OrbHandle } from "./runtime";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RealOrbRun — wraps an OrbHandle behind the OrbRunPort interface
|
||||
//
|
||||
// Effect-based Orb methods are run to Promise here so the orchestrator and
|
||||
// tests work with plain async/await. Effect failures surface as rejections.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class RealOrbRun implements OrbRunPort {
|
||||
private readonly listeners = new Set<(event: OrbEvent) => void>();
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private readonly handle: OrbHandle;
|
||||
|
||||
constructor(handle: OrbHandle) {
|
||||
this.handle = handle;
|
||||
}
|
||||
|
||||
/** Called once after the handle-level listener is wired. */
|
||||
_setUnsubscribe(fn: () => void): void {
|
||||
this.unsubscribe = fn;
|
||||
}
|
||||
|
||||
get orbId(): string {
|
||||
return this.handle.id;
|
||||
}
|
||||
|
||||
get runId(): string {
|
||||
return this.handle.runId;
|
||||
}
|
||||
|
||||
get sessionId(): string | undefined {
|
||||
return this.handle.currentSessionId;
|
||||
}
|
||||
|
||||
get state(): string {
|
||||
return this.handle.state;
|
||||
}
|
||||
|
||||
onEvent = (listener: (event: OrbEvent) => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
/** Forward an OrbHandle event to all port-level listeners. */
|
||||
forwardEvent = (event: OrbEvent): void => {
|
||||
for (const listener of this.listeners) {
|
||||
listener(event);
|
||||
}
|
||||
};
|
||||
|
||||
prepareRepository = async (input: PrepareRepoInput): Promise<void> => {
|
||||
await Effect.runPromise(this.handle.prepareRepository(input));
|
||||
};
|
||||
|
||||
openSession = async (): Promise<string> =>
|
||||
(await Effect.runPromise(this.handle.openSession())) as string;
|
||||
|
||||
sendTask = async (prompt: string): Promise<unknown> =>
|
||||
await Effect.runPromise(this.handle.sendTask(prompt));
|
||||
|
||||
executeCommand = async (
|
||||
command: string,
|
||||
cwd?: string
|
||||
): Promise<CommandResult> => {
|
||||
const result = (await Effect.runPromise(
|
||||
this.handle.executeCommand({
|
||||
command,
|
||||
...(cwd === undefined ? {} : { cwd }),
|
||||
})
|
||||
)) as { exitCode: number | null; stderr: string; stdout: string };
|
||||
return {
|
||||
exitCode: result.exitCode ?? -1,
|
||||
stderr: result.stderr,
|
||||
stdout: result.stdout,
|
||||
};
|
||||
};
|
||||
|
||||
cancel = async (): Promise<void> => {
|
||||
await Effect.runPromise(this.handle.cancel().pipe(Effect.ignore));
|
||||
};
|
||||
|
||||
dispose = async (): Promise<void> => {
|
||||
this.unsubscribe?.();
|
||||
this.listeners.clear();
|
||||
await Effect.runPromise(this.handle.dispose().pipe(Effect.ignore));
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real Orb adapter — wraps OrbRuntime.createOrb behind the OrbAdapter port
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const createOrbAdapter = (env?: OrbEnv): OrbAdapter => {
|
||||
const runtime = new OrbRuntime(env);
|
||||
|
||||
return {
|
||||
createOrb: async (input: OrbCreatePortInput): Promise<OrbRunPort> => {
|
||||
const handle = await Effect.runPromise(
|
||||
runtime.createOrb({
|
||||
context: input.context,
|
||||
docker: input.docker,
|
||||
gateway: input.gateway,
|
||||
identity: input.identity,
|
||||
})
|
||||
);
|
||||
|
||||
const run = new RealOrbRun(handle);
|
||||
|
||||
// Bridge OrbHandle events to port listeners via a single forwarding point.
|
||||
const unsubscribe = handle.onEvent((event) => {
|
||||
run.forwardEvent(event);
|
||||
});
|
||||
run._setUnsubscribe(unsubscribe);
|
||||
|
||||
return run;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,132 +0,0 @@
|
||||
/* eslint-disable no-console -- integration test is a CLI-style probe */
|
||||
/**
|
||||
* Opt-in live integration test for the Orb-wired project-manager.
|
||||
*
|
||||
* Only runs when ORB_PM_INTEGRATION=1 is set. Requires:
|
||||
* - Docker daemon
|
||||
* - Model gateway credentials (ORB_GATEWAY_* or AGENT_MODEL_*)
|
||||
* - A local Gitea instance with a test repo (optional — set GITEA_*)
|
||||
*
|
||||
* This test exercises the full flow: Orb creation, repo preparation, session
|
||||
* open, model turn, Git lifecycle (or skip if Gitea is not configured).
|
||||
*
|
||||
* Usage:
|
||||
* ORB_PM_INTEGRATION=1 \
|
||||
* ORB_GATEWAY_API_KEY=... \
|
||||
* ORB_GATEWAY_BASE_URL=https://ai.example.com/v1 \
|
||||
* ORB_GATEWAY_MODEL=glm-5.2 \
|
||||
* ORB_GATEWAY_PROVIDER=cheaptricks \
|
||||
* bun test packages/agents/src/orb/orb-project-manager.live.test.ts
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createGiteaHttpTransport } from "../git/gitea";
|
||||
import { createGitLifecyclePort } from "./git-adapter";
|
||||
import { createOrbAdapter } from "./orb-adapter";
|
||||
import { OrbProjectManager } from "./orb-project-manager";
|
||||
|
||||
const env = (key: string): string | undefined => process.env[key];
|
||||
const isLive = env("ORB_PM_INTEGRATION") === "1";
|
||||
|
||||
const resolveGateway = () => {
|
||||
const apiKey = env("ORB_GATEWAY_API_KEY") ?? env("AGENT_MODEL_API_KEY");
|
||||
const baseUrl = env("ORB_GATEWAY_BASE_URL") ?? env("AGENT_MODEL_BASE_URL");
|
||||
const model = env("ORB_GATEWAY_MODEL") ?? env("AGENT_MODEL_NAME");
|
||||
const provider = env("ORB_GATEWAY_PROVIDER") ?? env("AGENT_MODEL_PROVIDER");
|
||||
if (!apiKey || !baseUrl || !model || !provider) {
|
||||
return null;
|
||||
}
|
||||
return { apiKey, baseUrl, model, provider };
|
||||
};
|
||||
|
||||
const hasGitea = () =>
|
||||
env("GITEA_URL") !== undefined && env("GITEA_TOKEN") !== undefined;
|
||||
|
||||
describe.skipIf(!isLive)("OrbProjectManager live integration", () => {
|
||||
it("creates an Orb, prepares repo, sends a model turn, and projects events", async () => {
|
||||
const gateway = resolveGateway();
|
||||
expect(gateway, "Gateway credentials required").not.toBeNull();
|
||||
|
||||
const adapter = createOrbAdapter();
|
||||
const events: { type: string; text?: string }[] = [];
|
||||
|
||||
const pm = new OrbProjectManager({
|
||||
createGitLifecycle: (orb) =>
|
||||
createGitLifecyclePort(
|
||||
orb,
|
||||
createGiteaHttpTransport({
|
||||
baseUrl: env("GITEA_URL") ?? "http://localhost:3000",
|
||||
token: env("GITEA_TOKEN") ?? "",
|
||||
})
|
||||
),
|
||||
onProjectEvent: (e) => events.push({ text: e.text, type: e.type }),
|
||||
orbAdapter: adapter,
|
||||
});
|
||||
|
||||
const result = await pm.startIssue({
|
||||
baseBranch: "main",
|
||||
branchName: `work/orb-pm-test-${Date.now()}`,
|
||||
context: {
|
||||
artifacts: [],
|
||||
contextFiles: [],
|
||||
issueBody: "Reply with WORK_COMPLETE: hello world test passed",
|
||||
issueTitle: "Integration: model echo",
|
||||
repositoryUrl: undefined,
|
||||
},
|
||||
contextPack: {
|
||||
artifacts: [],
|
||||
contextFiles: [],
|
||||
evidence: [],
|
||||
issueBody: "Reply with WORK_COMPLETE: hello world test passed",
|
||||
issueNumber: 1,
|
||||
issueTitle: "Integration: model echo",
|
||||
repositoryMetadata: {
|
||||
baseBranch: "main",
|
||||
repositoryName: "orb-pm-test",
|
||||
repositoryUrl: "local",
|
||||
},
|
||||
},
|
||||
docker: {
|
||||
hostWorkspacePath: `/tmp/orb-pm-test-${Date.now()}`,
|
||||
},
|
||||
gateway: gateway ?? {
|
||||
apiKey: "",
|
||||
baseUrl: "",
|
||||
model: "",
|
||||
provider: "",
|
||||
},
|
||||
issueId: `orb-pm-integration-${Date.now()}`,
|
||||
issueNumber: 1,
|
||||
issueTitle: "Integration: model echo",
|
||||
projectId: "orb-pm-test",
|
||||
runId: `run-${Date.now()}`,
|
||||
});
|
||||
|
||||
expect(result.orbId).toBeDefined();
|
||||
expect(result.sessionId).toBeDefined();
|
||||
|
||||
const eventTypes = events.map((e) => e.type);
|
||||
expect(eventTypes).toContain("run.started");
|
||||
expect(eventTypes).toContain("run.repository_prepared");
|
||||
expect(eventTypes).toContain("run.session_opened");
|
||||
|
||||
// If Gitea is configured, attempt the full Git lifecycle.
|
||||
if (hasGitea()) {
|
||||
console.log("[orb-pm] Gitea configured — attempting Git lifecycle...");
|
||||
try {
|
||||
const gitResult = await pm.complete({
|
||||
commitMessage: "test: orb project-manager integration",
|
||||
issueId: result.issueId,
|
||||
});
|
||||
console.log(`[orb-pm] Git lifecycle result: ${gitResult.status}`);
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`[orb-pm] Git lifecycle failed (expected in CI): ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await pm.cancel(result.issueId);
|
||||
console.log(`[orb-pm] Events: ${eventTypes.join(", ")}`);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -1,709 +0,0 @@
|
||||
/* eslint-disable no-non-null-assertion -- test assertions on controlled fakes */
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { makeOrbEvent } from "./events";
|
||||
import type { OrbEvent } from "./events";
|
||||
import { OrbProjectManager, ProjectManagerError } from "./orb-project-manager";
|
||||
import type {
|
||||
GitLifecyclePort,
|
||||
GitLifecycleResult,
|
||||
GitPublishInput,
|
||||
OrbAdapter,
|
||||
OrbCreatePortInput,
|
||||
OrbRunPort,
|
||||
PrepareRepoInput,
|
||||
ProjectArtifact,
|
||||
} from "./ports";
|
||||
import type { ProjectRunEvent } from "./project-events";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake Orb run — records calls and emits configurable events on sendTask
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FakeOrbConfig {
|
||||
/** Events to emit when sendTask resolves, mapped by call index. */
|
||||
readonly eventsByTurn?: readonly (readonly OrbEvent[])[];
|
||||
/** Default events to emit on every sendTask if no per-turn mapping. */
|
||||
readonly defaultEvents?: readonly OrbEvent[];
|
||||
/** If true, sendTask rejects on the first call. */
|
||||
readonly failOnFirstSend?: boolean;
|
||||
/** If true, prepareRepository rejects. */
|
||||
readonly failOnPrepare?: boolean;
|
||||
}
|
||||
|
||||
class FakeOrbRun implements OrbRunPort {
|
||||
readonly orbId: string;
|
||||
readonly runId: string;
|
||||
sessionId: string | undefined;
|
||||
state = "running";
|
||||
private readonly config: FakeOrbConfig;
|
||||
|
||||
readonly sentTasks: string[] = [];
|
||||
readonly prepareCalls: PrepareRepoInput[] = [];
|
||||
cancelCalled = false;
|
||||
disposeCalled = false;
|
||||
|
||||
private listeners = new Set<(event: OrbEvent) => void>();
|
||||
private sendCallCount = 0;
|
||||
|
||||
constructor(orbId: string, runId: string, config: FakeOrbConfig) {
|
||||
this.orbId = orbId;
|
||||
this.runId = runId;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
onEvent(listener: (event: OrbEvent) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private emit(events: readonly OrbEvent[]): void {
|
||||
for (const event of events) {
|
||||
for (const listener of this.listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prepareRepository(input: PrepareRepoInput): Promise<void> {
|
||||
this.prepareCalls.push(input);
|
||||
if (this.config.failOnPrepare) {
|
||||
return Promise.reject(new Error("Fake: prepareRepository failed"));
|
||||
}
|
||||
this.state = "prepared";
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
openSession(): Promise<string> {
|
||||
this.sessionId = `session-${this.orbId}`;
|
||||
this.state = "running";
|
||||
return Promise.resolve(this.sessionId);
|
||||
}
|
||||
|
||||
sendTask(prompt: string): Promise<unknown> {
|
||||
this.sentTasks.push(prompt);
|
||||
const callIndex = this.sendCallCount;
|
||||
this.sendCallCount += 1;
|
||||
|
||||
if (callIndex === 0 && this.config.failOnFirstSend) {
|
||||
return Promise.reject(new Error("Fake: sendTask failed on first call"));
|
||||
}
|
||||
|
||||
const events =
|
||||
this.config.eventsByTurn?.[callIndex] ?? this.config.defaultEvents ?? [];
|
||||
this.emit(events);
|
||||
return Promise.resolve({ ok: true });
|
||||
}
|
||||
|
||||
executeCommand(
|
||||
command: string,
|
||||
_cwd?: string
|
||||
): Promise<{ exitCode: number; stderr: string; stdout: string }> {
|
||||
void this.config;
|
||||
return Promise.resolve({
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
stdout: `fake: ${command}`,
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): Promise<void> {
|
||||
this.cancelCalled = true;
|
||||
this.state = "cancelled";
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
this.disposeCalled = true;
|
||||
this.state = "disposed";
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake Orb adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const createFakeOrbAdapter = (
|
||||
config?: FakeOrbConfig,
|
||||
counter?: { value: number }
|
||||
): { adapter: OrbAdapter; runs: FakeOrbRun[] } => {
|
||||
const runs: FakeOrbRun[] = [];
|
||||
const cfg = config ?? {};
|
||||
const cnt = counter ?? { value: 0 };
|
||||
const adapter: OrbAdapter = {
|
||||
createOrb(_input: OrbCreatePortInput): Promise<OrbRunPort> {
|
||||
cnt.value += 1;
|
||||
const orbId = `orb-fake-${cnt.value}`;
|
||||
const runId = `run-fake-${cnt.value}`;
|
||||
const run = new FakeOrbRun(orbId, runId, cfg);
|
||||
runs.push(run);
|
||||
return Promise.resolve(run);
|
||||
},
|
||||
};
|
||||
return { adapter, runs };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake Git lifecycle adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FakeGitConfig {
|
||||
readonly result?: GitLifecycleResult;
|
||||
readonly failWith?: Error;
|
||||
readonly failOnFirstAttempt?: boolean;
|
||||
}
|
||||
|
||||
const createFakeGitLifecycle = (
|
||||
config?: FakeGitConfig
|
||||
): { port: GitLifecyclePort; publishCalls: GitPublishInput[] } => {
|
||||
const cfg = config ?? {};
|
||||
const publishCalls: GitPublishInput[] = [];
|
||||
let attemptCount = 0;
|
||||
|
||||
const defaultResult: GitLifecycleResult = {
|
||||
baseBranch: "main",
|
||||
branch: "work/issue-42",
|
||||
commitSha: "abc123def",
|
||||
pullRequest: {
|
||||
baseBranch: "main",
|
||||
branch: "work/issue-42",
|
||||
number: 7,
|
||||
status: "open",
|
||||
url: "https://git.example.com/repo/pulls/7",
|
||||
},
|
||||
status: "pull_request_open",
|
||||
};
|
||||
|
||||
const port: GitLifecyclePort = {
|
||||
publish(input: GitPublishInput): Promise<GitLifecycleResult> {
|
||||
publishCalls.push(input);
|
||||
attemptCount += 1;
|
||||
|
||||
if (cfg.failWith && attemptCount === 1) {
|
||||
return Promise.reject(cfg.failWith);
|
||||
}
|
||||
if (cfg.failOnFirstAttempt && attemptCount === 1) {
|
||||
return Promise.reject(
|
||||
new Error("Fake: PR creation failed on first attempt")
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve(cfg.result ?? defaultResult);
|
||||
},
|
||||
};
|
||||
|
||||
return { port, publishCalls };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const baseStartInput = (
|
||||
overrides?: Partial<StartIssueInputTest>
|
||||
): StartIssueInputTest => ({
|
||||
baseBranch: "main",
|
||||
branchName: "work/issue-42",
|
||||
context: {
|
||||
artifacts: [],
|
||||
contextFiles: [],
|
||||
issueBody: "Add a hello world endpoint",
|
||||
issueTitle: "Add hello endpoint",
|
||||
repositoryUrl: undefined,
|
||||
},
|
||||
contextPack: {
|
||||
artifacts: [],
|
||||
contextFiles: [],
|
||||
evidence: [],
|
||||
issueBody: "Add a hello world endpoint",
|
||||
issueNumber: 42,
|
||||
issueTitle: "Add hello endpoint",
|
||||
repositoryMetadata: {
|
||||
baseBranch: "main",
|
||||
repositoryName: "test-repo",
|
||||
repositoryUrl: "https://git.example.com/repo",
|
||||
},
|
||||
},
|
||||
docker: { hostWorkspacePath: "/tmp/test" },
|
||||
gateway: {
|
||||
apiKey: "test-key",
|
||||
baseUrl: "https://gw.example.com/v1",
|
||||
model: "m1",
|
||||
provider: "p1",
|
||||
},
|
||||
issueId: "issue-42",
|
||||
issueNumber: 42,
|
||||
issueTitle: "Add hello endpoint",
|
||||
projectId: "prj-1",
|
||||
repositoryPath: "org/repo",
|
||||
runId: "run-42",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
type StartIssueInputTest = Parameters<OrbProjectManager["startIssue"]>[0];
|
||||
|
||||
const makeMessageEvent = (text: string, sequence: number): OrbEvent =>
|
||||
makeOrbEvent(sequence, "agent_message_completed", { text });
|
||||
|
||||
const makeCommandEvent = (
|
||||
command: string,
|
||||
exitCode: number,
|
||||
sequence: number
|
||||
): OrbEvent =>
|
||||
makeOrbEvent(sequence, "command_executed", { command, exitCode });
|
||||
|
||||
// ===========================================================================
|
||||
// TESTS
|
||||
// ===========================================================================
|
||||
|
||||
describe("OrbProjectManager", () => {
|
||||
// -----------------------------------------------------------------------
|
||||
// 1. First-message start
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("first-message start", () => {
|
||||
it("creates an Orb run, prepares the repo, opens a session, and sends the context pack", async () => {
|
||||
const { adapter, runs } = createFakeOrbAdapter({
|
||||
defaultEvents: [makeMessageEvent("WORK_COMPLETE: done", 1)],
|
||||
});
|
||||
const events: ProjectRunEvent[] = [];
|
||||
const pm = new OrbProjectManager({
|
||||
createGitLifecycle: () => createFakeGitLifecycle().port,
|
||||
onProjectEvent: (e) => events.push(e),
|
||||
orbAdapter: adapter,
|
||||
});
|
||||
|
||||
const result = await pm.startIssue(baseStartInput());
|
||||
|
||||
expect(result.issueId).toBe("issue-42");
|
||||
expect(result.status).toBe("completing");
|
||||
expect(result.sessionId).toBeDefined();
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]!.prepareCalls).toHaveLength(1);
|
||||
expect(runs[0]!.prepareCalls[0]?.branchName).toBe("work/issue-42");
|
||||
expect(runs[0]!.sentTasks).toHaveLength(1);
|
||||
expect(runs[0]!.sentTasks[0]).toContain("Add hello endpoint");
|
||||
|
||||
const eventTypes = events.map((e) => e.type);
|
||||
expect(eventTypes).toContain("run.started");
|
||||
expect(eventTypes).toContain("run.repository_prepared");
|
||||
expect(eventTypes).toContain("run.session_opened");
|
||||
});
|
||||
|
||||
it("maps OrbEvents into durable project events and forwards them to the sink", async () => {
|
||||
const { adapter } = createFakeOrbAdapter({
|
||||
defaultEvents: [
|
||||
makeOrbEvent(1, "tool_call_started", { toolName: "edit_file" }),
|
||||
makeCommandEvent("npm test", 0, 2),
|
||||
makeMessageEvent("WORK_COMPLETE: all tests pass", 3),
|
||||
],
|
||||
});
|
||||
const events: ProjectRunEvent[] = [];
|
||||
const pm = new OrbProjectManager({
|
||||
createGitLifecycle: () => createFakeGitLifecycle().port,
|
||||
onProjectEvent: (e) => events.push(e),
|
||||
orbAdapter: adapter,
|
||||
});
|
||||
|
||||
await pm.startIssue(baseStartInput());
|
||||
|
||||
const types = events.map((e) => e.type);
|
||||
expect(types).toContain("run.agent_progress");
|
||||
expect(types).toContain("run.command_executed");
|
||||
expect(types).toContain("run.agent_message");
|
||||
|
||||
const cmdEvent = events.find((e) => e.type === "run.command_executed");
|
||||
expect(cmdEvent?.text).toBe("npm test");
|
||||
expect(cmdEvent?.exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. Follow-up forwarding
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("follow-up forwarding", () => {
|
||||
it("forwards a contextual message to the same OpenCode session", async () => {
|
||||
const { adapter, runs } = createFakeOrbAdapter({
|
||||
eventsByTurn: [
|
||||
[makeMessageEvent("NEEDS_INPUT: what name?", 1)],
|
||||
[makeMessageEvent("WORK_COMPLETE: done", 2)],
|
||||
],
|
||||
});
|
||||
const pm = new OrbProjectManager({
|
||||
createGitLifecycle: () => createFakeGitLifecycle().port,
|
||||
orbAdapter: adapter,
|
||||
});
|
||||
|
||||
const start = await pm.startIssue(baseStartInput());
|
||||
expect(start.status).toBe("needs-input");
|
||||
expect(start.needsInputQuestion).toBe("what name?");
|
||||
|
||||
const followUp = await pm.sendMessage("issue-42", "Name it /hello");
|
||||
expect(followUp.status).toBe("completing");
|
||||
expect(runs[0]!.sentTasks).toHaveLength(2);
|
||||
expect(runs[0]!.sentTasks[1]).toBe("Name it /hello");
|
||||
});
|
||||
|
||||
it("rejects a follow-up for a non-existent run", async () => {
|
||||
const { adapter } = createFakeOrbAdapter();
|
||||
const pm = new OrbProjectManager({
|
||||
createGitLifecycle: () => createFakeGitLifecycle().port,
|
||||
orbAdapter: adapter,
|
||||
});
|
||||
|
||||
await expect(pm.sendMessage("nope", "hello")).rejects.toThrow();
|
||||
try {
|
||||
await pm.sendMessage("nope", "hello");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ProjectManagerError);
|
||||
expect((error as ProjectManagerError).reason).toBe("RunNotFound");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a follow-up after cancellation", async () => {
|
||||
const { adapter } = createFakeOrbAdapter({
|
||||
defaultEvents: [makeMessageEvent("NEEDS_INPUT: hmm", 1)],
|
||||
});
|
||||
const pm = new OrbProjectManager({
|
||||
createGitLifecycle: () => createFakeGitLifecycle().port,
|
||||
orbAdapter: adapter,
|
||||
});
|
||||
|
||||
await pm.startIssue(baseStartInput());
|
||||
await pm.cancel("issue-42");
|
||||
|
||||
await expect(pm.sendMessage("issue-42", "hello")).rejects.toThrow();
|
||||
try {
|
||||
await pm.sendMessage("issue-42", "hello");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ProjectManagerError);
|
||||
expect((error as ProjectManagerError).reason).toBe("RunTerminal");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 3. Needs-input handling
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("needs-input handling", () => {
|
||||
it("detects the needs-input marker and surfaces the question", async () => {
|
||||
const { adapter } = createFakeOrbAdapter({
|
||||
defaultEvents: [
|
||||
makeMessageEvent("NEEDS_INPUT: Should I use GET or POST?", 1),
|
||||
],
|
||||
});
|
||||
const pm = new OrbProjectManager({
|
||||
createGitLifecycle: () => createFakeGitLifecycle().port,
|
||||
orbAdapter: adapter,
|
||||
});
|
||||
|
||||
const result = await pm.startIssue(baseStartInput());
|
||||
|
||||
expect(result.status).toBe("needs-input");
|
||||
expect(result.needsInputQuestion).toBe("Should I use GET or POST?");
|
||||
|
||||
const events = pm.getRunEvents("issue-42");
|
||||
expect(events.some((e) => e.type === "run.needs_input")).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the needs-input condition when a follow-up is sent", async () => {
|
||||
const { adapter } = createFakeOrbAdapter({
|
||||
eventsByTurn: [
|
||||
[makeMessageEvent("NEEDS_INPUT: clarify?", 1)],
|
||||
[makeMessageEvent("WORK_COMPLETE: done", 2)],
|
||||
],
|
||||
});
|
||||
const pm = new OrbProjectManager({
|
||||
createGitLifecycle: () => createFakeGitLifecycle().port,
|
||||
orbAdapter: adapter,
|
||||
});
|
||||
|
||||
await pm.startIssue(baseStartInput());
|
||||
expect(pm.getRunStatus("issue-42")).toBe("needs-input");
|
||||
|
||||
const result = await pm.sendMessage("issue-42", "Use POST");
|
||||
expect(result.status).toBe("completing");
|
||||
expect(result.needsInputQuestion).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4. Cancellation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("cancellation", () => {
|
||||
it("cancels and disposes the Orb run", async () => {
|
||||
const { adapter, runs } = createFakeOrbAdapter({
|
||||
defaultEvents: [makeMessageEvent("NEEDS_INPUT: wait", 1)],
|
||||
});
|
||||
const pm = new OrbProjectManager({
|
||||
createGitLifecycle: () => createFakeGitLifecycle().port,
|
||||
orbAdapter: adapter,
|
||||
});
|
||||
|
||||
await pm.startIssue(baseStartInput());
|
||||
await pm.cancel("issue-42");
|
||||
|
||||
expect(runs[0]!.cancelCalled).toBe(true);
|
||||
expect(runs[0]!.disposeCalled).toBe(true);
|
||||
expect(pm.getRunStatus("issue-42")).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("is idempotent — cancelling a non-existent run is a no-op", async () => {
|
||||
const { adapter } = createFakeOrbAdapter();
|
||||
const pm = new OrbProjectManager({
|
||||
createGitLifecycle: () => createFakeGitLifecycle().port,
|
||||
orbAdapter: adapter,
|
||||
});
|
||||
|
||||
await expect(pm.cancel("nonexistent")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("is idempotent — cancelling twice does not error", async () => {
|
||||
const { adapter } = createFakeOrbAdapter({
|
||||
defaultEvents: [makeMessageEvent("working...", 1)],
|
||||
});
|
||||
const pm = new OrbProjectManager({
|
||||
createGitLifecycle: () => createFakeGitLifecycle().port,
|
||||
orbAdapter: adapter,
|
||||
});
|
||||
|
||||
await pm.startIssue(baseStartInput());
|
||||
await pm.cancel("issue-42");
|
||||
await pm.cancel("issue-42");
|
||||
|
||||
expect(pm.getRunStatus("issue-42")).toBe("cancelled");
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 5. Duplicate-start prevention
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("duplicate-start prevention", () => {
|
||||
it("does not create a second Orb run for an active issue", async () => {
|
||||
const { adapter, runs } = createFakeOrbAdapter({
|
||||
defaultEvents: [makeMessageEvent("NEEDS_INPUT: hmm", 1)],
|
||||
});
|
||||
const pm = new OrbProjectManager({
|
||||
createGitLifecycle: () => createFakeGitLifecycle().port,
|
||||
orbAdapter: adapter,
|
||||
});
|
||||
|
||||
const first = await pm.startIssue(baseStartInput());
|
||||
const second = await pm.startIssue(baseStartInput());
|
||||
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(second.orbId).toBe(first.orbId);
|
||||
expect(second.status).toBe("needs-input");
|
||||
});
|
||||
|
||||
it("allows starting a new run after the previous one completed", async () => {
|
||||
const { adapter, runs } = createFakeOrbAdapter({
|
||||
defaultEvents: [makeMessageEvent("WORK_COMPLETE: done", 1)],
|
||||
});
|
||||
const { port: gitPort } = createFakeGitLifecycle();
|
||||
const pm = new OrbProjectManager({
|
||||
createGitLifecycle: () => gitPort,
|
||||
orbAdapter: adapter,
|
||||
});
|
||||
|
||||
await pm.startIssue(baseStartInput());
|
||||
await pm.complete({ issueId: "issue-42" });
|
||||
expect(pm.getRunStatus("issue-42")).toBe("completed");
|
||||
|
||||
// A terminal run allows re-creating via startIssue.
|
||||
const second = await pm.startIssue(baseStartInput());
|
||||
expect(runs.length).toBeGreaterThanOrEqual(2);
|
||||
expect(second.orbId).not.toBe(runs[0]!.orbId);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 6. Successful PR completion
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("successful PR completion", () => {
|
||||
it("runs the Git lifecycle, creates a PR, stores artifacts, and marks completed", async () => {
|
||||
const { adapter } = createFakeOrbAdapter({
|
||||
defaultEvents: [makeMessageEvent("WORK_COMPLETE: implemented", 1)],
|
||||
});
|
||||
const artifacts: ProjectArtifact[] = [];
|
||||
const { port: gitPort, publishCalls } = createFakeGitLifecycle();
|
||||
const pm = new OrbProjectManager({
|
||||
createGitLifecycle: () => gitPort,
|
||||
onArtifact: (a) => artifacts.push(a),
|
||||
orbAdapter: adapter,
|
||||
});
|
||||
|
||||
await pm.startIssue(baseStartInput());
|
||||
const result = await pm.complete({ issueId: "issue-42" });
|
||||
|
||||
expect(result.status).toBe("pull_request_open");
|
||||
expect(result.pullRequest?.number).toBe(7);
|
||||
expect(pm.getRunStatus("issue-42")).toBe("completed");
|
||||
expect(publishCalls).toHaveLength(1);
|
||||
expect(publishCalls[0]?.branchName).toBe("work/issue-42");
|
||||
|
||||
const artifactTypes = artifacts.map((a) => a.type);
|
||||
expect(artifactTypes).toContain("branch");
|
||||
expect(artifactTypes).toContain("commit");
|
||||
expect(artifactTypes).toContain("pull_request");
|
||||
expect(artifactTypes).toContain("agent_summary");
|
||||
});
|
||||
|
||||
it("marks completed with no_changes when the Git lifecycle reports no changes", async () => {
|
||||
const { adapter } = createFakeOrbAdapter({
|
||||
defaultEvents: [makeMessageEvent("WORK_COMPLETE: nothing to do", 1)],
|
||||
});
|
||||
const { port: gitPort } = createFakeGitLifecycle({
|
||||
result: {
|
||||
baseBranch: "main",
|
||||
branch: "work/issue-42",
|
||||
status: "no_changes",
|
||||
},
|
||||
});
|
||||
const pm = new OrbProjectManager({
|
||||
createGitLifecycle: () => gitPort,
|
||||
orbAdapter: adapter,
|
||||
});
|
||||
|
||||
await pm.startIssue(baseStartInput());
|
||||
const result = await pm.complete({ issueId: "issue-42" });
|
||||
|
||||
expect(result.status).toBe("no_changes");
|
||||
expect(pm.getRunStatus("issue-42")).toBe("completed");
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 7. Failed PR creation recovery
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("failed PR creation recovery", () => {
|
||||
it("marks the run as failed and raises a tagged error when PR creation fails", async () => {
|
||||
const { adapter } = createFakeOrbAdapter({
|
||||
defaultEvents: [makeMessageEvent("WORK_COMPLETE: done", 1)],
|
||||
});
|
||||
const { port: gitPort } = createFakeGitLifecycle({
|
||||
failWith: new Error("Remote rejected push"),
|
||||
});
|
||||
const pm = new OrbProjectManager({
|
||||
createGitLifecycle: () => gitPort,
|
||||
orbAdapter: adapter,
|
||||
});
|
||||
|
||||
await pm.startIssue(baseStartInput());
|
||||
|
||||
try {
|
||||
await pm.complete({ issueId: "issue-42" });
|
||||
expect.fail("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ProjectManagerError);
|
||||
expect((error as ProjectManagerError).reason).toBe("GitRejection");
|
||||
}
|
||||
expect(pm.getRunStatus("issue-42")).toBe("failed");
|
||||
});
|
||||
|
||||
it("allows retrying completion after a failed attempt (idempotent commit+push)", async () => {
|
||||
const { adapter } = createFakeOrbAdapter({
|
||||
defaultEvents: [makeMessageEvent("WORK_COMPLETE: done", 1)],
|
||||
});
|
||||
const { port: gitPort, publishCalls } = createFakeGitLifecycle({
|
||||
failOnFirstAttempt: true,
|
||||
});
|
||||
const pm = new OrbProjectManager({
|
||||
createGitLifecycle: () => gitPort,
|
||||
orbAdapter: adapter,
|
||||
});
|
||||
|
||||
await pm.startIssue(baseStartInput());
|
||||
|
||||
try {
|
||||
await pm.complete({ issueId: "issue-42" });
|
||||
} catch {
|
||||
// expected first-attempt failure
|
||||
}
|
||||
expect(pm.getRunStatus("issue-42")).toBe("failed");
|
||||
|
||||
// Retry: the Orb run still exists, the branch is preserved.
|
||||
const result = await pm.complete({ issueId: "issue-42" });
|
||||
expect(result.status).toBe("pull_request_open");
|
||||
expect(pm.getRunStatus("issue-42")).toBe("completed");
|
||||
expect(publishCalls).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 8. Infrastructure failure mapping
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("infrastructure failure mapping", () => {
|
||||
it("maps a failed Orb creation to InfrastructureFailure", async () => {
|
||||
const failingAdapter: OrbAdapter = {
|
||||
createOrb: () => Promise.reject(new Error("Docker daemon unavailable")),
|
||||
};
|
||||
const pm = new OrbProjectManager({
|
||||
createGitLifecycle: () => createFakeGitLifecycle().port,
|
||||
orbAdapter: failingAdapter,
|
||||
});
|
||||
|
||||
try {
|
||||
await pm.startIssue(baseStartInput());
|
||||
expect.fail("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ProjectManagerError);
|
||||
expect((error as ProjectManagerError).reason).toBe(
|
||||
"InfrastructureFailure"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps a failed prepareRepository to InfrastructureFailure", async () => {
|
||||
const { adapter } = createFakeOrbAdapter({
|
||||
failOnPrepare: true,
|
||||
});
|
||||
const pm = new OrbProjectManager({
|
||||
createGitLifecycle: () => createFakeGitLifecycle().port,
|
||||
orbAdapter: adapter,
|
||||
});
|
||||
|
||||
try {
|
||||
await pm.startIssue(baseStartInput());
|
||||
expect.fail("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ProjectManagerError);
|
||||
expect((error as ProjectManagerError).reason).toBe(
|
||||
"InfrastructureFailure"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not create duplicate events from idempotent completion", async () => {
|
||||
const { adapter } = createFakeOrbAdapter({
|
||||
defaultEvents: [makeMessageEvent("WORK_COMPLETE: done", 1)],
|
||||
});
|
||||
const { port: gitPort } = createFakeGitLifecycle();
|
||||
const pm = new OrbProjectManager({
|
||||
createGitLifecycle: () => gitPort,
|
||||
orbAdapter: adapter,
|
||||
});
|
||||
|
||||
await pm.startIssue(baseStartInput());
|
||||
await pm.complete({ issueId: "issue-42" });
|
||||
|
||||
const eventsBefore = pm.getRunEvents("issue-42").length;
|
||||
const result = await pm.complete({ issueId: "issue-42" });
|
||||
const eventsAfter = pm.getRunEvents("issue-42").length;
|
||||
|
||||
// Idempotent: returns cached result without duplicating events.
|
||||
expect(result.status).toBe("pull_request_open");
|
||||
expect(eventsAfter).toBe(eventsBefore);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,636 +0,0 @@
|
||||
/* eslint-disable max-classes-per-file -- domain errors are grouped by concern. */
|
||||
import { Schema } from "effect";
|
||||
|
||||
import { buildContextPack } from "./context-pack";
|
||||
import type { ContextPackInput } from "./context-pack";
|
||||
import type { OrbEvent } from "./events";
|
||||
import type {
|
||||
GitLifecyclePort,
|
||||
GitLifecycleResult,
|
||||
OrbAdapter,
|
||||
OrbCreatePortInput,
|
||||
OrbRunPort,
|
||||
ProjectArtifact,
|
||||
RunStatus,
|
||||
} from "./ports";
|
||||
import { isWorkComplete, mapOrbEvent } from "./project-events";
|
||||
import type { ProjectRunEvent } from "./project-events";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tagged errors — the failure-mapping surface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ProjectManagerErrorReason = Schema.Literals([
|
||||
"RunNotFound",
|
||||
"SessionNotReady",
|
||||
"RunTerminal",
|
||||
"DuplicateActiveRun",
|
||||
"InfrastructureFailure",
|
||||
"NeedsInput",
|
||||
"GitRejection",
|
||||
"PullRequestFailure",
|
||||
"Cancelled",
|
||||
"UnrecoverableFailure",
|
||||
]);
|
||||
export type ProjectManagerErrorReason = typeof ProjectManagerErrorReason.Type;
|
||||
|
||||
export class ProjectManagerError extends Schema.TaggedErrorClass<ProjectManagerError>()(
|
||||
"ProjectManagerError",
|
||||
{
|
||||
issueId: Schema.String,
|
||||
message: Schema.String,
|
||||
reason: ProjectManagerErrorReason,
|
||||
}
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active run record — one per managed issue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ActiveRun {
|
||||
readonly issueId: string;
|
||||
readonly orbId: string;
|
||||
readonly runId: string;
|
||||
readonly orb: OrbRunPort;
|
||||
sessionId: string | undefined;
|
||||
status: RunStatus;
|
||||
readonly projectEvents: ProjectRunEvent[];
|
||||
result: GitLifecycleResult | undefined;
|
||||
needsInputQuestion: string | undefined;
|
||||
readonly contextPack: string;
|
||||
lastTurnEventIndex: number;
|
||||
readonly baseBranch: string;
|
||||
readonly branchName: string;
|
||||
readonly issueNumber: number;
|
||||
readonly issueTitle: string;
|
||||
readonly repositoryPath: string;
|
||||
readonly workspacePath: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dependencies injected into the orchestrator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface OrbProjectManagerDeps {
|
||||
readonly orbAdapter: OrbAdapter;
|
||||
readonly createGitLifecycle: (orb: OrbRunPort) => GitLifecyclePort;
|
||||
readonly onProjectEvent?: (event: ProjectRunEvent) => void;
|
||||
readonly onArtifact?: (artifact: ProjectArtifact) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input types for the orchestration API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StartIssueInput {
|
||||
readonly issueId: string;
|
||||
readonly projectId: string;
|
||||
readonly runId: string;
|
||||
readonly context: OrbCreatePortInput["context"];
|
||||
readonly gateway: OrbCreatePortInput["gateway"];
|
||||
readonly docker: OrbCreatePortInput["docker"];
|
||||
readonly baseBranch: string;
|
||||
readonly branchName: string;
|
||||
readonly contextPack: ContextPackInput;
|
||||
readonly workspacePath?: string;
|
||||
readonly repositoryPath?: string;
|
||||
readonly issueNumber?: number;
|
||||
readonly issueTitle?: string;
|
||||
}
|
||||
|
||||
export interface StartIssueResult {
|
||||
readonly issueId: string;
|
||||
readonly orbId: string;
|
||||
readonly runId: string;
|
||||
readonly sessionId: string | undefined;
|
||||
readonly status: RunStatus;
|
||||
readonly needsInputQuestion?: string;
|
||||
}
|
||||
|
||||
export interface SendMessageResult {
|
||||
readonly issueId: string;
|
||||
readonly status: RunStatus;
|
||||
readonly needsInputQuestion?: string;
|
||||
}
|
||||
|
||||
export interface CompleteInput {
|
||||
readonly issueId: string;
|
||||
readonly verification?: "passed" | "failed" | "not-run";
|
||||
readonly commitMessage?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isTerminal = (status: RunStatus): boolean =>
|
||||
status === "completed" || status === "failed" || status === "cancelled";
|
||||
|
||||
const isSessionValid = (run: ActiveRun): boolean =>
|
||||
run.sessionId !== undefined && !isTerminal(run.status);
|
||||
|
||||
const timestamp = () => new Date().toISOString();
|
||||
|
||||
const wrapError = (
|
||||
error: unknown,
|
||||
issueId: string,
|
||||
reason: ProjectManagerErrorReason,
|
||||
fallback: string
|
||||
): ProjectManagerError => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return new ProjectManagerError({
|
||||
issueId,
|
||||
message: message.length > 0 ? message : fallback,
|
||||
reason,
|
||||
});
|
||||
};
|
||||
|
||||
const isGitRejection = (error: unknown): boolean => {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
message.includes("rejected") ||
|
||||
message.includes("authentication") ||
|
||||
message.includes("permission denied") ||
|
||||
message.includes("remote")
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OrbProjectManager — thin orchestration agent over the merged Orb runtime
|
||||
//
|
||||
// Responsibilities:
|
||||
// - Create or resume an Orb run per issue (idempotent)
|
||||
// - Assemble a context pack and send it as the implementation objective
|
||||
// - Project OrbEvents into durable ProjectRunEvents (no parallel event system)
|
||||
// - Detect needs-input and work-complete conditions
|
||||
// - Forward follow-up messages to the same OpenCode session
|
||||
// - Drive the Git publish lifecycle on completion (never auto-merge)
|
||||
// - Store branch/commit/diff/PR/summary as project artifacts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class OrbProjectManager {
|
||||
private readonly activeRuns = new Map<string, ActiveRun>();
|
||||
|
||||
private readonly deps: OrbProjectManagerDeps;
|
||||
|
||||
constructor(deps: OrbProjectManagerDeps) {
|
||||
this.deps = deps;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public state queries
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
getRunStatus(issueId: string): RunStatus | undefined {
|
||||
return this.activeRuns.get(issueId)?.status;
|
||||
}
|
||||
|
||||
getRunEvents(issueId: string): readonly ProjectRunEvent[] {
|
||||
return this.activeRuns.get(issueId)?.projectEvents ?? [];
|
||||
}
|
||||
|
||||
getRunResult(issueId: string): GitLifecycleResult | undefined {
|
||||
return this.activeRuns.get(issueId)?.result;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Start or resume an Orb run for an issue
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async startIssue(input: StartIssueInput): Promise<StartIssueResult> {
|
||||
const existing = this.activeRuns.get(input.issueId);
|
||||
|
||||
// Idempotency: a still-active run is reused, never duplicated.
|
||||
if (existing && !isTerminal(existing.status)) {
|
||||
return {
|
||||
issueId: input.issueId,
|
||||
needsInputQuestion: existing.needsInputQuestion,
|
||||
orbId: existing.orbId,
|
||||
runId: existing.runId,
|
||||
sessionId: existing.sessionId,
|
||||
status: existing.status,
|
||||
};
|
||||
}
|
||||
|
||||
let orb: OrbRunPort;
|
||||
try {
|
||||
orb = await this.deps.orbAdapter.createOrb({
|
||||
context: input.context,
|
||||
docker: input.docker,
|
||||
gateway: input.gateway,
|
||||
identity: {
|
||||
projectId: input.projectId,
|
||||
runId: input.runId,
|
||||
workUnitId: input.issueId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw wrapError(
|
||||
error,
|
||||
input.issueId,
|
||||
"InfrastructureFailure",
|
||||
"Failed to create Orb run"
|
||||
);
|
||||
}
|
||||
|
||||
const contextPack = buildContextPack(input.contextPack);
|
||||
|
||||
const run: ActiveRun = {
|
||||
baseBranch: input.baseBranch,
|
||||
branchName: input.branchName,
|
||||
contextPack,
|
||||
issueId: input.issueId,
|
||||
issueNumber: input.issueNumber ?? 0,
|
||||
issueTitle: input.issueTitle ?? input.issueId,
|
||||
lastTurnEventIndex: 0,
|
||||
needsInputQuestion: undefined,
|
||||
orb,
|
||||
orbId: orb.orbId,
|
||||
projectEvents: [],
|
||||
repositoryPath: input.repositoryPath ?? "",
|
||||
result: undefined,
|
||||
runId: orb.runId,
|
||||
sessionId: undefined,
|
||||
status: "starting",
|
||||
workspacePath: input.workspacePath ?? "/mnt/sandbox/repository",
|
||||
};
|
||||
this.activeRuns.set(input.issueId, run);
|
||||
|
||||
// Subscribe to OrbEvents and project them into durable ProjectRunEvents.
|
||||
orb.onEvent((orbEvent: OrbEvent) => {
|
||||
this.processOrbEvent(orbEvent, run);
|
||||
});
|
||||
|
||||
this.emitProjectEvent(run, "run.started", {
|
||||
text: `Started Orb run for issue ${input.issueId}`,
|
||||
});
|
||||
|
||||
try {
|
||||
await orb.prepareRepository({
|
||||
baseBranch: input.baseBranch,
|
||||
branchName: input.branchName,
|
||||
});
|
||||
this.emitProjectEvent(run, "run.repository_prepared", {
|
||||
text: `Repository prepared on branch ${input.branchName}`,
|
||||
});
|
||||
|
||||
const sessionId = await orb.openSession();
|
||||
run.sessionId = sessionId;
|
||||
run.status = "working";
|
||||
this.emitProjectEvent(run, "run.session_opened", {
|
||||
text: `Session ${sessionId} opened`,
|
||||
});
|
||||
|
||||
// Record turn boundary before sending the implementation objective.
|
||||
run.lastTurnEventIndex = run.projectEvents.length;
|
||||
|
||||
// Send the implementation objective (the assembled context pack).
|
||||
await orb.sendTask(contextPack);
|
||||
|
||||
// After the turn, check for needs-input or work-complete signals.
|
||||
OrbProjectManager.evaluateTurnOutcome(run);
|
||||
} catch (error) {
|
||||
if (error instanceof ProjectManagerError) {
|
||||
throw error;
|
||||
}
|
||||
throw wrapError(
|
||||
error,
|
||||
input.issueId,
|
||||
"InfrastructureFailure",
|
||||
"Orb run failed during startup"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
issueId: input.issueId,
|
||||
needsInputQuestion: run.needsInputQuestion,
|
||||
orbId: run.orbId,
|
||||
runId: run.runId,
|
||||
sessionId: run.sessionId,
|
||||
status: run.status,
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Forward a follow-up message to the same OpenCode session
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async sendMessage(
|
||||
issueId: string,
|
||||
message: string
|
||||
): Promise<SendMessageResult> {
|
||||
const run = this.activeRuns.get(issueId);
|
||||
if (!run) {
|
||||
throw new ProjectManagerError({
|
||||
issueId,
|
||||
message: `No active run for issue ${issueId}`,
|
||||
reason: "RunNotFound",
|
||||
});
|
||||
}
|
||||
if (isTerminal(run.status)) {
|
||||
throw new ProjectManagerError({
|
||||
issueId,
|
||||
message: `Run for issue ${issueId} is in terminal state ${run.status}`,
|
||||
reason: "RunTerminal",
|
||||
});
|
||||
}
|
||||
if (!isSessionValid(run)) {
|
||||
throw new ProjectManagerError({
|
||||
issueId,
|
||||
message: `Session is not open for issue ${issueId}`,
|
||||
reason: "SessionNotReady",
|
||||
});
|
||||
}
|
||||
|
||||
// Clear any prior needs-input condition and record turn boundary.
|
||||
run.needsInputQuestion = undefined;
|
||||
run.status = "working";
|
||||
run.lastTurnEventIndex = run.projectEvents.length;
|
||||
|
||||
try {
|
||||
await run.orb.sendTask(message);
|
||||
OrbProjectManager.evaluateTurnOutcome(run);
|
||||
} catch (error) {
|
||||
throw wrapError(
|
||||
error,
|
||||
issueId,
|
||||
"InfrastructureFailure",
|
||||
"Failed to forward message to OpenCode session"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
issueId,
|
||||
needsInputQuestion: run.needsInputQuestion,
|
||||
status: run.status,
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cancel — terminate OpenCode and sandbox, then dispose
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async cancel(issueId: string): Promise<void> {
|
||||
const run = this.activeRuns.get(issueId);
|
||||
if (!run) {
|
||||
// Idempotent cancel: a non-existent run is already "cancelled".
|
||||
return;
|
||||
}
|
||||
if (run.status === "cancelled") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await run.orb.cancel();
|
||||
} catch {
|
||||
// best-effort: proceed to dispose even if cancel failed
|
||||
}
|
||||
try {
|
||||
await run.orb.dispose();
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
|
||||
run.status = "cancelled";
|
||||
this.emitProjectEvent(run, "run.cancelled", {
|
||||
text: "Run cancelled by user",
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Complete — run Git publish lifecycle, store artifacts, mark completed
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async complete(input: CompleteInput): Promise<GitLifecycleResult> {
|
||||
const run = this.activeRuns.get(input.issueId);
|
||||
if (!run) {
|
||||
throw new ProjectManagerError({
|
||||
issueId: input.issueId,
|
||||
message: `No active run for issue ${input.issueId}`,
|
||||
reason: "RunNotFound",
|
||||
});
|
||||
}
|
||||
|
||||
// Idempotency: a completed run with an existing result is returned as-is.
|
||||
if (run.status === "completed" && run.result !== undefined) {
|
||||
return run.result;
|
||||
}
|
||||
|
||||
if (run.status === "cancelled") {
|
||||
throw new ProjectManagerError({
|
||||
issueId: input.issueId,
|
||||
message: "Cannot complete a cancelled run",
|
||||
reason: "Cancelled",
|
||||
});
|
||||
}
|
||||
|
||||
const git = this.deps.createGitLifecycle(run.orb);
|
||||
const verification = input.verification ?? "passed";
|
||||
|
||||
run.status = "completing";
|
||||
|
||||
let gitResult: GitLifecycleResult;
|
||||
try {
|
||||
gitResult = await git.publish({
|
||||
baseBranch: run.baseBranch,
|
||||
branchName: run.branchName,
|
||||
commitMessage: input.commitMessage,
|
||||
issueNumber: run.issueNumber,
|
||||
issueTitle: run.issueTitle,
|
||||
repositoryPath: run.repositoryPath,
|
||||
verification,
|
||||
workspace: run.workspacePath,
|
||||
});
|
||||
} catch (error) {
|
||||
run.status = "failed";
|
||||
const reason = isGitRejection(error)
|
||||
? "GitRejection"
|
||||
: "PullRequestFailure";
|
||||
this.emitProjectEvent(run, "run.failed", {
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw wrapError(
|
||||
error,
|
||||
input.issueId,
|
||||
reason,
|
||||
"Git publish lifecycle failed"
|
||||
);
|
||||
}
|
||||
|
||||
run.result = gitResult;
|
||||
this.storeArtifacts(run, gitResult);
|
||||
|
||||
// Mark completed only when a PR exists or a verified no-change result.
|
||||
if (
|
||||
(gitResult.status === "pull_request_open" && gitResult.pullRequest) ||
|
||||
gitResult.status === "no_changes"
|
||||
) {
|
||||
run.status = "completed";
|
||||
this.emitProjectEvent(run, "run.completed", {
|
||||
text: gitResult.pullRequest
|
||||
? `PR #${gitResult.pullRequest.number} created: ${gitResult.pullRequest.url}`
|
||||
: "No changes to publish",
|
||||
});
|
||||
} else {
|
||||
run.status = "failed";
|
||||
this.emitProjectEvent(run, "run.failed", {
|
||||
text: `Git lifecycle stopped at ${gitResult.status} without a pull request`,
|
||||
});
|
||||
throw new ProjectManagerError({
|
||||
issueId: input.issueId,
|
||||
message: `Git lifecycle did not produce a pull request (status: ${gitResult.status})`,
|
||||
reason: "PullRequestFailure",
|
||||
});
|
||||
}
|
||||
|
||||
return gitResult;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Dispose all runs (for graceful shutdown)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async disposeAll(): Promise<void> {
|
||||
const issues = [...this.activeRuns.keys()];
|
||||
await Promise.allSettled(
|
||||
issues.map(async (issueId) => {
|
||||
const run = this.activeRuns.get(issueId);
|
||||
if (run && !isTerminal(run.status)) {
|
||||
try {
|
||||
await run.orb.dispose();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal: OrbEvent processing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private processOrbEvent(orbEvent: OrbEvent, run: ActiveRun): void {
|
||||
const projectEvent = mapOrbEvent(orbEvent, run.issueId, run.runId);
|
||||
if (projectEvent !== undefined) {
|
||||
run.projectEvents.push(projectEvent);
|
||||
this.deps.onProjectEvent?.(projectEvent);
|
||||
|
||||
if (
|
||||
projectEvent.type === "run.needs_input" &&
|
||||
run.needsInputQuestion === undefined
|
||||
) {
|
||||
run.needsInputQuestion = projectEvent.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal: evaluate the outcome of a completed model turn
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static evaluateTurnOutcome(run: ActiveRun): void {
|
||||
// Only scan events from the current turn (after the last turn boundary).
|
||||
const turnEvents = run.projectEvents.slice(run.lastTurnEventIndex);
|
||||
|
||||
// Check for needs-input: the mapOrbEvent step already extracted the marker
|
||||
// into a run.needs_input event with the question text. A run.needs_input
|
||||
// event IS the signal — no need to re-extract the marker from its text.
|
||||
const needsInputEvent = [...turnEvents]
|
||||
.toReversed()
|
||||
.find((event) => event.type === "run.needs_input");
|
||||
|
||||
if (needsInputEvent !== undefined) {
|
||||
run.status = "needs-input";
|
||||
run.needsInputQuestion = needsInputEvent.text ?? "Agent requires input";
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for work-complete marker in agent messages from this turn.
|
||||
const turnMessages = turnEvents.filter(
|
||||
(event) => event.type === "run.agent_message"
|
||||
);
|
||||
|
||||
const hasWorkComplete = turnMessages.some(
|
||||
(event) => event.text !== undefined && isWorkComplete(event.text)
|
||||
);
|
||||
|
||||
if (hasWorkComplete && run.status === "working") {
|
||||
run.status = "completing";
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal: emit a synthetic project event (not derived from an OrbEvent)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private emitProjectEvent(
|
||||
run: ActiveRun,
|
||||
type: ProjectRunEvent["type"],
|
||||
fields: { text?: string; exitCode?: number; toolName?: string }
|
||||
): void {
|
||||
const event: ProjectRunEvent = {
|
||||
exitCode: fields.exitCode,
|
||||
issueId: run.issueId,
|
||||
runId: run.runId,
|
||||
sequence: run.projectEvents.length + 1,
|
||||
text: fields.text,
|
||||
timestamp: timestamp(),
|
||||
toolName: fields.toolName,
|
||||
type,
|
||||
};
|
||||
run.projectEvents.push(event);
|
||||
this.deps.onProjectEvent?.(event);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal: store artifacts from the Git lifecycle result
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private storeArtifacts(run: ActiveRun, result: GitLifecycleResult): void {
|
||||
const ts = timestamp();
|
||||
const base = { issueId: run.issueId, runId: run.runId };
|
||||
|
||||
const emitArtifact = (
|
||||
type: ProjectArtifact["type"],
|
||||
path: string,
|
||||
content: string
|
||||
): void => {
|
||||
const artifact: ProjectArtifact = {
|
||||
...base,
|
||||
content,
|
||||
path,
|
||||
timestamp: ts,
|
||||
type,
|
||||
};
|
||||
this.deps.onArtifact?.(artifact);
|
||||
};
|
||||
|
||||
emitArtifact("branch", "branch.txt", result.branch);
|
||||
|
||||
if (result.commitSha) {
|
||||
emitArtifact("commit", "commit.txt", result.commitSha);
|
||||
}
|
||||
|
||||
if (result.pullRequest) {
|
||||
emitArtifact(
|
||||
"pull_request",
|
||||
"pull_request.json",
|
||||
JSON.stringify(result.pullRequest, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
const lastMessage = [...run.projectEvents]
|
||||
.toReversed()
|
||||
.find(
|
||||
(event) =>
|
||||
event.type === "run.agent_message" || event.type === "run.needs_input"
|
||||
);
|
||||
if (lastMessage?.text) {
|
||||
emitArtifact("agent_summary", "summary.md", lastMessage.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { evaluatePermission, isDangerousPermission } from "./permission-policy";
|
||||
|
||||
const allowOption = { id: "allow", title: "Allow" };
|
||||
const denyOption = { id: "deny", title: "Deny" };
|
||||
|
||||
describe("isDangerousPermission", () => {
|
||||
it("flags merge operations", () => {
|
||||
expect(
|
||||
isDangerousPermission({
|
||||
requestId: "r1",
|
||||
toolCall: { title: "git merge main" },
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("flags production deployment", () => {
|
||||
expect(
|
||||
isDangerousPermission({
|
||||
requestId: "r2",
|
||||
toolCall: { title: "deploy to production" },
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("flags secret access", () => {
|
||||
expect(
|
||||
isDangerousPermission({
|
||||
requestId: "r3",
|
||||
toolCall: { title: "read secrets" },
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("flags credential access", () => {
|
||||
expect(
|
||||
isDangerousPermission({
|
||||
requestId: "r4",
|
||||
toolCall: { title: "access credentials" },
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag safe operations", () => {
|
||||
expect(
|
||||
isDangerousPermission({
|
||||
requestId: "r5",
|
||||
toolCall: { title: "run tests" },
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag file edits", () => {
|
||||
expect(
|
||||
isDangerousPermission({
|
||||
requestId: "r6",
|
||||
toolCall: { title: "edit src/index.ts" },
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluatePermission", () => {
|
||||
it("allows safe operations and picks allow option", () => {
|
||||
const decision = evaluatePermission({
|
||||
options: [allowOption, denyOption],
|
||||
requestId: "r1",
|
||||
toolCall: { title: "run bun test" },
|
||||
});
|
||||
expect(decision.allow).toBe(true);
|
||||
expect(decision.optionId).toBe("allow");
|
||||
});
|
||||
|
||||
it("denies dangerous operations and picks deny option", () => {
|
||||
const decision = evaluatePermission({
|
||||
options: [allowOption, denyOption],
|
||||
requestId: "r2",
|
||||
toolCall: { title: "git merge main" },
|
||||
});
|
||||
expect(decision.allow).toBe(false);
|
||||
expect(decision.optionId).toBe("deny");
|
||||
});
|
||||
|
||||
it("falls back to last option when no explicit deny exists", () => {
|
||||
const decision = evaluatePermission({
|
||||
options: [
|
||||
{ id: "ok", title: "OK" },
|
||||
{ id: "cancel", title: "Cancel" },
|
||||
],
|
||||
requestId: "r3",
|
||||
toolCall: { title: "deploy to production" },
|
||||
});
|
||||
expect(decision.allow).toBe(false);
|
||||
expect(decision.optionId).toBe("cancel");
|
||||
});
|
||||
|
||||
it("handles empty options", () => {
|
||||
const decision = evaluatePermission({
|
||||
options: [],
|
||||
requestId: "r4",
|
||||
toolCall: { title: "run tests" },
|
||||
});
|
||||
expect(decision.allow).toBe(true);
|
||||
expect(decision.optionId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* Narrow permission policy for Orb sessions.
|
||||
*
|
||||
* Denies merge, production deployment, secret access, and external
|
||||
* communications. Allows all other operations. Used as the callback for
|
||||
* ACP permission_request events with permissionPolicy: "ask".
|
||||
*/
|
||||
|
||||
interface PermissionOption {
|
||||
readonly id: string;
|
||||
readonly title?: string;
|
||||
readonly description?: string;
|
||||
}
|
||||
|
||||
interface PermissionRequestLike {
|
||||
readonly requestId: string;
|
||||
readonly options?: readonly PermissionOption[];
|
||||
readonly toolCall?: {
|
||||
readonly title?: string;
|
||||
readonly kind?: string;
|
||||
readonly name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const DENY_PATTERNS = [
|
||||
/\bmerge\b/iu,
|
||||
/\bdeploy\b.*\bprod/iu,
|
||||
/\bproduction\b/iu,
|
||||
/\bsecret/iu,
|
||||
/\bcredential/iu,
|
||||
/\bpassword\b/iu,
|
||||
/\bapi[_-]?key\b/iu,
|
||||
/\bpush\s+to\s+(?<branch>main|master)\b/iu,
|
||||
];
|
||||
|
||||
/** Evaluate whether a permission request is dangerous. */
|
||||
export const isDangerousPermission = (
|
||||
request: PermissionRequestLike
|
||||
): boolean => {
|
||||
const text = [
|
||||
request.toolCall?.title,
|
||||
request.toolCall?.kind,
|
||||
request.toolCall?.name,
|
||||
]
|
||||
.filter((s): s is string => typeof s === "string")
|
||||
.join(" ");
|
||||
return DENY_PATTERNS.some((pattern) => pattern.test(text));
|
||||
};
|
||||
|
||||
/**
|
||||
* Pick the option ID that matches the desired decision. Falls back to the
|
||||
* last option (typically deny) for safety when no explicit deny option exists,
|
||||
* or the first option (typically allow) when no explicit allow option exists.
|
||||
*/
|
||||
const pickOption = (
|
||||
options: readonly PermissionOption[],
|
||||
allow: boolean
|
||||
): string | undefined => {
|
||||
if (options.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (allow) {
|
||||
const match = options.find(
|
||||
(o) =>
|
||||
/allow|accept|yes|permit/iu.test(o.title ?? "") ||
|
||||
/allow|accept|yes|permit/iu.test(o.description ?? "")
|
||||
);
|
||||
return match?.id ?? options[0]?.id;
|
||||
}
|
||||
const match = options.find(
|
||||
(o) =>
|
||||
/deny|reject|no|cancel/iu.test(o.title ?? "") ||
|
||||
/deny|reject|no|cancel/iu.test(o.description ?? "")
|
||||
);
|
||||
return match?.id ?? options.at(-1)?.id;
|
||||
};
|
||||
|
||||
export interface PermissionDecision {
|
||||
readonly allow: boolean;
|
||||
readonly optionId: string | undefined;
|
||||
}
|
||||
|
||||
/** Evaluate a permission request and return the decision. */
|
||||
export const evaluatePermission = (
|
||||
request: PermissionRequestLike
|
||||
): PermissionDecision => {
|
||||
const dangerous = isDangerousPermission(request);
|
||||
const optionId = pickOption(request.options ?? [], !dangerous);
|
||||
return { allow: !dangerous, optionId };
|
||||
};
|
||||
@@ -1,136 +0,0 @@
|
||||
/* eslint-disable max-classes-per-file -- domain errors are grouped by concern. */
|
||||
import { Schema } from "effect";
|
||||
|
||||
import type {
|
||||
OrbIdentity,
|
||||
OrbModelGatewayConfig,
|
||||
OrbProjectContext,
|
||||
} from "./domain";
|
||||
import type { OrbEvent } from "./events";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command result — shared shape for sandbox command execution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CommandResult {
|
||||
readonly exitCode: number;
|
||||
readonly stderr: string;
|
||||
readonly stdout: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orb port — abstracts OrbRuntime/OrbHandle for testability
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PrepareRepoInput {
|
||||
readonly baseBranch?: string;
|
||||
readonly branchName?: string;
|
||||
}
|
||||
|
||||
export interface OrbRunPort {
|
||||
readonly orbId: string;
|
||||
readonly runId: string;
|
||||
readonly sessionId: string | undefined;
|
||||
readonly state: string;
|
||||
readonly onEvent: (listener: (event: OrbEvent) => void) => () => void;
|
||||
readonly prepareRepository: (input: PrepareRepoInput) => Promise<void>;
|
||||
readonly openSession: () => Promise<string>;
|
||||
readonly sendTask: (prompt: string) => Promise<unknown>;
|
||||
readonly executeCommand: (
|
||||
command: string,
|
||||
cwd?: string
|
||||
) => Promise<CommandResult>;
|
||||
readonly cancel: () => Promise<void>;
|
||||
readonly dispose: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface OrbCreatePortInput {
|
||||
readonly context: OrbProjectContext;
|
||||
readonly gateway: OrbModelGatewayConfig;
|
||||
readonly identity: OrbIdentity;
|
||||
readonly docker: {
|
||||
readonly hostWorkspacePath: string;
|
||||
readonly containerName?: string;
|
||||
readonly image?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OrbAdapter {
|
||||
readonly createOrb: (input: OrbCreatePortInput) => Promise<OrbRunPort>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Git lifecycle port — abstracts the Gitea lifecycle for testability
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GitPublishInput {
|
||||
readonly workspace: string;
|
||||
readonly baseBranch: string;
|
||||
readonly branchName: string;
|
||||
readonly issueNumber: number;
|
||||
readonly issueTitle: string;
|
||||
readonly repositoryPath: string;
|
||||
readonly commitMessage?: string;
|
||||
readonly verification: "passed" | "failed" | "not-run";
|
||||
}
|
||||
|
||||
export interface GitPullRequestMeta {
|
||||
readonly baseBranch: string;
|
||||
readonly branch: string;
|
||||
readonly number: number;
|
||||
readonly status: "open" | "closed" | "merged";
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
export interface GitLifecycleResult {
|
||||
readonly baseBranch: string;
|
||||
readonly branch: string;
|
||||
readonly commitSha?: string;
|
||||
readonly pullRequest?: GitPullRequestMeta;
|
||||
readonly status:
|
||||
| "no_changes"
|
||||
| "committed"
|
||||
| "pushed"
|
||||
| "pull_request_open"
|
||||
| "failed";
|
||||
}
|
||||
|
||||
export interface GitLifecyclePort {
|
||||
readonly publish: (input: GitPublishInput) => Promise<GitLifecycleResult>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project artifact — durable output stored after a run
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ProjectArtifactSchema = Schema.Struct({
|
||||
content: Schema.String,
|
||||
issueId: Schema.String,
|
||||
path: Schema.String,
|
||||
runId: Schema.String,
|
||||
timestamp: Schema.String,
|
||||
type: Schema.Literals([
|
||||
"branch",
|
||||
"commit",
|
||||
"diff",
|
||||
"verification_report",
|
||||
"pull_request",
|
||||
"agent_summary",
|
||||
]),
|
||||
});
|
||||
export type ProjectArtifact = typeof ProjectArtifactSchema.Type;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestration status for a managed issue run
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const RunStatus = Schema.Literals([
|
||||
"starting",
|
||||
"working",
|
||||
"needs-input",
|
||||
"completing",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]);
|
||||
export type RunStatus = typeof RunStatus.Type;
|
||||
@@ -1,123 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { makeOrbEvent } from "./events";
|
||||
import {
|
||||
extractNeedsInputQuestion,
|
||||
isWorkComplete,
|
||||
mapOrbEvent,
|
||||
NEEDS_INPUT_MARKER,
|
||||
WORK_COMPLETE_MARKER,
|
||||
} from "./project-events";
|
||||
|
||||
describe("marker detection", () => {
|
||||
it("extracts the needs-input question from a marker message", () => {
|
||||
const text = `Some preamble\n${NEEDS_INPUT_MARKER} Which port should I use?`;
|
||||
expect(extractNeedsInputQuestion(text)).toBe("Which port should I use?");
|
||||
});
|
||||
|
||||
it("returns undefined for a message without the marker", () => {
|
||||
expect(extractNeedsInputQuestion("just working")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns a default question when marker has no text after it", () => {
|
||||
expect(extractNeedsInputQuestion(NEEDS_INPUT_MARKER)).toBe(
|
||||
"Agent requires input"
|
||||
);
|
||||
});
|
||||
|
||||
it("detects the work-complete marker", () => {
|
||||
expect(isWorkComplete(`${WORK_COMPLETE_MARKER} all good`)).toBe(true);
|
||||
expect(isWorkComplete("still working")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapOrbEvent", () => {
|
||||
const issueId = "issue-1";
|
||||
const runId = "run-1";
|
||||
|
||||
it("maps session_opened to run.session_opened", () => {
|
||||
const event = mapOrbEvent(
|
||||
makeOrbEvent(1, "session_opened", { text: "Session abc opened" }),
|
||||
issueId,
|
||||
runId
|
||||
);
|
||||
expect(event?.type).toBe("run.session_opened");
|
||||
expect(event?.text).toBe("Session abc opened");
|
||||
expect(event?.issueId).toBe(issueId);
|
||||
expect(event?.runId).toBe(runId);
|
||||
});
|
||||
|
||||
it("maps agent_message_completed with needs-input marker to run.needs_input", () => {
|
||||
const event = mapOrbEvent(
|
||||
makeOrbEvent(2, "agent_message_completed", {
|
||||
text: `${NEEDS_INPUT_MARKER} What name?`,
|
||||
}),
|
||||
issueId,
|
||||
runId
|
||||
);
|
||||
expect(event?.type).toBe("run.needs_input");
|
||||
expect(event?.text).toBe("What name?");
|
||||
});
|
||||
|
||||
it("maps agent_message_completed without marker to run.agent_message", () => {
|
||||
const event = mapOrbEvent(
|
||||
makeOrbEvent(3, "agent_message_completed", { text: "I fixed the bug" }),
|
||||
issueId,
|
||||
runId
|
||||
);
|
||||
expect(event?.type).toBe("run.agent_message");
|
||||
expect(event?.text).toBe("I fixed the bug");
|
||||
});
|
||||
|
||||
it("maps command_executed to run.command_executed with exit code", () => {
|
||||
const event = mapOrbEvent(
|
||||
makeOrbEvent(4, "command_executed", { command: "npm test", exitCode: 0 }),
|
||||
issueId,
|
||||
runId
|
||||
);
|
||||
expect(event?.type).toBe("run.command_executed");
|
||||
expect(event?.exitCode).toBe(0);
|
||||
expect(event?.text).toBe("npm test");
|
||||
});
|
||||
|
||||
it("maps tool_call events to run.agent_progress", () => {
|
||||
const started = mapOrbEvent(
|
||||
makeOrbEvent(5, "tool_call_started", { toolName: "edit_file" }),
|
||||
issueId,
|
||||
runId
|
||||
);
|
||||
expect(started?.type).toBe("run.agent_progress");
|
||||
expect(started?.toolName).toBe("edit_file");
|
||||
});
|
||||
|
||||
it("maps session_failed to run.failed", () => {
|
||||
const event = mapOrbEvent(
|
||||
makeOrbEvent(6, "session_failed", { text: "crashed" }),
|
||||
issueId,
|
||||
runId
|
||||
);
|
||||
expect(event?.type).toBe("run.failed");
|
||||
});
|
||||
|
||||
it("returns undefined for chunked events and vm_booted", () => {
|
||||
expect(
|
||||
mapOrbEvent(
|
||||
makeOrbEvent(7, "agent_message_chunk", { text: "partial" }),
|
||||
issueId,
|
||||
runId
|
||||
)
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
mapOrbEvent(makeOrbEvent(8, "vm_booted"), issueId, runId)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps permission_requested to run.permission_requested", () => {
|
||||
const event = mapOrbEvent(
|
||||
makeOrbEvent(9, "permission_requested", { text: "needs approval" }),
|
||||
issueId,
|
||||
runId
|
||||
);
|
||||
expect(event?.type).toBe("run.permission_requested");
|
||||
});
|
||||
});
|
||||
@@ -1,171 +0,0 @@
|
||||
import { Schema } from "effect";
|
||||
|
||||
import type { OrbEvent } from "./events";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Durable project events — human-meaningful projections of Orb execution
|
||||
//
|
||||
// These are NOT raw OpenCode events. Each variant maps to a product-level
|
||||
// concept the UI and work-graph understand. Raw OrbEvents are preserved for
|
||||
// audit; this is the durable projection layer.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ProjectRunEventVariant = Schema.Literals([
|
||||
"run.started",
|
||||
"run.repository_prepared",
|
||||
"run.session_opened",
|
||||
"run.agent_message",
|
||||
"run.agent_progress",
|
||||
"run.command_executed",
|
||||
"run.needs_input",
|
||||
"run.permission_requested",
|
||||
"run.completed",
|
||||
"run.failed",
|
||||
"run.cancelled",
|
||||
"run.session_closed",
|
||||
]);
|
||||
export type ProjectRunEventVariant = typeof ProjectRunEventVariant.Type;
|
||||
|
||||
export const ProjectRunEvent = Schema.Struct({
|
||||
exitCode: Schema.UndefinedOr(Schema.Int),
|
||||
issueId: Schema.String,
|
||||
runId: Schema.String,
|
||||
sequence: Schema.Number,
|
||||
text: Schema.UndefinedOr(Schema.String),
|
||||
timestamp: Schema.String,
|
||||
toolName: Schema.UndefinedOr(Schema.String),
|
||||
type: ProjectRunEventVariant,
|
||||
});
|
||||
export type ProjectRunEvent = typeof ProjectRunEvent.Type;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Marker detection — OpenCode signals product-level conditions via markers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Prefix the agent emits when it cannot proceed without human input. */
|
||||
export const NEEDS_INPUT_MARKER = "NEEDS_INPUT:";
|
||||
|
||||
/**
|
||||
* Prefix the agent emits when implementation and verification are complete
|
||||
* and the orchestrator should proceed to the Git publish lifecycle.
|
||||
*/
|
||||
export const WORK_COMPLETE_MARKER = "WORK_COMPLETE:";
|
||||
|
||||
export const extractNeedsInputQuestion = (text: string): string | undefined => {
|
||||
const index = text.indexOf(NEEDS_INPUT_MARKER);
|
||||
if (index === -1) {
|
||||
return undefined;
|
||||
}
|
||||
const after = text.slice(index + NEEDS_INPUT_MARKER.length).trim();
|
||||
return after.length > 0 ? after.slice(0, 4000) : "Agent requires input";
|
||||
};
|
||||
|
||||
export const isWorkComplete = (text: string): boolean =>
|
||||
text.includes(WORK_COMPLETE_MARKER);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OrbEvent → ProjectRunEvent translation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Translate one normalized OrbEvent into zero or one durable ProjectRunEvent.
|
||||
* Returns undefined for OrbEvents that have no product-meaningful projection
|
||||
* (e.g. chunked intermediate output that is only useful in the raw audit log).
|
||||
*/
|
||||
export const mapOrbEvent = (
|
||||
orbEvent: OrbEvent,
|
||||
issueId: string,
|
||||
runId: string
|
||||
): ProjectRunEvent | undefined => {
|
||||
const base: {
|
||||
exitCode: number | undefined;
|
||||
issueId: string;
|
||||
runId: string;
|
||||
sequence: number;
|
||||
text: string | undefined;
|
||||
timestamp: string;
|
||||
toolName: string | undefined;
|
||||
} = {
|
||||
exitCode: undefined,
|
||||
issueId,
|
||||
runId,
|
||||
sequence: orbEvent.sequence,
|
||||
text: undefined,
|
||||
timestamp: orbEvent.timestamp,
|
||||
toolName: undefined,
|
||||
};
|
||||
|
||||
switch (orbEvent.type) {
|
||||
case "session_opened": {
|
||||
return {
|
||||
...base,
|
||||
text: orbEvent.text ?? "Session opened",
|
||||
type: "run.session_opened",
|
||||
};
|
||||
}
|
||||
case "vm_booted": {
|
||||
return undefined;
|
||||
}
|
||||
case "agent_message_completed": {
|
||||
const text = orbEvent.text ?? "";
|
||||
if (extractNeedsInputQuestion(text) !== undefined) {
|
||||
return {
|
||||
...base,
|
||||
text: extractNeedsInputQuestion(text),
|
||||
type: "run.needs_input",
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
text,
|
||||
type: "run.agent_message",
|
||||
};
|
||||
}
|
||||
case "agent_message_chunk":
|
||||
case "agent_thought_chunk": {
|
||||
return undefined;
|
||||
}
|
||||
case "tool_call_started":
|
||||
case "tool_call_completed": {
|
||||
return {
|
||||
...base,
|
||||
text: undefined,
|
||||
toolName: orbEvent.toolName,
|
||||
type: "run.agent_progress",
|
||||
};
|
||||
}
|
||||
case "command_executed": {
|
||||
return {
|
||||
...base,
|
||||
exitCode: orbEvent.exitCode,
|
||||
text: orbEvent.command,
|
||||
type: "run.command_executed",
|
||||
};
|
||||
}
|
||||
case "permission_requested":
|
||||
case "permission_denied": {
|
||||
return {
|
||||
...base,
|
||||
text: orbEvent.text ?? "Permission requested",
|
||||
type: "run.permission_requested",
|
||||
};
|
||||
}
|
||||
case "session_failed": {
|
||||
return {
|
||||
...base,
|
||||
text: orbEvent.text ?? "Session failed",
|
||||
type: "run.failed",
|
||||
};
|
||||
}
|
||||
case "session_closed": {
|
||||
return {
|
||||
...base,
|
||||
text: orbEvent.text ?? "Session closed",
|
||||
type: "run.session_closed",
|
||||
};
|
||||
}
|
||||
default: {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,125 +0,0 @@
|
||||
/* eslint-disable no-non-null-assertion -- test assertions on defined objects */
|
||||
import { Effect } from "effect";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { OrbConfigurationError } from "./domain";
|
||||
import { makeOrbEvent } from "./events";
|
||||
import type { OrbEvent } from "./events";
|
||||
import { OrbHandle, OrbRuntime } from "./runtime";
|
||||
|
||||
const validGateway = {
|
||||
apiKey: "test-key",
|
||||
baseUrl: "https://gw.example.com/v1",
|
||||
model: "m1",
|
||||
provider: "p1",
|
||||
};
|
||||
|
||||
const validContext = {
|
||||
artifacts: [],
|
||||
contextFiles: [],
|
||||
issueBody: "Do the thing",
|
||||
issueTitle: "Thing",
|
||||
repositoryUrl: undefined,
|
||||
};
|
||||
|
||||
const validIdentity = {
|
||||
projectId: "prj-1",
|
||||
runId: "run-1",
|
||||
workUnitId: "wrk-1",
|
||||
};
|
||||
|
||||
describe("OrbRuntime.createOrb validation", () => {
|
||||
it("rejects missing gateway API key before touching Docker", () => {
|
||||
const runtime = new OrbRuntime();
|
||||
// This must fail at validation, not at Docker — if Docker is the failure,
|
||||
// that indicates the validation ordering is wrong.
|
||||
const error = Effect.runSync(
|
||||
Effect.flip(
|
||||
runtime.createOrb({
|
||||
context: validContext,
|
||||
docker: {
|
||||
containerName: "orb-test",
|
||||
hostWorkspacePath: "/tmp/orb-test",
|
||||
},
|
||||
gateway: { ...validGateway, apiKey: " " },
|
||||
identity: validIdentity,
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(error).toBeInstanceOf(OrbConfigurationError);
|
||||
expect(error.reason).toBe("MissingGateway");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OrbHandle event lifecycle", () => {
|
||||
it("emits events to listeners", () => {
|
||||
const handle = new OrbHandle(
|
||||
"orb-x" as never,
|
||||
"run-x" as never,
|
||||
validIdentity
|
||||
);
|
||||
const events: OrbEvent[] = [];
|
||||
handle.onEvent((e) => events.push(e));
|
||||
|
||||
handle.emitEvent(makeOrbEvent(1, "vm_booted", { text: "booted" }));
|
||||
handle.emitEvent(
|
||||
makeOrbEvent(2, "command_executed", { command: "ls", exitCode: 0 })
|
||||
);
|
||||
|
||||
expect(events.length).toBe(2);
|
||||
expect(events[0]!.type).toBe("vm_booted");
|
||||
expect(events[1]!.type).toBe("command_executed");
|
||||
expect(events[1]!.command).toBe("ls");
|
||||
});
|
||||
|
||||
it("unsubscribes listeners correctly", () => {
|
||||
const handle = new OrbHandle(
|
||||
"orb-y" as never,
|
||||
"run-y" as never,
|
||||
validIdentity
|
||||
);
|
||||
const events: OrbEvent[] = [];
|
||||
const unsub = handle.onEvent((e) => events.push(e));
|
||||
|
||||
handle.emitEvent(makeOrbEvent(1, "session_opened"));
|
||||
unsub();
|
||||
handle.emitEvent(makeOrbEvent(2, "session_closed"));
|
||||
|
||||
expect(events.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OrbHandle state transitions", () => {
|
||||
it("starts in creating state", () => {
|
||||
const handle = new OrbHandle(
|
||||
"orb-z" as never,
|
||||
"run-z" as never,
|
||||
validIdentity
|
||||
);
|
||||
expect(handle.state).toBe("creating");
|
||||
});
|
||||
|
||||
it("transitions to prepared then running", () => {
|
||||
const handle = new OrbHandle(
|
||||
"orb-a" as never,
|
||||
"run-a" as never,
|
||||
validIdentity
|
||||
);
|
||||
Effect.runSync(handle.setOrbState("prepared"));
|
||||
expect(handle.state).toBe("prepared");
|
||||
Effect.runSync(handle.setOrbState("running"));
|
||||
expect(handle.state).toBe("running");
|
||||
});
|
||||
|
||||
it("rejects invalid transition", () => {
|
||||
const handle = new OrbHandle(
|
||||
"orb-b" as never,
|
||||
"run-b" as never,
|
||||
validIdentity
|
||||
);
|
||||
const error = Effect.runSync(
|
||||
Effect.flip(handle.setOrbState("needs-input"))
|
||||
);
|
||||
expect(error.reason).toBe("InvalidTransition");
|
||||
});
|
||||
});
|
||||
@@ -1,763 +0,0 @@
|
||||
/* eslint-disable prefer-destructuring -- field captures before mutation are intentional */
|
||||
/* eslint-disable max-classes-per-file -- runtime and handle form one service. */
|
||||
import opencodePkg from "@agentos-software/opencode";
|
||||
import { AgentOs } from "@rivet-dev/agentos-core";
|
||||
import type { SessionStreamEntry } from "@rivet-dev/agentos-core";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import type { DockerSandboxOptions } from "./docker-sandbox";
|
||||
import { DockerSandboxProvider } from "./docker-sandbox";
|
||||
import {
|
||||
OrbConfigurationError,
|
||||
OrbSandboxError,
|
||||
OrbSessionError,
|
||||
orbActorKey,
|
||||
transitionOrbState,
|
||||
transitionRunState,
|
||||
} from "./domain";
|
||||
import type {
|
||||
OrbIdentity,
|
||||
OrbId,
|
||||
OrbModelGatewayConfig,
|
||||
OrbProjectContext,
|
||||
OrbRunId,
|
||||
OrbSessionId,
|
||||
OrbState,
|
||||
OrbStateError,
|
||||
RunState,
|
||||
} from "./domain";
|
||||
import type { OrbEvent } from "./events";
|
||||
import { makeOrbEvent, normalizeSessionEvent, redactSecrets } from "./events";
|
||||
import { prepareOpenCodeConfig } from "./opencode-config";
|
||||
import { evaluatePermission } from "./permission-policy";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface OrbEnv {
|
||||
readonly dockerImage?: string;
|
||||
readonly dockerWorkspace?: string;
|
||||
readonly rivetEndpoint?: string;
|
||||
}
|
||||
|
||||
export interface OrbCreateInput {
|
||||
readonly context: OrbProjectContext;
|
||||
readonly docker: Omit<DockerSandboxOptions, "image">;
|
||||
readonly gateway: OrbModelGatewayConfig;
|
||||
readonly identity: OrbIdentity;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OrbHandle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class OrbHandle {
|
||||
readonly id: OrbId;
|
||||
readonly runId: OrbRunId;
|
||||
readonly identity: OrbIdentity;
|
||||
readonly actorKey: string;
|
||||
|
||||
private vm: AgentOs | null = null;
|
||||
private dockerProvider: DockerSandboxProvider | null = null;
|
||||
private sessionId: string | undefined;
|
||||
private orbState: OrbState = "creating";
|
||||
private runState: RunState = "queued";
|
||||
private eventSequence = 0;
|
||||
private readonly eventListeners = new Set<(event: OrbEvent) => void>();
|
||||
private unsubscribeSession: (() => void) | null = null;
|
||||
private context: OrbProjectContext | undefined;
|
||||
private gateway: OrbModelGatewayConfig | undefined;
|
||||
|
||||
constructor(id: OrbId, runId: OrbRunId, identity: OrbIdentity) {
|
||||
this.id = id;
|
||||
this.runId = runId;
|
||||
this.identity = identity;
|
||||
this.actorKey = orbActorKey(identity);
|
||||
}
|
||||
|
||||
get state(): OrbState {
|
||||
return this.orbState;
|
||||
}
|
||||
|
||||
get currentSessionId(): string | undefined {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
get docker(): DockerSandboxProvider | null {
|
||||
return this.dockerProvider;
|
||||
}
|
||||
|
||||
get vmInstance(): AgentOs | null {
|
||||
return this.vm;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
onEvent(listener: (event: OrbEvent) => void): () => void {
|
||||
this.eventListeners.add(listener);
|
||||
return () => {
|
||||
this.eventListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
emitEvent(event: OrbEvent): void {
|
||||
for (const listener of this.eventListeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
private nextSequence(): number {
|
||||
this.eventSequence += 1;
|
||||
return this.eventSequence;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// State transitions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
readonly setOrbState = (to: OrbState): Effect.Effect<void, OrbStateError> =>
|
||||
transitionOrbState({ from: this.orbState, to }).pipe(
|
||||
Effect.tap((next) =>
|
||||
Effect.sync(() => {
|
||||
this.orbState = next;
|
||||
})
|
||||
),
|
||||
Effect.asVoid
|
||||
);
|
||||
|
||||
readonly setRunState = (to: RunState): Effect.Effect<void, OrbStateError> =>
|
||||
transitionRunState({ from: this.runState, to }).pipe(
|
||||
Effect.tap((next) =>
|
||||
Effect.sync(() => {
|
||||
this.runState = next;
|
||||
})
|
||||
),
|
||||
Effect.asVoid
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal attachment
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
_attachVm(vm: AgentOs): void {
|
||||
this.vm = vm;
|
||||
this.unsubscribeSession = vm.onSessionEvent((entry: SessionStreamEntry) => {
|
||||
const normalized = normalizeSessionEvent(entry);
|
||||
if (normalized) {
|
||||
this.emitEvent({ ...normalized, sequence: this.nextSequence() });
|
||||
}
|
||||
if (
|
||||
typeof entry === "object" &&
|
||||
entry !== null &&
|
||||
"type" in entry &&
|
||||
entry.type === "permission_request"
|
||||
) {
|
||||
void this.handlePermissionRequest(
|
||||
entry as unknown as {
|
||||
requestId: string;
|
||||
options: {
|
||||
description?: string;
|
||||
id: string;
|
||||
title?: string;
|
||||
}[];
|
||||
toolCall?: { kind?: string; name?: string; title?: string };
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
this.emitEvent(
|
||||
makeOrbEvent(this.nextSequence(), "vm_booted", { text: "VM booted" })
|
||||
);
|
||||
}
|
||||
|
||||
_attachDocker(provider: DockerSandboxProvider): void {
|
||||
this.dockerProvider = provider;
|
||||
}
|
||||
|
||||
_configure(input: {
|
||||
readonly context: OrbProjectContext;
|
||||
readonly gateway: OrbModelGatewayConfig;
|
||||
}): void {
|
||||
this.context = input.context;
|
||||
this.gateway = input.gateway;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Permission handler
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private async handlePermissionRequest(request: {
|
||||
readonly requestId: string;
|
||||
readonly options: {
|
||||
description?: string;
|
||||
id: string;
|
||||
title?: string;
|
||||
}[];
|
||||
readonly toolCall?: { kind?: string; name?: string; title?: string };
|
||||
}): Promise<void> {
|
||||
const vm = this.vm;
|
||||
const sessionId = this.sessionId;
|
||||
if (!vm || !sessionId) {
|
||||
return;
|
||||
}
|
||||
const decision = evaluatePermission(request);
|
||||
if (!decision.allow) {
|
||||
this.emitEvent(
|
||||
makeOrbEvent(this.nextSequence(), "permission_denied", {
|
||||
text: `Permission denied for: ${request.toolCall?.title ?? "unknown"}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (decision.optionId) {
|
||||
await vm
|
||||
.respondPermission({
|
||||
optionId: decision.optionId,
|
||||
requestId: request.requestId,
|
||||
sessionId,
|
||||
})
|
||||
.catch(
|
||||
// eslint-disable-next-line no-empty-function -- best-effort permission response
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Repository preparation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
readonly prepareRepository = Effect.fn("Orb.prepareRepository")(
|
||||
function* prepareRepository(
|
||||
this: OrbHandle,
|
||||
input: { readonly baseBranch?: string; readonly branchName?: string }
|
||||
) {
|
||||
if (!this.dockerProvider) {
|
||||
return yield* Effect.fail(
|
||||
new OrbSandboxError({
|
||||
message: "Docker sandbox is not attached",
|
||||
reason: "ContainerStart",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
yield* this.setOrbState("prepared");
|
||||
yield* this.setRunState("provisioning");
|
||||
|
||||
const client = yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSandboxError({
|
||||
message: `Failed to start Docker sandbox: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "ContainerStart",
|
||||
}),
|
||||
try: () => {
|
||||
const dp = this.dockerProvider;
|
||||
if (!dp) {
|
||||
throw new Error("Docker provider detached");
|
||||
}
|
||||
return dp.start();
|
||||
},
|
||||
});
|
||||
|
||||
const repoUrl = this.context?.repositoryUrl;
|
||||
if (repoUrl) {
|
||||
const branch = input.branchName ?? "main";
|
||||
const base = input.baseBranch ?? "main";
|
||||
// eslint-disable-next-line no-use-before-define -- module-level helper
|
||||
const baseRef = `origin/${base}`;
|
||||
// eslint-disable-next-line no-use-before-define -- module-level helper
|
||||
const cloneCmd = `git clone --branch ${shellQuote(base)} --single-branch ${shellQuote(repoUrl)} /home/sandbox/repository || git clone ${shellQuote(repoUrl)} /home/sandbox/repository`;
|
||||
// eslint-disable-next-line no-use-before-define -- module-level helper
|
||||
const checkoutCmd = `cd /home/sandbox/repository && git checkout -b ${shellQuote(branch)} ${shellQuote(baseRef)} 2>/dev/null || git checkout ${shellQuote(branch)} 2>/dev/null || true`;
|
||||
const result = yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSandboxError({
|
||||
message: `Repository checkout failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "CommandFailed",
|
||||
}),
|
||||
try: () =>
|
||||
client.runProcess({
|
||||
args: ["-c", `${cloneCmd} && ${checkoutCmd}`],
|
||||
command: "sh",
|
||||
cwd: "/home/sandbox",
|
||||
timeoutMs: 300_000,
|
||||
}),
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
return yield* Effect.fail(
|
||||
new OrbSandboxError({
|
||||
message: `Repository clone failed: ${result.stderr}`,
|
||||
reason: "CommandFailed",
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSandboxError({
|
||||
message: `Failed to create workspace: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "CommandFailed",
|
||||
}),
|
||||
try: () =>
|
||||
client.runProcess({
|
||||
args: ["-c", "mkdir -p /home/sandbox/repository"],
|
||||
command: "sh",
|
||||
cwd: "/home/sandbox",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const ctx = this.context;
|
||||
const vm = this.vm;
|
||||
if (ctx && vm) {
|
||||
yield* Effect.tryPromise({
|
||||
catch: () => null, // eslint-disable-next-line no-empty-function -- best-effort context staging
|
||||
try: () =>
|
||||
vm
|
||||
.mkdir("/mnt/sandbox/control", { recursive: true })
|
||||
.then(() =>
|
||||
vm.writeFile(
|
||||
"/mnt/sandbox/control/issue.md",
|
||||
`# ${ctx.issueTitle}\n\n${ctx.issueBody}\n`
|
||||
)
|
||||
),
|
||||
});
|
||||
for (const file of ctx.contextFiles) {
|
||||
yield* Effect.tryPromise({
|
||||
catch: () => null,
|
||||
try: () =>
|
||||
vm.writeFile(`/mnt/sandbox/control/${file.path}`, file.content),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
yield* this.setRunState("preparing");
|
||||
}
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// OpenCode session
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
readonly openSession = Effect.fn("Orb.openSession")(
|
||||
function* openSession(this: OrbHandle) {
|
||||
if (!this.vm) {
|
||||
return yield* Effect.fail(
|
||||
new OrbSessionError({
|
||||
message: "AgentOS VM is not attached",
|
||||
reason: "OpenSession",
|
||||
})
|
||||
);
|
||||
}
|
||||
if (!this.gateway || !this.context) {
|
||||
return yield* Effect.fail(
|
||||
new OrbConfigurationError({
|
||||
message: "Orb is not configured with gateway and context",
|
||||
reason: "MissingGateway",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const config = yield* prepareOpenCodeConfig({
|
||||
context: this.context,
|
||||
gateway: this.gateway,
|
||||
});
|
||||
|
||||
const vm = this.vm;
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSessionError({
|
||||
message: `Failed to create config directory: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "OpenSession",
|
||||
}),
|
||||
try: () => vm.mkdir("/root/.config/opencode", { recursive: true }),
|
||||
});
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSessionError({
|
||||
message: `Failed to write OpenCode config: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "OpenSession",
|
||||
}),
|
||||
try: () => vm.writeFile(config.configPath, config.configJson),
|
||||
});
|
||||
|
||||
// Restrict the config file containing the run-scoped gateway key.
|
||||
yield* Effect.tryPromise({
|
||||
catch: () => null, // eslint-disable-next-line no-empty-function -- best-effort hardening
|
||||
try: () => vm.exec(`chmod 600 ${config.configPath}`),
|
||||
}).pipe(Effect.ignore);
|
||||
|
||||
const agents = yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSessionError({
|
||||
message: `Failed to list agents: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "AgentNotInstalled",
|
||||
}),
|
||||
try: () => vm.listAgents(),
|
||||
});
|
||||
const hasOpencode = agents.some(
|
||||
(a) => a.id === "opencode" && a.installed
|
||||
);
|
||||
if (!hasOpencode) {
|
||||
return yield* Effect.fail(
|
||||
new OrbSessionError({
|
||||
message: "OpenCode agent is not installed in the VM",
|
||||
reason: "AgentNotInstalled",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSessionError({
|
||||
message: `Failed to open OpenCode session: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "OpenSession",
|
||||
}),
|
||||
try: () =>
|
||||
vm.openSession({
|
||||
agent: "opencode",
|
||||
cwd: "/mnt/sandbox/repository",
|
||||
permissionPolicy: "ask",
|
||||
skipOsInstructions: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const sessionInfo = yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSessionError({
|
||||
message: `Failed to read session info: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "OpenSession",
|
||||
}),
|
||||
try: () => vm.getSession(),
|
||||
});
|
||||
|
||||
this.sessionId = sessionInfo.sessionId;
|
||||
this.emitEvent(
|
||||
makeOrbEvent(this.nextSequence(), "session_opened", {
|
||||
text: `Session ${sessionInfo.sessionId} opened`,
|
||||
})
|
||||
);
|
||||
|
||||
yield* this.setOrbState("running");
|
||||
yield* this.setRunState("running");
|
||||
|
||||
return sessionInfo.sessionId as OrbSessionId;
|
||||
}
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Send task — raw prompt to model, redacted copy in events only
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
readonly sendTask = Effect.fn("Orb.sendTask")(function* sendTask(
|
||||
this: OrbHandle,
|
||||
prompt: string
|
||||
) {
|
||||
if (!this.vm || !this.sessionId) {
|
||||
return yield* Effect.fail(
|
||||
new OrbSessionError({
|
||||
message: "No active OpenCode session",
|
||||
reason: "SessionNotFound",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-destructuring -- captured before mutation
|
||||
const vm = this.vm;
|
||||
// eslint-disable-next-line prefer-destructuring -- captured before mutation
|
||||
const sessionId = this.sessionId;
|
||||
|
||||
// Send raw prompt — no redaction of outgoing content.
|
||||
const result = yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSessionError({
|
||||
message: `Prompt failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "PromptFailed",
|
||||
}),
|
||||
try: () =>
|
||||
vm.prompt({
|
||||
content: [{ text: prompt, type: "text" }],
|
||||
sessionId,
|
||||
}),
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Execute command via Docker
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
readonly executeCommand = Effect.fn("Orb.executeCommand")(
|
||||
function* executeCommand(
|
||||
this: OrbHandle,
|
||||
input: {
|
||||
readonly command: string;
|
||||
readonly cwd?: string;
|
||||
readonly env?: Readonly<Record<string, string>>;
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
) {
|
||||
if (!this.dockerProvider) {
|
||||
return yield* Effect.fail(
|
||||
new OrbSandboxError({
|
||||
message: "Docker sandbox is not attached",
|
||||
reason: "ContainerStart",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const client = yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSandboxError({
|
||||
message: `Failed to get sandbox client: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "ContainerStart",
|
||||
}),
|
||||
try: () => {
|
||||
const dp = this.dockerProvider;
|
||||
if (!dp) {
|
||||
throw new Error("Docker provider detached");
|
||||
}
|
||||
return dp.start();
|
||||
},
|
||||
});
|
||||
|
||||
const result = yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSandboxError({
|
||||
message: `Command failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "CommandFailed",
|
||||
}),
|
||||
try: () =>
|
||||
client.runProcess({
|
||||
args: ["-c", input.command],
|
||||
command: "sh",
|
||||
...(input.cwd === undefined ? {} : { cwd: input.cwd }),
|
||||
...(input.env === undefined ? {} : { env: input.env }),
|
||||
...(input.timeoutMs === undefined
|
||||
? {}
|
||||
: { timeoutMs: input.timeoutMs }),
|
||||
}),
|
||||
});
|
||||
|
||||
// Emit redacted copy for logs/UI.
|
||||
this.emitEvent(
|
||||
makeOrbEvent(this.nextSequence(), "command_executed", {
|
||||
command: redactSecrets(input.command),
|
||||
exitCode: result.exitCode ?? undefined,
|
||||
})
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cancel / dispose
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
readonly cancel = Effect.fn("Orb.cancel")(function* cancel(this: OrbHandle) {
|
||||
// eslint-disable-next-line prefer-destructuring -- captured before mutation
|
||||
const vm = this.vm;
|
||||
// eslint-disable-next-line prefer-destructuring -- captured before mutation
|
||||
const sessionId = this.sessionId;
|
||||
if (vm && sessionId) {
|
||||
yield* Effect.tryPromise({
|
||||
catch: () => null,
|
||||
try: () => vm.cancelPrompt({ sessionId }),
|
||||
}).pipe(Effect.ignore);
|
||||
}
|
||||
yield* this.setOrbState("cancelled");
|
||||
yield* this.setRunState("cancelled");
|
||||
this.emitEvent(
|
||||
makeOrbEvent(this.nextSequence(), "session_closed", {
|
||||
text: "Orb cancelled",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
readonly dispose = Effect.fn("Orb.dispose")(
|
||||
function* dispose(this: OrbHandle) {
|
||||
// eslint-disable-next-line prefer-destructuring -- captured before mutation
|
||||
const vm = this.vm;
|
||||
// eslint-disable-next-line prefer-destructuring -- captured before mutation
|
||||
const sessionId = this.sessionId;
|
||||
// eslint-disable-next-line prefer-destructuring -- captured before mutation
|
||||
const docker = this.dockerProvider;
|
||||
|
||||
if (vm && sessionId) {
|
||||
yield* Effect.tryPromise({
|
||||
catch: () => null,
|
||||
try: () => vm.cancelPrompt({ sessionId }),
|
||||
}).pipe(Effect.ignore);
|
||||
}
|
||||
// Dispose the VM before its sandbox so the mount unbinds cleanly.
|
||||
if (vm) {
|
||||
if (this.unsubscribeSession) {
|
||||
this.unsubscribeSession();
|
||||
this.unsubscribeSession = null;
|
||||
}
|
||||
yield* Effect.tryPromise({
|
||||
catch: () => null,
|
||||
try: () => vm.dispose(),
|
||||
}).pipe(Effect.ignore);
|
||||
}
|
||||
if (docker) {
|
||||
yield* Effect.tryPromise({
|
||||
catch: () => null,
|
||||
try: () => docker.dispose(),
|
||||
}).pipe(Effect.ignore);
|
||||
}
|
||||
yield* this.setOrbState("disposed");
|
||||
this.eventListeners.clear();
|
||||
this.vm = null;
|
||||
this.dockerProvider = null;
|
||||
this.sessionId = undefined;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OrbRuntime
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class OrbRuntime {
|
||||
private readonly orbs = new Map<string, OrbHandle>();
|
||||
private readonly env: OrbEnv;
|
||||
|
||||
constructor(env: OrbEnv = {}) {
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
readonly createOrb = Effect.fn("OrbRuntime.createOrb")(function* createOrb(
|
||||
this: OrbRuntime,
|
||||
input: OrbCreateInput
|
||||
) {
|
||||
if (!input.gateway.apiKey.trim()) {
|
||||
return yield* Effect.fail(
|
||||
new OrbConfigurationError({
|
||||
message: "Model gateway API key is required",
|
||||
reason: "MissingGateway",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const orbId =
|
||||
`orb-${input.identity.projectId}-${input.identity.runId}` as OrbId;
|
||||
const runId = `run-${input.identity.runId}` as OrbRunId;
|
||||
|
||||
const handle = new OrbHandle(orbId, runId, input.identity);
|
||||
handle._configure({
|
||||
context: input.context,
|
||||
gateway: input.gateway,
|
||||
});
|
||||
|
||||
// 1. Create and start Docker sandbox (the provider owns its container).
|
||||
const dockerOptions: DockerSandboxOptions = {
|
||||
...input.docker,
|
||||
...(this.env.dockerImage === undefined
|
||||
? {}
|
||||
: { image: this.env.dockerImage }),
|
||||
};
|
||||
const provider = yield* DockerSandboxProvider.create(dockerOptions);
|
||||
handle._attachDocker(provider);
|
||||
|
||||
// 2-3. Start the sandbox, create the AgentOS VM, and link OpenCode. Any
|
||||
// failure here disposes the VM before its sandbox so neither leaks.
|
||||
let createdVm: AgentOs | null = null;
|
||||
const vm = yield* Effect.gen(function* vm() {
|
||||
const sandboxClient = yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSandboxError({
|
||||
message: `Failed to start Docker sandbox: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "ContainerStart",
|
||||
}),
|
||||
try: () => provider.start(),
|
||||
});
|
||||
const created = yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSessionError({
|
||||
message: `Failed to create AgentOS VM: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "OpenSession",
|
||||
}),
|
||||
try: () =>
|
||||
AgentOs.create({
|
||||
database: {
|
||||
path: `/tmp/${orbId}.db`,
|
||||
type: "sqlite_file",
|
||||
},
|
||||
sandbox: {
|
||||
client: sandboxClient,
|
||||
dispose: false,
|
||||
mountPath: "/mnt/sandbox",
|
||||
readOnly: false,
|
||||
sandboxRoot: "/home/sandbox",
|
||||
},
|
||||
software: [opencodePkg],
|
||||
}),
|
||||
});
|
||||
createdVm = created;
|
||||
yield* Effect.tryPromise({
|
||||
catch: (cause) =>
|
||||
new OrbSessionError({
|
||||
message: `Failed to link OpenCode: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
reason: "AgentNotInstalled",
|
||||
}),
|
||||
try: () => created.linkSoftware({ path: opencodePkg.packagePath }),
|
||||
});
|
||||
return created;
|
||||
}).pipe(
|
||||
Effect.tapError(() =>
|
||||
Effect.tryPromise({
|
||||
catch: () => null,
|
||||
// eslint-disable-next-line no-use-before-define -- module-level cleanup helper
|
||||
try: () => disposeVmBeforeSandbox(createdVm, provider),
|
||||
}).pipe(Effect.ignore)
|
||||
)
|
||||
);
|
||||
|
||||
// 4. Attach VM to handle.
|
||||
handle._attachVm(vm);
|
||||
|
||||
this.orbs.set(orbId, handle);
|
||||
return handle;
|
||||
});
|
||||
|
||||
getOrb(id: string): OrbHandle | undefined {
|
||||
return this.orbs.get(id);
|
||||
}
|
||||
|
||||
listOrbs(): readonly OrbHandle[] {
|
||||
return [...this.orbs.values()];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Partial-failure cleanup — dispose the VM before its sandbox
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const disposeVmBeforeSandbox = async (
|
||||
vm: AgentOs | null,
|
||||
docker: DockerSandboxProvider
|
||||
): Promise<void> => {
|
||||
if (vm) {
|
||||
try {
|
||||
await vm.dispose();
|
||||
} catch {
|
||||
// best-effort; container removal below is the hard guarantee
|
||||
}
|
||||
}
|
||||
await docker.dispose();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell quoting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// eslint-disable-next-line no-use-before-define -- module-level helper
|
||||
const shellQuote = (value: string): string =>
|
||||
`'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
@@ -1,175 +0,0 @@
|
||||
import {
|
||||
decodeProjectIssueRequest,
|
||||
ProjectIssueDispatchInput,
|
||||
ProjectIssueRequestError,
|
||||
ProjectIssueRequestResult,
|
||||
ProjectIssueValidationError,
|
||||
} from "@code/primitives/project-issue";
|
||||
import type { ProjectIssueRequest } from "@code/primitives/project-issue";
|
||||
import { dispatch } from "@flue/runtime";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import { Effect, Schema } from "effect";
|
||||
import type { Context } from "hono";
|
||||
|
||||
import { createAuthenticatedClient, extractBearerToken } from "./auth";
|
||||
|
||||
interface ProjectIssueCreateArgs extends Record<string, unknown> {
|
||||
readonly body: string;
|
||||
readonly projectId: string;
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
const createIssue = makeFunctionReference<
|
||||
"mutation",
|
||||
ProjectIssueCreateArgs,
|
||||
string
|
||||
>("projectIssues:create");
|
||||
|
||||
const createIssueFromSignal = makeFunctionReference<
|
||||
"mutation",
|
||||
{ readonly signalId: string },
|
||||
{ readonly issueId: string; readonly projectId: string }
|
||||
>("projectIssues:createFromSignal");
|
||||
const getIssue = makeFunctionReference<
|
||||
"query",
|
||||
{ readonly issueId: string },
|
||||
{ readonly issueId: string; readonly projectId: string } | null
|
||||
>("projectIssues:getForDispatch");
|
||||
|
||||
const beginIssue = makeFunctionReference<
|
||||
"mutation",
|
||||
{ readonly issueId: string },
|
||||
"queued" | "working"
|
||||
>("projectIssues:begin");
|
||||
|
||||
const markDispatchFailed = makeFunctionReference<
|
||||
"mutation",
|
||||
{ readonly error: string; readonly issueId: string },
|
||||
null
|
||||
>("projectIssues:markDispatchFailed");
|
||||
|
||||
const invalidRequest = (message: string) =>
|
||||
Response.json({ error: message }, { status: 400 });
|
||||
|
||||
const knownAuthorizationFailure = (message: string): boolean =>
|
||||
/authentication required|membership required|project not found|signal not found|not project-scoped/iu.test(
|
||||
message
|
||||
);
|
||||
|
||||
const errorMessage = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
const decodeRequest = async (input: unknown): Promise<ProjectIssueRequest> => {
|
||||
try {
|
||||
return await Effect.runPromise(decodeProjectIssueRequest(input));
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ProjectIssueRequestError ||
|
||||
error instanceof ProjectIssueValidationError
|
||||
) {
|
||||
throw invalidRequest(
|
||||
error instanceof Error ? error.message : "Invalid project request"
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const createIssueForRequest = async (
|
||||
client: ConvexHttpClient,
|
||||
request: Awaited<ReturnType<typeof decodeRequest>>
|
||||
): Promise<{ readonly issueId: string; readonly projectId: string }> => {
|
||||
if (request.kind === "existing") {
|
||||
const issue = await client.query(getIssue, { issueId: request.issueId });
|
||||
if (!issue) {
|
||||
throw new Error("Project issue not found");
|
||||
}
|
||||
return issue;
|
||||
}
|
||||
|
||||
if (request.kind === "signal") {
|
||||
return client.mutation(createIssueFromSignal, {
|
||||
signalId: request.signalId,
|
||||
});
|
||||
}
|
||||
const issueId = await client.mutation(createIssue, {
|
||||
body: request.body,
|
||||
projectId: request.projectId,
|
||||
title: request.title,
|
||||
});
|
||||
return { issueId, projectId: request.projectId };
|
||||
};
|
||||
|
||||
export const projectRequestRoute = async (c: Context): Promise<Response> => {
|
||||
const accessToken = extractBearerToken(c.req.raw);
|
||||
if (!accessToken) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
let input: unknown;
|
||||
try {
|
||||
input = await c.req.json();
|
||||
} catch {
|
||||
return invalidRequest("Request body must be valid JSON");
|
||||
}
|
||||
|
||||
let request: Awaited<ReturnType<typeof decodeRequest>>;
|
||||
try {
|
||||
request = await decodeRequest(input);
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
return error;
|
||||
}
|
||||
return c.json({ error: "Invalid project request" }, 400);
|
||||
}
|
||||
|
||||
const client = createAuthenticatedClient(accessToken);
|
||||
let issue:
|
||||
| { readonly issueId: string; readonly projectId: string }
|
||||
| undefined;
|
||||
try {
|
||||
issue = await createIssueForRequest(client, request);
|
||||
const status = await client.mutation(beginIssue, {
|
||||
issueId: issue.issueId,
|
||||
});
|
||||
const dispatchInput = Schema.decodeUnknownSync(ProjectIssueDispatchInput)({
|
||||
issueId: issue.issueId,
|
||||
kind: "project.issue.started",
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
const receipt = await dispatch({
|
||||
agent: "project-manager",
|
||||
id: issue.issueId,
|
||||
input: dispatchInput,
|
||||
});
|
||||
const result = Schema.decodeUnknownSync(ProjectIssueRequestResult)({
|
||||
acceptedAt: receipt.acceptedAt,
|
||||
dispatchId: receipt.dispatchId,
|
||||
issueId: issue.issueId,
|
||||
projectId: issue.projectId,
|
||||
status,
|
||||
});
|
||||
return c.json(result, 202);
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
if (issue) {
|
||||
try {
|
||||
await client.mutation(markDispatchFailed, {
|
||||
error: message,
|
||||
issueId: issue.issueId,
|
||||
});
|
||||
} catch {
|
||||
// Preserve the original request failure; the issue remains inspectable.
|
||||
}
|
||||
}
|
||||
return c.json(
|
||||
{
|
||||
error: knownAuthorizationFailure(message)
|
||||
? "Project request is not authorized"
|
||||
: "Project request could not be dispatched",
|
||||
},
|
||||
knownAuthorizationFailure(message) ? 403 : 502
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,312 +0,0 @@
|
||||
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";
|
||||
|
||||
import {
|
||||
clonePublicRepository,
|
||||
mirrorHostCheckoutToSandbox,
|
||||
} from "./host-repository-bridge";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export 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 hostCheckout = await clonePublicRepository({
|
||||
branchName: plan.branchName,
|
||||
repositoryUrl: plan.sourceUrl,
|
||||
timeoutMs: checkoutOperation.timeoutMs,
|
||||
});
|
||||
try {
|
||||
if (!(await sandbox.exists(plan.checkoutPath))) {
|
||||
await mirrorHostCheckoutToSandbox({
|
||||
checkoutDirectory: hostCheckout.checkoutDirectory,
|
||||
sandbox,
|
||||
sandboxDirectory: plan.checkoutPath,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await hostCheckout.cleanup();
|
||||
}
|
||||
const checkoutCommandResult = {
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
stdout: `${plan.branchName} ${hostCheckout.headSha}\n`,
|
||||
};
|
||||
|
||||
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");
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,121 +0,0 @@
|
||||
import {
|
||||
createSandboxSessionEnv,
|
||||
SandboxOperationUnsupportedError,
|
||||
} from "@flue/runtime";
|
||||
import type {
|
||||
FileStat,
|
||||
SandboxApi,
|
||||
SandboxFactory,
|
||||
SessionEnv,
|
||||
} from "@flue/runtime";
|
||||
import { AgentOs } from "@rivet-dev/agentos-core";
|
||||
import type { VirtualStat } from "@rivet-dev/agentos-core";
|
||||
|
||||
class AgentOsSandboxApi implements SandboxApi {
|
||||
private readonly vm: AgentOs;
|
||||
|
||||
constructor(vm: AgentOs) {
|
||||
this.vm = vm;
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<string> {
|
||||
const bytes = await this.vm.readFile(path);
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
readFileBuffer(path: string): Promise<Uint8Array> {
|
||||
return this.vm.readFile(path);
|
||||
}
|
||||
|
||||
async writeFile(path: string, content: string | Uint8Array): Promise<void> {
|
||||
await this.vm.writeFile(path, content);
|
||||
}
|
||||
|
||||
async stat(path: string): Promise<FileStat> {
|
||||
const s: VirtualStat = await this.vm.stat(path);
|
||||
return {
|
||||
isDirectory: s.isDirectory,
|
||||
isFile: !s.isDirectory && !s.isSymbolicLink,
|
||||
isSymbolicLink: s.isSymbolicLink,
|
||||
mtime: new Date(s.mtimeMs),
|
||||
size: s.size,
|
||||
};
|
||||
}
|
||||
|
||||
readdir(path: string): Promise<string[]> {
|
||||
return this.vm.readdir(path);
|
||||
}
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
return await this.vm.exists(path);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
|
||||
await this.vm.mkdir(path, options);
|
||||
}
|
||||
|
||||
async rm(
|
||||
path: string,
|
||||
options?: { recursive?: boolean; force?: boolean }
|
||||
): Promise<void> {
|
||||
// agentOS remove has no `force` flag. Reject it per the adapter contract:
|
||||
// never ignore an option or leave its behavior provider-defined.
|
||||
if (options?.force) {
|
||||
throw new SandboxOperationUnsupportedError({
|
||||
operation: "rm",
|
||||
options: ["force"],
|
||||
provider: "agentos",
|
||||
});
|
||||
}
|
||||
await this.vm.remove(path, { recursive: options?.recursive });
|
||||
}
|
||||
|
||||
async exec(
|
||||
command: string,
|
||||
options?: {
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
// agentOS exec takes timeout in ms, same unit as Flue's timeoutMs.
|
||||
// Forward directly — no rounding needed.
|
||||
const result = await this.vm.exec(command, {
|
||||
cwd: options?.cwd,
|
||||
env: options?.env,
|
||||
timeout: options?.timeoutMs,
|
||||
});
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.stderr,
|
||||
stdout: result.stdout,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts an agentOS VM into Flue's sandbox contract.
|
||||
*
|
||||
* The VM boots lazily on the first operation and sleeps when idle. Files
|
||||
* persist for the VM's lifetime. Shell commands run in an isolated Wasm+V8
|
||||
* Linux environment with sh and coreutils.
|
||||
*/
|
||||
export const agentos = (): SandboxFactory => {
|
||||
let vm: AgentOs | null = null;
|
||||
|
||||
return {
|
||||
async createSessionEnv(): Promise<SessionEnv> {
|
||||
// Boot once per harness. Repeated calls reuse the same VM.
|
||||
if (!vm) {
|
||||
vm = await AgentOs.create();
|
||||
}
|
||||
const api = new AgentOsSandboxApi(vm);
|
||||
return createSandboxSessionEnv(api, "/workspace");
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,297 +0,0 @@
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import nodePath from "node:path";
|
||||
|
||||
import type { FileStat, SandboxApi } from "@flue/runtime";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
mirrorHostCheckoutToSandbox,
|
||||
mirrorSandboxToHostCheckout,
|
||||
parseGitCommitSha,
|
||||
parsePublicGitUrl,
|
||||
} from "./host-repository-bridge";
|
||||
|
||||
class FakeSandbox implements SandboxApi {
|
||||
readonly files = new Map<string, Uint8Array>();
|
||||
readonly directories = new Set<string>(["/"]);
|
||||
|
||||
async readFile(path: string): Promise<string> {
|
||||
return new TextDecoder().decode(await this.readFileBuffer(path));
|
||||
}
|
||||
|
||||
readFileBuffer(path: string): Promise<Uint8Array> {
|
||||
const content = this.files.get(path);
|
||||
if (!content) {
|
||||
throw new Error(`Missing fake sandbox file: ${path}`);
|
||||
}
|
||||
return Promise.resolve(Uint8Array.from(content));
|
||||
}
|
||||
|
||||
writeFile(path: string, content: string | Uint8Array): Promise<void> {
|
||||
this.files.set(
|
||||
path,
|
||||
typeof content === "string"
|
||||
? new TextEncoder().encode(content)
|
||||
: Uint8Array.from(content)
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
stat(path: string): Promise<FileStat> {
|
||||
const content = this.files.get(path);
|
||||
if (content) {
|
||||
return Promise.resolve({
|
||||
isDirectory: false,
|
||||
isFile: true,
|
||||
size: content.byteLength,
|
||||
});
|
||||
}
|
||||
if (this.directories.has(path)) {
|
||||
return Promise.resolve({ isDirectory: true, isFile: false });
|
||||
}
|
||||
throw new Error(`Missing fake sandbox path: ${path}`);
|
||||
}
|
||||
|
||||
readdir(path: string): Promise<string[]> {
|
||||
const prefix = path === "/" ? "/" : `${path}/`;
|
||||
const entries = new Set<string>();
|
||||
for (const directory of this.directories) {
|
||||
if (directory.startsWith(prefix)) {
|
||||
const [entry] = directory.slice(prefix.length).split("/");
|
||||
if (entry) {
|
||||
entries.add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const file of this.files.keys()) {
|
||||
if (file.startsWith(prefix)) {
|
||||
const [entry] = file.slice(prefix.length).split("/");
|
||||
if (entry) {
|
||||
entries.add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.resolve([...entries]);
|
||||
}
|
||||
|
||||
exists(path: string): Promise<boolean> {
|
||||
return Promise.resolve(this.files.has(path) || this.directories.has(path));
|
||||
}
|
||||
|
||||
mkdir(
|
||||
path: string,
|
||||
options?: { readonly recursive?: boolean }
|
||||
): Promise<void> {
|
||||
if (!options?.recursive) {
|
||||
this.directories.add(path);
|
||||
return Promise.resolve();
|
||||
}
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
let current = "";
|
||||
for (const part of parts) {
|
||||
current += `/${part}`;
|
||||
this.directories.add(current);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
rm(
|
||||
path: string,
|
||||
options?: { readonly force?: boolean; readonly recursive?: boolean }
|
||||
): Promise<void> {
|
||||
const prefix = `${path}/`;
|
||||
if (options?.recursive) {
|
||||
for (const file of this.files.keys()) {
|
||||
if (file === path || file.startsWith(prefix)) {
|
||||
this.files.delete(file);
|
||||
}
|
||||
}
|
||||
for (const directory of this.directories) {
|
||||
if (directory === path || directory.startsWith(prefix)) {
|
||||
this.directories.delete(directory);
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
this.files.delete(path);
|
||||
this.directories.delete(path);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
exec(): Promise<{
|
||||
exitCode: number;
|
||||
stderr: string;
|
||||
stdout: string;
|
||||
}> {
|
||||
void this.directories;
|
||||
return Promise.resolve({ exitCode: 0, stderr: "", stdout: "" });
|
||||
}
|
||||
}
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
const makeCheckout = async (): Promise<string> => {
|
||||
const directory = await mkdtemp(nodePath.join(tmpdir(), "host-bridge-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
await mkdir(nodePath.join(directory, ".git"), { recursive: true });
|
||||
await writeFile(nodePath.join(directory, ".git", "keep"), "git metadata");
|
||||
return directory;
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true }))
|
||||
);
|
||||
});
|
||||
|
||||
describe("parsePublicGitUrl", () => {
|
||||
it("accepts public HTTP(S) repositories", () => {
|
||||
expect(
|
||||
parsePublicGitUrl("https://git.openputer.com/team/repository.git")
|
||||
.hostname
|
||||
).toBe("git.openputer.com");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"file:///tmp/repository",
|
||||
"ssh://git@example.com/team/repository.git",
|
||||
"https://token@example.com/team/repository.git",
|
||||
"http://127.0.0.1/repository.git",
|
||||
"http://192.168.1.10/repository.git",
|
||||
])("rejects non-public repository URL %s", (repositoryUrl) => {
|
||||
expect(() => parsePublicGitUrl(repositoryUrl)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseGitCommitSha", () => {
|
||||
it("normalizes a full SHA returned by git", () => {
|
||||
const sha = "a".repeat(40);
|
||||
expect(parseGitCommitSha(` ${sha}\n`)).toBe(sha);
|
||||
});
|
||||
|
||||
it.each(["", "abc123", "g".repeat(40), "a".repeat(41)])(
|
||||
"rejects invalid commit SHA %s",
|
||||
(sha) => {
|
||||
expect(() => parseGitCommitSha(sha)).toThrow("invalid HEAD commit SHA");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("repository mirroring", () => {
|
||||
it("copies host files into a sandbox without copying .git", async () => {
|
||||
const checkoutDirectory = await makeCheckout();
|
||||
await mkdir(nodePath.join(checkoutDirectory, "src"), {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(nodePath.join(checkoutDirectory, "README.md"), "hello");
|
||||
await writeFile(
|
||||
nodePath.join(checkoutDirectory, "src", "binary.bin"),
|
||||
Uint8Array.from([0, 255, 1])
|
||||
);
|
||||
const sandbox = new FakeSandbox();
|
||||
|
||||
await mirrorHostCheckoutToSandbox({
|
||||
checkoutDirectory,
|
||||
sandbox,
|
||||
sandboxDirectory: "/workspace/repository",
|
||||
});
|
||||
|
||||
expect(
|
||||
new TextDecoder().decode(
|
||||
sandbox.files.get("/workspace/repository/README.md")
|
||||
)
|
||||
).toBe("hello");
|
||||
expect(sandbox.files.get("/workspace/repository/src/binary.bin")).toEqual(
|
||||
Uint8Array.from([0, 255, 1])
|
||||
);
|
||||
expect(
|
||||
[...sandbox.files.keys()].some((filePath) => filePath.includes("/.git/"))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("stages sandbox files, removes stale files, and preserves .git", async () => {
|
||||
const checkoutDirectory = await makeCheckout();
|
||||
await writeFile(nodePath.join(checkoutDirectory, "stale.txt"), "delete me");
|
||||
const executablePath = nodePath.join(checkoutDirectory, "run.sh");
|
||||
await writeFile(executablePath, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
||||
const sandbox = new FakeSandbox();
|
||||
await sandbox.mkdir("/workspace/repository/src", { recursive: true });
|
||||
await sandbox.writeFile(
|
||||
"/workspace/repository/run.sh",
|
||||
"#!/bin/sh\necho changed\n"
|
||||
);
|
||||
await sandbox.writeFile(
|
||||
"/workspace/repository/src/index.ts",
|
||||
"export const value = 1;\n"
|
||||
);
|
||||
|
||||
await mirrorSandboxToHostCheckout({
|
||||
checkoutDirectory,
|
||||
sandbox,
|
||||
sandboxDirectory: "/workspace/repository",
|
||||
});
|
||||
|
||||
await expect(
|
||||
readFile(nodePath.join(checkoutDirectory, "stale.txt"))
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
readFile(nodePath.join(checkoutDirectory, ".git", "keep"), "utf-8")
|
||||
).resolves.toBe("git metadata");
|
||||
await expect(
|
||||
readFile(nodePath.join(checkoutDirectory, "src", "index.ts"), "utf-8")
|
||||
).resolves.toBe("export const value = 1;\n");
|
||||
const executableMetadata = await stat(executablePath);
|
||||
expect(executableMetadata.mode % 0o1000).toBe(0o755);
|
||||
});
|
||||
|
||||
it("enforces transfer limits before modifying the sandbox", async () => {
|
||||
const checkoutDirectory = await makeCheckout();
|
||||
await writeFile(nodePath.join(checkoutDirectory, "one.txt"), "1");
|
||||
await writeFile(nodePath.join(checkoutDirectory, "two.txt"), "2");
|
||||
const sandbox = new FakeSandbox();
|
||||
|
||||
await expect(
|
||||
mirrorHostCheckoutToSandbox({
|
||||
checkoutDirectory,
|
||||
limits: { maxFiles: 1 },
|
||||
sandbox,
|
||||
sandboxDirectory: "/workspace/repository",
|
||||
})
|
||||
).rejects.toThrow("limit is 1");
|
||||
expect(sandbox.files.size).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects host symbolic links", async () => {
|
||||
const checkoutDirectory = await makeCheckout();
|
||||
await writeFile(nodePath.join(checkoutDirectory, "outside.txt"), "outside");
|
||||
await symlink(
|
||||
"outside.txt",
|
||||
nodePath.join(checkoutDirectory, "linked.txt")
|
||||
);
|
||||
const sandbox = new FakeSandbox();
|
||||
|
||||
await expect(
|
||||
mirrorHostCheckoutToSandbox({
|
||||
checkoutDirectory,
|
||||
sandbox,
|
||||
sandboxDirectory: "/workspace/repository",
|
||||
})
|
||||
).rejects.toThrow("Symbolic links are not supported");
|
||||
const linkedMetadata = await lstat(
|
||||
nodePath.join(checkoutDirectory, "linked.txt")
|
||||
);
|
||||
expect(linkedMetadata.isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,506 +0,0 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
chmod,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { isIP } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import type { SandboxApi } from "@flue/runtime";
|
||||
|
||||
const DEFAULT_GIT_TIMEOUT_MS = 60_000;
|
||||
const GIT_OUTPUT_LIMIT_BYTES = 1_048_576;
|
||||
|
||||
export interface RepositoryMirrorLimits {
|
||||
readonly maxDepth: number;
|
||||
readonly maxFileBytes: number;
|
||||
readonly maxFiles: number;
|
||||
readonly maxTotalBytes: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_REPOSITORY_MIRROR_LIMITS: RepositoryMirrorLimits = {
|
||||
maxDepth: 64,
|
||||
maxFileBytes: 10 * 1024 * 1024,
|
||||
maxFiles: 10_000,
|
||||
maxTotalBytes: 100 * 1024 * 1024,
|
||||
};
|
||||
|
||||
export interface HostRepositoryCheckout {
|
||||
readonly branchName: string;
|
||||
readonly checkoutDirectory: string;
|
||||
readonly headSha: string;
|
||||
readonly temporaryDirectory: string;
|
||||
readonly cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface MirrorFile {
|
||||
readonly mode?: number;
|
||||
readonly path: string;
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
interface GitResult {
|
||||
readonly stderr: string;
|
||||
readonly stdout: string;
|
||||
}
|
||||
|
||||
const gitError = (
|
||||
args: readonly string[],
|
||||
error: Error & { readonly stderr?: string }
|
||||
): Error => {
|
||||
const detail = error.stderr?.trim() || error.message;
|
||||
return new Error(`git ${args.join(" ")} failed: ${detail}`, {
|
||||
cause: error,
|
||||
});
|
||||
};
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const runGit = async (
|
||||
args: readonly string[],
|
||||
options: { readonly cwd?: string; readonly timeoutMs: number }
|
||||
): Promise<GitResult> => {
|
||||
try {
|
||||
const result = await execFileAsync("git", [...args], {
|
||||
cwd: options.cwd,
|
||||
maxBuffer: GIT_OUTPUT_LIMIT_BYTES,
|
||||
timeout: options.timeoutMs,
|
||||
});
|
||||
return {
|
||||
stderr: String(result.stderr),
|
||||
stdout: String(result.stdout),
|
||||
};
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw error;
|
||||
}
|
||||
throw gitError(
|
||||
args,
|
||||
error as Error & {
|
||||
readonly stderr?: string;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isPrivateIpv4 = (hostname: string): boolean => {
|
||||
const octets = hostname.split(".").map(Number);
|
||||
const [first = -1, second = -1] = octets;
|
||||
return (
|
||||
first === 0 ||
|
||||
first === 10 ||
|
||||
first === 127 ||
|
||||
(first === 169 && second === 254) ||
|
||||
(first === 172 && second >= 16 && second <= 31) ||
|
||||
(first === 192 && second === 168)
|
||||
);
|
||||
};
|
||||
|
||||
const isPrivateIpv6 = (hostname: string): boolean => {
|
||||
const normalized = hostname
|
||||
.toLowerCase()
|
||||
.replaceAll("[", "")
|
||||
.replaceAll("]", "");
|
||||
return (
|
||||
normalized === "::" ||
|
||||
normalized === "::1" ||
|
||||
normalized.startsWith("fc") ||
|
||||
normalized.startsWith("fd") ||
|
||||
normalized.startsWith("fe8") ||
|
||||
normalized.startsWith("fe9") ||
|
||||
normalized.startsWith("fea") ||
|
||||
normalized.startsWith("feb")
|
||||
);
|
||||
};
|
||||
|
||||
export const parsePublicGitUrl = (repositoryUrl: string): URL => {
|
||||
const url = new URL(repositoryUrl);
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
||||
throw new Error("Public Git repositories must use an HTTP(S) URL");
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
throw new Error("Public Git repository URLs must not contain credentials");
|
||||
}
|
||||
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const addressKind = isIP(hostname);
|
||||
const isPrivateAddress =
|
||||
(addressKind === 4 && isPrivateIpv4(hostname)) ||
|
||||
(addressKind === 6 && isPrivateIpv6(hostname));
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
hostname.endsWith(".localhost") ||
|
||||
hostname.endsWith(".local") ||
|
||||
isPrivateAddress
|
||||
) {
|
||||
throw new Error("Public Git repository URLs must use a public host");
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export const parseGitCommitSha = (value: string): string => {
|
||||
const sha = value.trim();
|
||||
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(sha)) {
|
||||
throw new Error("Git returned an invalid HEAD commit SHA");
|
||||
}
|
||||
return sha;
|
||||
};
|
||||
|
||||
export const clonePublicRepository = async (options: {
|
||||
readonly branchName: string;
|
||||
readonly repositoryUrl: string;
|
||||
readonly timeoutMs?: number;
|
||||
}): Promise<HostRepositoryCheckout> => {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS;
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new Error("Git timeout must be a positive integer");
|
||||
}
|
||||
|
||||
const repositoryUrl = parsePublicGitUrl(options.repositoryUrl).toString();
|
||||
await runGit(["check-ref-format", "--branch", options.branchName], {
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
const temporaryDirectory = await mkdtemp(
|
||||
path.join(tmpdir(), "zopu-host-repository-")
|
||||
);
|
||||
const checkoutDirectory = path.join(temporaryDirectory, "repository");
|
||||
let headSha = "";
|
||||
|
||||
try {
|
||||
await runGit(
|
||||
[
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"--single-branch",
|
||||
"--no-tags",
|
||||
"--",
|
||||
repositoryUrl,
|
||||
checkoutDirectory,
|
||||
],
|
||||
{ timeoutMs }
|
||||
);
|
||||
await runGit(["switch", "-c", options.branchName], {
|
||||
cwd: checkoutDirectory,
|
||||
timeoutMs,
|
||||
});
|
||||
const headResult = await runGit(["rev-parse", "HEAD"], {
|
||||
cwd: checkoutDirectory,
|
||||
timeoutMs,
|
||||
});
|
||||
headSha = parseGitCommitSha(headResult.stdout);
|
||||
} catch (error) {
|
||||
await rm(temporaryDirectory, { force: true, recursive: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
branchName: options.branchName,
|
||||
checkoutDirectory,
|
||||
cleanup: () =>
|
||||
rm(temporaryDirectory, {
|
||||
force: true,
|
||||
recursive: true,
|
||||
}),
|
||||
headSha,
|
||||
temporaryDirectory,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveLimits = (
|
||||
limits: Partial<RepositoryMirrorLimits> | undefined
|
||||
): RepositoryMirrorLimits => {
|
||||
const resolvedLimits = {
|
||||
...DEFAULT_REPOSITORY_MIRROR_LIMITS,
|
||||
...limits,
|
||||
};
|
||||
for (const [name, value] of Object.entries(resolvedLimits)) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer`);
|
||||
}
|
||||
}
|
||||
return resolvedLimits;
|
||||
};
|
||||
|
||||
const assertSafeEntryName = (name: string): void => {
|
||||
if (
|
||||
!name ||
|
||||
name === "." ||
|
||||
name === ".." ||
|
||||
name.includes("/") ||
|
||||
name.includes("\\")
|
||||
) {
|
||||
throw new Error(`Sandbox returned an unsafe directory entry: ${name}`);
|
||||
}
|
||||
};
|
||||
|
||||
const assertWithinCheckout = (checkoutDirectory: string): string => {
|
||||
const absoluteCheckout = path.resolve(checkoutDirectory);
|
||||
if (absoluteCheckout === path.resolve(path.sep)) {
|
||||
throw new Error("Repository checkout cannot be the filesystem root");
|
||||
}
|
||||
return absoluteCheckout;
|
||||
};
|
||||
|
||||
const assertAbsoluteSandboxDirectory = (sandboxDirectory: string): string => {
|
||||
if (!path.posix.isAbsolute(sandboxDirectory)) {
|
||||
throw new Error("Sandbox directory must be absolute");
|
||||
}
|
||||
return path.posix.normalize(sandboxDirectory);
|
||||
};
|
||||
|
||||
const enforceFileBounds = (
|
||||
files: readonly MirrorFile[],
|
||||
limits: RepositoryMirrorLimits
|
||||
): void => {
|
||||
if (files.length > limits.maxFiles) {
|
||||
throw new Error(
|
||||
`Repository contains ${files.length} files; limit is ${limits.maxFiles}`
|
||||
);
|
||||
}
|
||||
let totalBytes = 0;
|
||||
for (const file of files) {
|
||||
if (file.size > limits.maxFileBytes) {
|
||||
throw new Error(
|
||||
`${file.path} is ${file.size} bytes; per-file limit is ${limits.maxFileBytes}`
|
||||
);
|
||||
}
|
||||
totalBytes += file.size;
|
||||
if (totalBytes > limits.maxTotalBytes) {
|
||||
throw new Error(
|
||||
`Repository files exceed the ${limits.maxTotalBytes}-byte total limit`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const listHostFiles = async (
|
||||
checkoutDirectory: string,
|
||||
limits: RepositoryMirrorLimits
|
||||
): Promise<MirrorFile[]> => {
|
||||
const files: MirrorFile[] = [];
|
||||
|
||||
const visit = async (
|
||||
directory: string,
|
||||
relativeDirectory: string
|
||||
): Promise<void> => {
|
||||
const depth = relativeDirectory
|
||||
? relativeDirectory.split(path.sep).length
|
||||
: 0;
|
||||
if (depth > limits.maxDepth) {
|
||||
throw new Error(`Repository directory depth exceeds ${limits.maxDepth}`);
|
||||
}
|
||||
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
for (const entry of entries) {
|
||||
if (!relativeDirectory && entry.name === ".git") {
|
||||
continue;
|
||||
}
|
||||
const relativePath = path.join(relativeDirectory, entry.name);
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(`Symbolic links are not supported: ${relativePath}`);
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
// oxlint-disable-next-line no-await-in-loop -- deterministic bounded traversal.
|
||||
await visit(absolutePath, relativePath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
throw new Error(`Unsupported repository entry: ${relativePath}`);
|
||||
}
|
||||
// oxlint-disable-next-line no-await-in-loop -- metadata is bounded before file transfer.
|
||||
const metadata = await lstat(absolutePath);
|
||||
files.push({
|
||||
mode: metadata.mode % 0o1000,
|
||||
path: relativePath.split(path.sep).join(path.posix.sep),
|
||||
size: metadata.size,
|
||||
});
|
||||
enforceFileBounds(files, limits);
|
||||
}
|
||||
};
|
||||
|
||||
await visit(checkoutDirectory, "");
|
||||
return files;
|
||||
};
|
||||
|
||||
const listSandboxFiles = async (
|
||||
sandbox: SandboxApi,
|
||||
sandboxDirectory: string,
|
||||
limits: RepositoryMirrorLimits
|
||||
): Promise<MirrorFile[]> => {
|
||||
const files: MirrorFile[] = [];
|
||||
|
||||
const visit = async (
|
||||
directory: string,
|
||||
relativeDirectory: string
|
||||
): Promise<void> => {
|
||||
const depth = relativeDirectory
|
||||
? relativeDirectory.split(path.posix.sep).length
|
||||
: 0;
|
||||
if (depth > limits.maxDepth) {
|
||||
throw new Error(`Sandbox directory depth exceeds ${limits.maxDepth}`);
|
||||
}
|
||||
|
||||
const entries = [...(await sandbox.readdir(directory))].toSorted(
|
||||
(left, right) => left.localeCompare(right)
|
||||
);
|
||||
for (const entry of entries) {
|
||||
assertSafeEntryName(entry);
|
||||
if (!relativeDirectory && entry === ".git") {
|
||||
continue;
|
||||
}
|
||||
const relativePath = path.posix.join(relativeDirectory, entry);
|
||||
const absolutePath = path.posix.join(directory, entry);
|
||||
// oxlint-disable-next-line no-await-in-loop -- sandbox metadata is read sequentially and bounded.
|
||||
const metadata = await sandbox.stat(absolutePath);
|
||||
if (metadata.isSymbolicLink) {
|
||||
throw new Error(`Symbolic links are not supported: ${relativePath}`);
|
||||
}
|
||||
if (metadata.isDirectory) {
|
||||
// oxlint-disable-next-line no-await-in-loop -- deterministic bounded traversal.
|
||||
await visit(absolutePath, relativePath);
|
||||
continue;
|
||||
}
|
||||
if (!metadata.isFile) {
|
||||
throw new Error(`Unsupported sandbox entry: ${relativePath}`);
|
||||
}
|
||||
if (metadata.size === undefined) {
|
||||
throw new Error(`Sandbox did not report a size for ${relativePath}`);
|
||||
}
|
||||
files.push({ path: relativePath, size: metadata.size });
|
||||
enforceFileBounds(files, limits);
|
||||
}
|
||||
};
|
||||
|
||||
await visit(sandboxDirectory, "");
|
||||
return files;
|
||||
};
|
||||
|
||||
export const mirrorHostCheckoutToSandbox = async (options: {
|
||||
readonly checkoutDirectory: string;
|
||||
readonly limits?: Partial<RepositoryMirrorLimits>;
|
||||
readonly sandbox: SandboxApi;
|
||||
readonly sandboxDirectory: string;
|
||||
}): Promise<void> => {
|
||||
const checkoutDirectory = assertWithinCheckout(options.checkoutDirectory);
|
||||
const sandboxDirectory = assertAbsoluteSandboxDirectory(
|
||||
options.sandboxDirectory
|
||||
);
|
||||
const limits = resolveLimits(options.limits);
|
||||
const files = await listHostFiles(checkoutDirectory, limits);
|
||||
|
||||
await options.sandbox.mkdir(sandboxDirectory, { recursive: true });
|
||||
for (const file of files) {
|
||||
const hostPath = path.join(
|
||||
checkoutDirectory,
|
||||
...file.path.split(path.posix.sep)
|
||||
);
|
||||
const sandboxPath = path.posix.join(sandboxDirectory, file.path);
|
||||
// oxlint-disable-next-line no-await-in-loop -- bounded sequential writes avoid overwhelming remote sandboxes.
|
||||
await options.sandbox.mkdir(path.posix.dirname(sandboxPath), {
|
||||
recursive: true,
|
||||
});
|
||||
// oxlint-disable-next-line no-await-in-loop -- bounded sequential transfer limits peak memory.
|
||||
const content = await readFile(hostPath);
|
||||
if (content.byteLength !== file.size) {
|
||||
throw new Error(`${file.path} changed while it was being mirrored`);
|
||||
}
|
||||
// oxlint-disable-next-line no-await-in-loop -- bounded sequential writes avoid overwhelming remote sandboxes.
|
||||
await options.sandbox.writeFile(sandboxPath, content);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCheckoutFiles = async (checkoutDirectory: string): Promise<void> => {
|
||||
const entries = await readdir(checkoutDirectory);
|
||||
for (const entry of entries) {
|
||||
if (entry === ".git") {
|
||||
continue;
|
||||
}
|
||||
// oxlint-disable-next-line no-await-in-loop -- deletion is deliberately scoped to checkout children.
|
||||
await rm(path.join(checkoutDirectory, entry), {
|
||||
force: true,
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const mirrorSandboxToHostCheckout = async (options: {
|
||||
readonly checkoutDirectory: string;
|
||||
readonly limits?: Partial<RepositoryMirrorLimits>;
|
||||
readonly sandbox: SandboxApi;
|
||||
readonly sandboxDirectory: string;
|
||||
}): Promise<void> => {
|
||||
const checkoutDirectory = assertWithinCheckout(options.checkoutDirectory);
|
||||
const sandboxDirectory = assertAbsoluteSandboxDirectory(
|
||||
options.sandboxDirectory
|
||||
);
|
||||
const limits = resolveLimits(options.limits);
|
||||
const files = await listSandboxFiles(
|
||||
options.sandbox,
|
||||
sandboxDirectory,
|
||||
limits
|
||||
);
|
||||
const originalFiles = await listHostFiles(checkoutDirectory, limits);
|
||||
const originalModes = new Map(
|
||||
originalFiles.map((file) => [file.path, file.mode])
|
||||
);
|
||||
const stagingDirectory = await mkdtemp(
|
||||
path.join(path.dirname(checkoutDirectory), ".zopu-sandbox-mirror-")
|
||||
);
|
||||
|
||||
try {
|
||||
let transferredBytes = 0;
|
||||
for (const file of files) {
|
||||
const sandboxPath = path.posix.join(sandboxDirectory, file.path);
|
||||
// oxlint-disable-next-line no-await-in-loop -- bounded sequential reads limit peak memory.
|
||||
const content = await options.sandbox.readFileBuffer(sandboxPath);
|
||||
if (content.byteLength !== file.size) {
|
||||
throw new Error(`${file.path} changed while it was being mirrored`);
|
||||
}
|
||||
transferredBytes += content.byteLength;
|
||||
if (transferredBytes > limits.maxTotalBytes) {
|
||||
throw new Error(
|
||||
`Sandbox files exceed the ${limits.maxTotalBytes}-byte total limit`
|
||||
);
|
||||
}
|
||||
|
||||
const stagedPath = path.join(
|
||||
stagingDirectory,
|
||||
...file.path.split(path.posix.sep)
|
||||
);
|
||||
// oxlint-disable-next-line no-await-in-loop -- staged writes keep the checkout intact until transfer succeeds.
|
||||
await mkdir(path.dirname(stagedPath), { recursive: true });
|
||||
// oxlint-disable-next-line no-await-in-loop -- bounded sequential writes limit peak memory.
|
||||
await writeFile(stagedPath, content);
|
||||
const originalMode = originalModes.get(file.path);
|
||||
if (originalMode !== undefined) {
|
||||
// oxlint-disable-next-line no-await-in-loop -- preserve executable bits for existing files.
|
||||
await chmod(stagedPath, originalMode);
|
||||
}
|
||||
}
|
||||
|
||||
await clearCheckoutFiles(checkoutDirectory);
|
||||
const stagedEntries = await readdir(stagingDirectory);
|
||||
for (const entry of stagedEntries) {
|
||||
// oxlint-disable-next-line no-await-in-loop -- each top-level entry is moved exactly once.
|
||||
await rename(
|
||||
path.join(stagingDirectory, entry),
|
||||
path.join(checkoutDirectory, entry)
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await rm(stagingDirectory, { force: true, recursive: true });
|
||||
}
|
||||
};
|
||||
@@ -1,143 +0,0 @@
|
||||
---
|
||||
name: paseo
|
||||
description: Paseo reference for managing workspaces, agents, schedules, and heartbeats.
|
||||
---
|
||||
|
||||
Paseo is a daemon that supervises AI coding agents on your machine. Control it through tools or a CLI.
|
||||
|
||||
## Workspaces
|
||||
|
||||
**`create_workspace`** — create a workspace independently of any agent. Required: `isolation` (`local` or `worktree`). Worktree isolation supports `mode: "branch-off" | "checkout-branch" | "checkout-pr"`: use `branchName`/`baseBranch` for a new branch, `branch` for an existing branch, or `prNumber` plus optional `forge`/`projectPath` for a change request. `worktreeSlug` controls the managed path. Returns the workspace descriptor centered on `workspaceId`.
|
||||
|
||||
**`list_workspaces`** — list active workspaces.
|
||||
|
||||
**`archive_workspace`** — `{ workspaceId }`. Archives the workspace, its agents, and its terminals. Local directories remain; Paseo removes an owned worktree only after its final active workspace reference is archived.
|
||||
|
||||
Worktree creation and reference accounting are implementation details of `isolation: "worktree"`.
|
||||
|
||||
## Agents
|
||||
|
||||
**`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Optional: `workspaceId`, `notifyOnFinish`, `settings`, `labels`. Returns `{ agentId, workspaceId, … }`.
|
||||
|
||||
Initial runtime settings live under `settings`: `modeId`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }` when creating the agent.
|
||||
|
||||
Agent-scoped creation always creates your subagent. Omit `workspaceId` to use your current workspace; pass a workspace returned by `create_workspace` for isolated delegation. Placement never changes parentage.
|
||||
|
||||
Detach is an explicit user action in the subagents track, not an agent tool. A cross-workspace child remains your subagent even though it also appears as a normal tab in its workspace.
|
||||
|
||||
Agent-scoped `create_agent` defaults `notifyOnFinish` to true. Set it to `false` only for truly fire-and-forget agents.
|
||||
|
||||
**`send_agent_prompt`** — `{ agentId, prompt }`. Use for follow-ups to an existing agent. Agent-scoped prompt calls default to `background: true` and `notifyOnFinish: true`; top-level calls default to blocking with no callback. For a synchronous follow-up, pass `background: false` and use the returned result.
|
||||
|
||||
**`update_agent`** — `{ agentId, name?, labels?, settings? }`. Use `settings` for runtime changes on an existing agent: `modeId`, `model`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }`.
|
||||
|
||||
**`list_agents`** — filter by `cwd`, `statuses`, `sinceHours`, `includeArchived`.
|
||||
|
||||
**`archive_agent`** — `{ agentId }`. Interrupts if running, removes from active list.
|
||||
|
||||
## Provider discovery
|
||||
|
||||
**`list_providers`** — compact provider availability and modes.
|
||||
|
||||
**`list_models`** — full model list for one provider. Use only when you need model IDs or thinking options; the list can be large.
|
||||
|
||||
**`inspect_provider`** — compact provider capability and feature inspection. Required: `provider`; pass `cwd` when you are not in an agent-scoped session. Optional: `settings` with draft `model`, `modeId`, `thinkingOptionId`, and `features`.
|
||||
|
||||
Only set feature IDs returned by `inspect_provider`. For Codex fast mode, look for `fast_mode` and pass `settings: { features: { "fast_mode": true } }` to `create_agent` or `update_agent`.
|
||||
|
||||
## Schedules and heartbeats
|
||||
|
||||
**`create_schedule`** — starts a new agent on a cron cadence. Required: `prompt`, `cron`, `provider`. Optional: `timezone`, `name`, `cwd`, `maxRuns`, `expiresIn`. Use when recurring work should live in fresh agents.
|
||||
|
||||
**`create_heartbeat`** — sends you a prompt on a cron cadence. Required: `prompt`, `cron`. Optional: `timezone`, `name`, `maxRuns`, `expiresIn`. Use for reminders, PR/build babysitting, and status checks that should return to this conversation.
|
||||
|
||||
**`delete_heartbeat`** stops it. MCP intentionally exposes no heartbeat update tool; delete and recreate when its task or cadence changes.
|
||||
|
||||
Schedules have the full list/inspect/update/pause/resume/run-once/log/delete surface. Heartbeats deliberately do not.
|
||||
|
||||
## Models
|
||||
|
||||
`claude/sonnet` (default), `claude/opus` (harder reasoning), `codex/gpt-5.4` (frontier coding), `claude/haiku` (tests only).
|
||||
|
||||
## Orchestration preferences
|
||||
|
||||
User-specific configuration at `~/.paseo/orchestration-preferences.json`. **Before any Paseo skill chooses a provider or creates an agent, it must read this file.** Reading means an actual file read, not relying on these examples or defaults. Never hardcode a provider string in another skill — resolve through this file.
|
||||
|
||||
Two parts:
|
||||
|
||||
- `providers` — map of role categories to provider strings. Pass straight to `create_agent`'s `provider` field.
|
||||
- `preferences` — freeform string array. Read on startup; weave into agent prompts contextually.
|
||||
|
||||
Categories: `impl`, `ui`, `research`, `planning`, `audit`. Skills pick the category that matches the role they're launching.
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"impl": "codex/gpt-5.4",
|
||||
"ui": "claude/opus",
|
||||
"research": "codex/gpt-5.4",
|
||||
"planning": "codex/gpt-5.4",
|
||||
"audit": "codex/gpt-5.4"
|
||||
},
|
||||
"preferences": [
|
||||
"Claude Opus is the right choice for anything artistic or human-skill-oriented: copywriting, naming, UX copy, visual design, styling. Codex is the workhorse for mechanical work."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If the file is missing, use sensible defaults and tell the user once.
|
||||
|
||||
## Waiting
|
||||
|
||||
Agents take time — 10–30+ minutes is routine. Favor asynchronous workflows.
|
||||
|
||||
For agent-scoped `create_agent` and background `send_agent_prompt`, leave `notifyOnFinish` omitted or set it to `true` unless the work is truly fire-and-forget. You will get notified when the target agent finishes, errors, or needs permission. Move on to other work. The notification arrives on its own.
|
||||
|
||||
Don't poll `list_agents` or `get_agent_status` to "check on" a running agent. The notification will tell you.
|
||||
|
||||
## CLI semantics
|
||||
|
||||
The CLI and tools use the same ownership semantics even where their syntax differs:
|
||||
|
||||
```bash
|
||||
paseo workspace create --isolation worktree --mode branch-off --new-branch fix-x --base main
|
||||
paseo workspace create --isolation worktree --mode checkout-branch --branch existing-work
|
||||
paseo workspace create --isolation worktree --mode checkout-pr --pr-number 42
|
||||
paseo run --provider codex/gpt-5.4 --mode full-access --workspace <workspace-id> "<prompt>"
|
||||
paseo send <agent-id> "<follow-up>"
|
||||
paseo ls
|
||||
paseo schedule create --cron "*/15 * * * *" "ping main build"
|
||||
paseo heartbeat create --cron "*/15 * * * *" "check the build"
|
||||
```
|
||||
|
||||
Discover with `paseo --help` and `paseo <cmd> --help`.
|
||||
|
||||
**If `paseo` isn't on PATH but the desktop app is installed**, the bundled CLI is at:
|
||||
|
||||
- macOS: `/Applications/Paseo.app/Contents/Resources/bin/paseo`
|
||||
- Linux: `<install-dir>/resources/bin/paseo`
|
||||
- Windows: `C:\Program Files\Paseo\resources\bin\paseo.cmd`
|
||||
|
||||
The desktop app's first-run hook (`installCli`) symlinks this to `~/.local/bin/paseo` (macOS/Linux) or drops a `.cmd` trampoline (Windows) and adds `~/.local/bin` to PATH via shell rc files. If that didn't take, offer to symlink it — don't do it silently.
|
||||
|
||||
## Ops and debugging
|
||||
|
||||
Daemon-client architecture: the daemon owns agent lifecycle, state, and the WebSocket API. Tools, CLI, mobile, and desktop apps are all clients.
|
||||
|
||||
| | Default |
|
||||
| --- | --- |
|
||||
| Listen address | `127.0.0.1:6767` (override `PASEO_LISTEN`) |
|
||||
| Home | `~/.paseo` (override `PASEO_HOME`) |
|
||||
| Daemon log | `$PASEO_HOME/daemon.log` |
|
||||
| Agent state | `$PASEO_HOME/agents/<id>.json` |
|
||||
| Worktrees | `$PASEO_HOME/worktrees/` (or `worktrees.root` in `config.json`) |
|
||||
| PID file | `$PASEO_HOME/paseo.pid` |
|
||||
| Health | `GET http://127.0.0.1:6767/api/health` |
|
||||
|
||||
Debug order:
|
||||
|
||||
1. `tail -n 200 ~/.paseo/daemon.log`.
|
||||
2. `paseo daemon status` for liveness.
|
||||
3. `curl -s localhost:6767/api/health` if the CLI itself is suspect.
|
||||
|
||||
**Never restart the daemon without explicit user approval** — it kills every running agent, including, often, the one asking.
|
||||
@@ -1,119 +0,0 @@
|
||||
import {
|
||||
createBranch,
|
||||
createIssue,
|
||||
createPullRequest,
|
||||
fetchTransport,
|
||||
listIssues,
|
||||
} from "@code/primitives/git-remote-runtime";
|
||||
import type {
|
||||
GitRemoteConfig,
|
||||
GitRemoteError,
|
||||
} from "@code/primitives/git-remote-runtime";
|
||||
import { defineTool } from "@flue/runtime";
|
||||
import { Effect } from "effect";
|
||||
import * as v from "valibot";
|
||||
|
||||
/**
|
||||
* Canonical repo for the bootstrap: one self-hosted Gitea repo hosts all work.
|
||||
* Hard-coded owner/repo keeps the agent off credential-shaped free input.
|
||||
*/
|
||||
export const CANONICAL_REPO = { owner: "puter", repo: "zopu-code" } as const;
|
||||
|
||||
export const makeGitRemoteConfig = (runtimeEnv: {
|
||||
readonly GITEA_TOKEN?: string;
|
||||
readonly GITEA_URL: string;
|
||||
}): GitRemoteConfig => {
|
||||
if (!runtimeEnv.GITEA_TOKEN) {
|
||||
throw new Error(
|
||||
"GITEA_TOKEN is required to configure the git remote tools"
|
||||
);
|
||||
}
|
||||
return {
|
||||
baseUrl: runtimeEnv.GITEA_URL,
|
||||
owner: CANONICAL_REPO.owner,
|
||||
repo: CANONICAL_REPO.repo,
|
||||
token: runtimeEnv.GITEA_TOKEN,
|
||||
};
|
||||
};
|
||||
|
||||
const run = <A, E>(eff: Effect.Effect<A, E>): Promise<A> =>
|
||||
Effect.runPromise(eff);
|
||||
|
||||
const describeError = (error: GitRemoteError) => ({
|
||||
error: error.message,
|
||||
reason: error.reason,
|
||||
});
|
||||
|
||||
// Tool returns are serialized to the model; round-trip through JSON so the
|
||||
// Effect-branded/readonly value objects become plain mutable JsonValue.
|
||||
// oxlint-disable-next-line unicorn/prefer-structured-clone -- structuredClone keeps readonly modifiers; JSON erases them for JsonValue
|
||||
const json = (value: unknown) => JSON.parse(JSON.stringify(value));
|
||||
|
||||
/**
|
||||
* Git-remote tools bound to the canonical repo. The agent creates issues,
|
||||
* branches, and pull requests on puter/zopu-code via the Gitea API using the
|
||||
* server-side token — it never accepts credentials or repo paths as input.
|
||||
*/
|
||||
export const createGitRemoteTools = (config: GitRemoteConfig) => [
|
||||
defineTool({
|
||||
description:
|
||||
"Create an issue on the canonical zopu-code repository. Use this to turn an agreed-upon piece of work into a tracked issue. Returns the issue number and URL. Always call list_issues first to avoid duplicates.",
|
||||
input: v.object({
|
||||
body: v.string(),
|
||||
title: v.string(),
|
||||
}),
|
||||
name: "create_issue",
|
||||
async run({ input }) {
|
||||
const result = await run(
|
||||
createIssue(fetchTransport, config, input)
|
||||
).catch(describeError);
|
||||
return json(result);
|
||||
},
|
||||
}),
|
||||
|
||||
defineTool({
|
||||
description:
|
||||
"List open issues on the canonical zopu-code repository. Call this before creating an issue to check for duplicates and understand existing work.",
|
||||
name: "list_issues",
|
||||
async run() {
|
||||
const result = await run(listIssues(fetchTransport, config)).catch(
|
||||
describeError
|
||||
);
|
||||
return json(result);
|
||||
},
|
||||
}),
|
||||
|
||||
defineTool({
|
||||
description:
|
||||
"Create a remote branch on the canonical zopu-code repository. Usually you do not need this directly — start_workflow handles branching for a work run. Use only when explicitly setting up a branch by hand.",
|
||||
input: v.object({
|
||||
from: v.optional(v.string()),
|
||||
name: v.string(),
|
||||
}),
|
||||
name: "create_branch",
|
||||
async run({ input }) {
|
||||
const result = await run(
|
||||
createBranch(fetchTransport, config, input)
|
||||
).catch(describeError);
|
||||
return json(result);
|
||||
},
|
||||
}),
|
||||
|
||||
defineTool({
|
||||
description:
|
||||
"Open a pull request on the canonical zopu-code repository. Provide the work branch, the base branch (usually main), a title, and a body. Returns the PR number and URL.",
|
||||
input: v.object({
|
||||
baseBranch: v.string(),
|
||||
body: v.string(),
|
||||
branch: v.string(),
|
||||
title: v.string(),
|
||||
}),
|
||||
name: "create_pull_request",
|
||||
async run({ input }) {
|
||||
const result = await run(
|
||||
createPullRequest(fetchTransport, config, input)
|
||||
).catch(describeError);
|
||||
return json(result);
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -1,43 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import path from "node:path";
|
||||
|
||||
import { defineTool } from "@flue/runtime";
|
||||
import * as v from "valibot";
|
||||
|
||||
const repositoryRoot = path.resolve(process.cwd(), "../..");
|
||||
|
||||
export const paseoCli = defineTool({
|
||||
description:
|
||||
"Run the locally installed Paseo CLI. Pass every argument exactly as it should appear after the paseo executable.",
|
||||
input: v.object({
|
||||
args: v.array(v.string()),
|
||||
}),
|
||||
name: "paseo_cli",
|
||||
output: v.object({
|
||||
exitCode: v.number(),
|
||||
stderr: v.string(),
|
||||
stdout: v.string(),
|
||||
}),
|
||||
async run({ input, signal }) {
|
||||
const process = spawn("paseo", input.args, {
|
||||
cwd: repositoryRoot,
|
||||
signal,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const stderrChunks: Buffer[] = [];
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
|
||||
process.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
|
||||
process.stdout.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
|
||||
|
||||
const [code] = await once(process, "close");
|
||||
const exitCode = typeof code === "number" ? code : 1;
|
||||
|
||||
return {
|
||||
exitCode,
|
||||
stderr: Buffer.concat(stderrChunks).toString(),
|
||||
stdout: Buffer.concat(stdoutChunks).toString(),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
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 };
|
||||
},
|
||||
}),
|
||||
];
|
||||
};
|
||||
@@ -1,368 +0,0 @@
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { ProjectIssueDispatchInput } from "@code/primitives/project-issue";
|
||||
import { defineTool, dispatch } from "@flue/runtime";
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import { Schema } from "effect";
|
||||
import * as v from "valibot";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent-gated Convex function references for the routing loop.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const listEvidenceRef = makeFunctionReference<
|
||||
"query",
|
||||
{ readonly organizationId: string; readonly token: string },
|
||||
{
|
||||
messageId: string;
|
||||
rawText: string;
|
||||
createdAt: number;
|
||||
submissionId: string | null;
|
||||
}[]
|
||||
>("signalRouting:listEvidence");
|
||||
|
||||
const createSignalRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{
|
||||
readonly organizationId: string;
|
||||
readonly projectId?: string;
|
||||
messageIds: string[];
|
||||
readonly problemStatement: {
|
||||
readonly title: string;
|
||||
readonly summary: string;
|
||||
readonly desiredOutcome: string;
|
||||
constraints: string[];
|
||||
};
|
||||
readonly processedByAgentInstanceId: string;
|
||||
readonly token: string;
|
||||
},
|
||||
{ signalId: string }
|
||||
>("signalRouting:createSignal");
|
||||
|
||||
const listSignalsRef = makeFunctionReference<
|
||||
"query",
|
||||
{
|
||||
readonly organizationId: string;
|
||||
readonly projectId?: string;
|
||||
readonly token: string;
|
||||
},
|
||||
{
|
||||
_id: string;
|
||||
createdAt: number;
|
||||
problemStatement: {
|
||||
title: string;
|
||||
summary: string;
|
||||
desiredOutcome: string;
|
||||
constraints: string[];
|
||||
};
|
||||
projectId: string | null;
|
||||
}[]
|
||||
>("signalRouting:listSignals");
|
||||
|
||||
const listActiveIssuesRef = makeFunctionReference<
|
||||
"query",
|
||||
{
|
||||
readonly organizationId: string;
|
||||
readonly projectId: string;
|
||||
readonly token: string;
|
||||
},
|
||||
{
|
||||
_id: string;
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
status: string;
|
||||
projectId: string;
|
||||
updatedAt: number;
|
||||
}[]
|
||||
>("signalRouting:listActiveIssues");
|
||||
|
||||
const attachSignalToIssueRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{
|
||||
readonly organizationId: string;
|
||||
readonly signalId: string;
|
||||
readonly issueId: string;
|
||||
readonly token: string;
|
||||
},
|
||||
{ attachmentId: string; alreadyAttached: boolean }
|
||||
>("signalRouting:attachSignalToIssue");
|
||||
|
||||
const createIssueFromSignalRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{
|
||||
readonly organizationId: string;
|
||||
readonly signalId: string;
|
||||
readonly token: string;
|
||||
},
|
||||
{ issueId: string; projectId: string }
|
||||
>("signalRouting:createIssueFromSignal");
|
||||
|
||||
const beginIssueRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{
|
||||
readonly organizationId: string;
|
||||
readonly issueId: string;
|
||||
readonly token: string;
|
||||
},
|
||||
{
|
||||
readonly dispatchRequired: boolean;
|
||||
readonly projectId: string;
|
||||
readonly status: "queued" | "working";
|
||||
}
|
||||
>("signalRouting:beginIssue");
|
||||
|
||||
const markDispatchFailedRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{
|
||||
readonly error: string;
|
||||
readonly issueId: string;
|
||||
readonly organizationId: string;
|
||||
readonly token: string;
|
||||
},
|
||||
null
|
||||
>("signalRouting:markDispatchFailed");
|
||||
|
||||
const getProjectContextRef = makeFunctionReference<
|
||||
"query",
|
||||
{
|
||||
readonly organizationId: string;
|
||||
readonly projectId: string;
|
||||
readonly token: string;
|
||||
},
|
||||
{
|
||||
project: {
|
||||
_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
organizationId: string;
|
||||
};
|
||||
contextDocuments: {
|
||||
kind: string;
|
||||
path: string;
|
||||
content: string;
|
||||
revision: number;
|
||||
}[];
|
||||
}
|
||||
>("signalRouting:getProjectContext");
|
||||
|
||||
const listProjectsRef = makeFunctionReference<
|
||||
"query",
|
||||
{
|
||||
readonly organizationId: string;
|
||||
readonly token: string;
|
||||
},
|
||||
{
|
||||
_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
organizationId: string;
|
||||
}[]
|
||||
>("signalRouting:listProjects");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool factory: creates all routing tools bound to one agent instance.
|
||||
//
|
||||
// The `instanceId` is the organization ID (established by the authenticated
|
||||
// route middleware). The tool never accepts organization ID as free input;
|
||||
// it always comes from the bound instance. This is the hard tenancy boundary.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const createSignalRoutingTools = (
|
||||
instanceId: string,
|
||||
runtimeEnv: Record<string, string | undefined>
|
||||
) => {
|
||||
const env = parseAgentEnv(runtimeEnv);
|
||||
const client = new ConvexHttpClient(env.CONVEX_URL);
|
||||
const token = env.FLUE_DB_TOKEN;
|
||||
// The instance id is the organization id; tools are scoped to it.
|
||||
const organizationId = instanceId;
|
||||
|
||||
return [
|
||||
defineTool({
|
||||
description:
|
||||
"List projects in the current organization. Call this first to identify the project context the user is working in.",
|
||||
name: "list_projects",
|
||||
async run() {
|
||||
return await client.query(listProjectsRef, {
|
||||
organizationId,
|
||||
token,
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
defineTool({
|
||||
description:
|
||||
"Read the project name, description, and canonical context documents (README, product, design, tech, agents). Use this to understand the project before routing work.",
|
||||
input: v.object({
|
||||
projectId: v.string(),
|
||||
}),
|
||||
name: "get_project_context",
|
||||
async run({ input }) {
|
||||
return await client.query(getProjectContextRef, {
|
||||
organizationId,
|
||||
projectId: input.projectId,
|
||||
token,
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
defineTool({
|
||||
description:
|
||||
"List admitted user messages in the current conversation that have not yet been consumed by a Signal. These are the candidate evidence messages. Raw text is exact — never modify it.",
|
||||
name: "list_signal_evidence",
|
||||
async run() {
|
||||
return await client.query(listEvidenceRef, {
|
||||
organizationId,
|
||||
token,
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
defineTool({
|
||||
description:
|
||||
"Create a Signal from one or more admitted user messages plus a structured problem statement. Use ONLY when the conversation contains an actionable problem, request, blocker, or decision. Do NOT create Signals for casual chat, greetings, or exploration without a concrete problem. The problemStatement must faithfully represent the user's own words — do not invent or rewrite their intent.",
|
||||
input: v.object({
|
||||
messageIds: v.array(v.string()),
|
||||
problemStatement: v.object({
|
||||
constraints: v.array(v.string()),
|
||||
desiredOutcome: v.string(),
|
||||
summary: v.string(),
|
||||
title: v.string(),
|
||||
}),
|
||||
projectId: v.optional(v.string()),
|
||||
}),
|
||||
name: "create_signal",
|
||||
async run({ input }) {
|
||||
return await client.mutation(createSignalRef, {
|
||||
messageIds: input.messageIds,
|
||||
organizationId,
|
||||
problemStatement: input.problemStatement,
|
||||
...(input.projectId === undefined
|
||||
? {}
|
||||
: { projectId: input.projectId }),
|
||||
processedByAgentInstanceId: organizationId,
|
||||
token,
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
defineTool({
|
||||
description:
|
||||
"List recent Signals for a project (or organization-wide if no projectId). Use this to see what has already been captured.",
|
||||
input: v.object({
|
||||
projectId: v.optional(v.string()),
|
||||
}),
|
||||
name: "list_recent_signals",
|
||||
async run({ input }) {
|
||||
return await client.query(listSignalsRef, {
|
||||
organizationId,
|
||||
...(input.projectId === undefined
|
||||
? {}
|
||||
: { projectId: input.projectId }),
|
||||
token,
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
defineTool({
|
||||
description:
|
||||
"List active (open, queued, working, needs-input) ProjectIssues for a project. Use this to find existing issues that a new Signal might relate to before deciding whether to attach or create a new one.",
|
||||
input: v.object({
|
||||
projectId: v.string(),
|
||||
}),
|
||||
name: "list_active_issues",
|
||||
async run({ input }) {
|
||||
return await client.query(listActiveIssuesRef, {
|
||||
organizationId,
|
||||
projectId: input.projectId,
|
||||
token,
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
defineTool({
|
||||
description:
|
||||
"Attach a Signal to an existing ProjectIssue. This links the signal's evidence to an open issue. Idempotent: repeating the same attachment is safe and returns the existing relation. Use when the signal's problem clearly relates to an existing active issue.",
|
||||
input: v.object({
|
||||
issueId: v.string(),
|
||||
signalId: v.string(),
|
||||
}),
|
||||
name: "attach_signal_to_issue",
|
||||
async run({ input }) {
|
||||
return await client.mutation(attachSignalToIssueRef, {
|
||||
issueId: input.issueId,
|
||||
organizationId,
|
||||
signalId: input.signalId,
|
||||
token,
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
defineTool({
|
||||
description:
|
||||
"Create a new ProjectIssue from a Signal. The signal must be project-scoped. This also auto-attaches the signal to the new issue. Use when the signal's problem does not match any existing active issue.",
|
||||
input: v.object({
|
||||
signalId: v.string(),
|
||||
}),
|
||||
name: "create_issue_from_signal",
|
||||
async run({ input }) {
|
||||
return await client.mutation(createIssueFromSignalRef, {
|
||||
organizationId,
|
||||
signalId: input.signalId,
|
||||
token,
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
defineTool({
|
||||
description:
|
||||
"Begin working on a ProjectIssue by transitioning it to queued. Use only after the user explicitly confirms they want to start the issue. Returns the new status.",
|
||||
input: v.object({
|
||||
issueId: v.string(),
|
||||
}),
|
||||
name: "begin_issue",
|
||||
async run({ input }) {
|
||||
const outcome = await client.mutation(beginIssueRef, {
|
||||
issueId: input.issueId,
|
||||
organizationId,
|
||||
token,
|
||||
});
|
||||
const dispatchInput = Schema.decodeUnknownSync(
|
||||
ProjectIssueDispatchInput
|
||||
)({
|
||||
issueId: input.issueId,
|
||||
kind: "project.issue.started",
|
||||
projectId: outcome.projectId,
|
||||
});
|
||||
if (!outcome.dispatchRequired) {
|
||||
return {
|
||||
acceptedAt: "",
|
||||
dispatchId: "",
|
||||
status: outcome.status,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const receipt = await dispatch({
|
||||
agent: "project-manager",
|
||||
id: input.issueId,
|
||||
input: dispatchInput,
|
||||
});
|
||||
return {
|
||||
acceptedAt: receipt.acceptedAt,
|
||||
dispatchId: receipt.dispatchId,
|
||||
status: outcome.status,
|
||||
};
|
||||
} catch (error) {
|
||||
await client.mutation(markDispatchFailedRef, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
issueId: input.issueId,
|
||||
organizationId,
|
||||
token,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import { defineTool } from "@flue/runtime";
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import * as v from "valibot";
|
||||
|
||||
// The control-plane mutation that enqueues a work run for an issue. Implemented
|
||||
// in the backend Convex workflow module; referenced by name so the agent never
|
||||
// imports generated bindings.
|
||||
const startIssueWorkRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ readonly issueNumber: number; readonly token: string },
|
||||
{ readonly runId: string; readonly status: string }
|
||||
>("workflows:startIssueWork");
|
||||
|
||||
export const createWorkflowTools = (clientOptions: {
|
||||
readonly convexUrl: string;
|
||||
readonly token: string;
|
||||
}) => {
|
||||
const client = new ConvexHttpClient(clientOptions.convexUrl);
|
||||
|
||||
return [
|
||||
defineTool({
|
||||
description:
|
||||
"Start the autonomous work workflow for an existing zopu-code issue. This spawns a Codex agent in an isolated worktree of the canonical repo, gives it the issue, lets it implement and verify the change, then commits and opens a pull request. Use it after create_issue (or for an existing issue number) once the user confirms they want work to begin. Returns the run id and initial status.",
|
||||
input: v.object({
|
||||
issueNumber: v.number(),
|
||||
}),
|
||||
name: "start_workflow",
|
||||
async run({ input }) {
|
||||
return await client.mutation(startIssueWorkRef, {
|
||||
issueNumber: input.issueNumber,
|
||||
token: clientOptions.token,
|
||||
});
|
||||
},
|
||||
}),
|
||||
];
|
||||
};
|
||||
@@ -22,7 +22,7 @@
|
||||
"expo-constants": "~57.0.2",
|
||||
"expo-secure-store": "~57.0.0",
|
||||
"heroui-native": "catalog:",
|
||||
"react": "^19.2.3",
|
||||
"react": "catalog:",
|
||||
"react-native": "0.86.0",
|
||||
"sonner": "catalog:",
|
||||
"zod": "catalog:"
|
||||
@@ -30,8 +30,8 @@
|
||||
"devDependencies": {
|
||||
"@code/config": "workspace:*",
|
||||
"@types/react": "~19.2.17",
|
||||
"vite": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<!-- convex-ai-start -->
|
||||
|
||||
This project uses [Convex](https://convex.dev) as its backend.
|
||||
|
||||
When working on Convex code, **always read
|
||||
`convex/_generated/ai/guidelines.md` first** for important guidelines on
|
||||
how to correctly use Convex APIs and patterns. The file contains rules that
|
||||
override what you may have learned about Convex from training data.
|
||||
|
||||
Convex agent skills for common tasks can be installed by running
|
||||
`npx convex ai-files install`.
|
||||
|
||||
<!-- convex-ai-end -->
|
||||
@@ -1,16 +1,16 @@
|
||||
# Convex Backend
|
||||
|
||||
This directory contains the Convex control plane for the app and daemon runtime. Generated files under `_generated/` are maintained by Convex and should not be edited by hand.
|
||||
Convex is the only application API used by web, desktop, and mobile clients.
|
||||
The active Slice 1 backend is intentionally limited to tenancy, projects,
|
||||
conversation turns, Signals, Work, and Flue's required persistence adapter.
|
||||
|
||||
## Daemon Control Plane
|
||||
## Domain relations
|
||||
|
||||
- `schema.ts` defines daemon definitions, high-churn daemon presence, leased daemon commands, append-only command events, and the sample `todos` table.
|
||||
- `daemons.ts` manages stable daemon definitions with `upsert`, `get`, and `list`.
|
||||
- `daemonRuntime.ts` records daemon session lifecycle: `connect`, `heartbeat`, `disconnect`, `recordEvent`, and bounded event listing.
|
||||
- `daemonCommands.ts` owns the command queue: `enqueue`, `available`, `claim`, `start`, `succeed`, `fail`, `cancel`, and bounded command listing.
|
||||
- `organizations` and `organizationMembers` own tenancy.
|
||||
- `projects` owns the single v0 Git source; `projectContextDocuments` stores repository context.
|
||||
- `conversations`, `conversationTurns`, `conversationMessages`, and `conversationAttachments` own chat admission and reactive history.
|
||||
- `signals`, `signalConstraints`, and `signalSources` preserve structured intent and exact evidence.
|
||||
- `works`, `signalWorkAttachments`, and `workEvents` own proposed Work and provenance.
|
||||
|
||||
Commands are claimed by daemon session. Preserve the `claimedBySessionId` ownership checks, `leaseExpiresAt` expiry semantics, terminal statuses, and bounded query results when changing this flow.
|
||||
|
||||
## Usage
|
||||
|
||||
Convex functions are exposed by file path through the generated `api` object, for example `api.daemonCommands.enqueue` or `api.daemonRuntime.connect`. Run `bun run dev:server` from the repository root to start the development deployment, and `bun run dev:setup` when configuring Convex for the first time.
|
||||
The `flue*` tables are infrastructure tables required by Flue's persistence
|
||||
contract. They are deliberately separate from the product relations.
|
||||
|
||||
22
packages/backend/convex/_generated/api.d.ts
vendored
22
packages/backend/convex/_generated/api.d.ts
vendored
@@ -8,28 +8,17 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as agentWorkspace from "../agentWorkspace.js";
|
||||
import type * as artifactModel from "../artifactModel.js";
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as authz from "../authz.js";
|
||||
import type * as conversationMessages from "../conversationMessages.js";
|
||||
import type * as daemonCommands from "../daemonCommands.js";
|
||||
import type * as daemonRuntime from "../daemonRuntime.js";
|
||||
import type * as daemons from "../daemons.js";
|
||||
import type * as fluePersistence from "../fluePersistence.js";
|
||||
import type * as healthCheck from "../healthCheck.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as organizations from "../organizations.js";
|
||||
import type * as privateData from "../privateData.js";
|
||||
import type * as projectArtifacts from "../projectArtifacts.js";
|
||||
import type * as projectIssues from "../projectIssues.js";
|
||||
import type * as projectStore from "../projectStore.js";
|
||||
import type * as projects from "../projects.js";
|
||||
import type * as publicGit from "../publicGit.js";
|
||||
import type * as signalRouting from "../signalRouting.js";
|
||||
import type * as signals from "../signals.js";
|
||||
import type * as todos from "../todos.js";
|
||||
import type * as workflows from "../workflows.js";
|
||||
import type * as works from "../works.js";
|
||||
|
||||
import type {
|
||||
@@ -39,28 +28,17 @@ import type {
|
||||
} from "convex/server";
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
agentWorkspace: typeof agentWorkspace;
|
||||
artifactModel: typeof artifactModel;
|
||||
auth: typeof auth;
|
||||
authz: typeof authz;
|
||||
conversationMessages: typeof conversationMessages;
|
||||
daemonCommands: typeof daemonCommands;
|
||||
daemonRuntime: typeof daemonRuntime;
|
||||
daemons: typeof daemons;
|
||||
fluePersistence: typeof fluePersistence;
|
||||
healthCheck: typeof healthCheck;
|
||||
http: typeof http;
|
||||
organizations: typeof organizations;
|
||||
privateData: typeof privateData;
|
||||
projectArtifacts: typeof projectArtifacts;
|
||||
projectIssues: typeof projectIssues;
|
||||
projectStore: typeof projectStore;
|
||||
projects: typeof projects;
|
||||
publicGit: typeof publicGit;
|
||||
signalRouting: typeof signalRouting;
|
||||
signals: typeof signals;
|
||||
todos: typeof todos;
|
||||
workflows: typeof workflows;
|
||||
works: typeof works;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { DataModel } from "./dataModel.js";
|
||||
*/
|
||||
type Env = {
|
||||
readonly FLUE_DB_TOKEN: string;
|
||||
readonly FLUE_URL: string | undefined;
|
||||
readonly GITEA_TOKEN: string | undefined;
|
||||
readonly GITEA_URL: string | undefined;
|
||||
readonly NATIVE_APP_URL: string | undefined;
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
import { env } from "@code/env/convex";
|
||||
import {
|
||||
makeIssueWorkspacePlan,
|
||||
transitionWorkspaceRun,
|
||||
WorkspaceStateError,
|
||||
WorkspaceValidationError,
|
||||
} from "@code/primitives/project-workspace";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { artifactPath } from "./artifactModel";
|
||||
|
||||
const requireAgent = (token: string) => {
|
||||
if (token !== env.FLUE_DB_TOKEN) {
|
||||
throw new ConvexError("Invalid agent control token");
|
||||
}
|
||||
};
|
||||
|
||||
const agentStatus = v.union(
|
||||
v.literal("working"),
|
||||
v.literal("needs-input"),
|
||||
v.literal("completed"),
|
||||
v.literal("failed")
|
||||
);
|
||||
|
||||
const workspacePlanFor = (input: {
|
||||
readonly issueBody: string;
|
||||
readonly issueId: string;
|
||||
readonly issueNumber: number;
|
||||
readonly issueTitle: string;
|
||||
readonly sourceUrl: string | undefined;
|
||||
readonly defaultBranch: string | undefined;
|
||||
}) => {
|
||||
try {
|
||||
return Effect.runSync(
|
||||
makeIssueWorkspacePlan({
|
||||
artifacts: [],
|
||||
branchName: undefined,
|
||||
checkoutPath: undefined,
|
||||
contextFiles: [],
|
||||
defaultBranch: input.defaultBranch,
|
||||
issueBody: input.issueBody,
|
||||
issueId: input.issueId,
|
||||
issueNumber: input.issueNumber,
|
||||
issueTitle: input.issueTitle,
|
||||
sourceUrl: input.sourceUrl,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceValidationError) {
|
||||
throw new ConvexError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const get = query({
|
||||
args: { issueId: v.id("projectIssues"), token: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
const project = await ctx.db.get("projects", issue.projectId);
|
||||
if (!project) {
|
||||
throw new ConvexError("Project not found");
|
||||
}
|
||||
const artifacts = await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", project._id))
|
||||
.take(25);
|
||||
const [source] = await ctx.db
|
||||
.query("projectSources")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", project._id))
|
||||
.take(1);
|
||||
const contextDocuments = await ctx.db
|
||||
.query("projectContextDocuments")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", project._id))
|
||||
.take(25);
|
||||
const run = await ctx.db
|
||||
.query("projectWorkRuns")
|
||||
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
|
||||
.unique();
|
||||
return {
|
||||
artifacts,
|
||||
contextDocuments,
|
||||
issue,
|
||||
project,
|
||||
run: run ?? null,
|
||||
source: source ?? null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const ensureRun = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
issueId: v.id("projectIssues"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
const [source] = await ctx.db
|
||||
.query("projectSources")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", issue.projectId))
|
||||
.take(1);
|
||||
const plan = workspacePlanFor({
|
||||
defaultBranch: source?.defaultBranch,
|
||||
issueBody: issue.body,
|
||||
issueId: String(issue._id),
|
||||
issueNumber: issue.number,
|
||||
issueTitle: issue.title,
|
||||
sourceUrl: source?.url,
|
||||
});
|
||||
const existing = await ctx.db
|
||||
.query("projectWorkRuns")
|
||||
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
|
||||
.unique();
|
||||
if (existing) {
|
||||
if (
|
||||
existing.baseBranch !== plan.baseBranch ||
|
||||
existing.branchName !== plan.branchName ||
|
||||
existing.checkoutPath !== plan.checkoutPath ||
|
||||
existing.sourceUrl !== plan.sourceUrl
|
||||
) {
|
||||
await ctx.db.patch("projectWorkRuns", existing._id, {
|
||||
baseBranch: plan.baseBranch,
|
||||
branchName: plan.branchName,
|
||||
checkoutPath: plan.checkoutPath,
|
||||
sourceUrl: plan.sourceUrl,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
return await ctx.db.get("projectWorkRuns", existing._id);
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
const actorKey = [
|
||||
"project",
|
||||
String(issue.projectId),
|
||||
"issue",
|
||||
String(issue._id),
|
||||
];
|
||||
const runId = await ctx.db.insert("projectWorkRuns", {
|
||||
actorKey,
|
||||
agentId: String(issue._id),
|
||||
baseBranch: plan.baseBranch,
|
||||
branchName: plan.branchName,
|
||||
checkoutPath: plan.checkoutPath,
|
||||
createdAt: timestamp,
|
||||
daemonId: args.daemonId,
|
||||
issueId: issue._id,
|
||||
projectId: issue.projectId,
|
||||
sourceUrl: plan.sourceUrl,
|
||||
status: "queued",
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: {
|
||||
actorKey,
|
||||
branchName: plan.branchName,
|
||||
checkoutPath: plan.checkoutPath,
|
||||
daemonId: args.daemonId,
|
||||
sourceUrl: plan.sourceUrl,
|
||||
},
|
||||
issueId: issue._id,
|
||||
kind: "agent.workspace.created",
|
||||
projectId: issue.projectId,
|
||||
runId,
|
||||
});
|
||||
return await ctx.db.get("projectWorkRuns", runId);
|
||||
},
|
||||
});
|
||||
|
||||
export const updateArtifact = mutation({
|
||||
args: {
|
||||
content: v.string(),
|
||||
issueId: v.id("projectIssues"),
|
||||
path: artifactPath,
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
if (args.content.length > 200_000) {
|
||||
throw new ConvexError("Artifact content exceeds 200000 characters");
|
||||
}
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
const artifact = await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project_and_path", (q) =>
|
||||
q.eq("projectId", issue.projectId).eq("path", args.path)
|
||||
)
|
||||
.unique();
|
||||
if (!artifact) {
|
||||
throw new ConvexError(`Artifact ${args.path} not found`);
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("projectArtifacts", artifact._id, {
|
||||
content: args.content,
|
||||
revision: artifact.revision + 1,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { path: args.path, revision: artifact.revision + 1 },
|
||||
issueId: issue._id,
|
||||
kind: "artifact.updated",
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
return artifact.revision + 1;
|
||||
},
|
||||
});
|
||||
|
||||
export const setStatus = mutation({
|
||||
args: {
|
||||
issueId: v.id("projectIssues"),
|
||||
status: agentStatus,
|
||||
summary: v.optional(v.string()),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
const run = await ctx.db
|
||||
.query("projectWorkRuns")
|
||||
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
|
||||
.unique();
|
||||
if (!run) {
|
||||
throw new ConvexError("Agent work run not found");
|
||||
}
|
||||
try {
|
||||
Effect.runSync(
|
||||
transitionWorkspaceRun({
|
||||
from: run.status,
|
||||
to: args.status,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceStateError) {
|
||||
throw new ConvexError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
const terminal = args.status === "completed" || args.status === "failed";
|
||||
await ctx.db.patch("projectIssues", issue._id, {
|
||||
status: args.status,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.patch("projectWorkRuns", run._id, {
|
||||
status: args.status,
|
||||
summary: args.summary?.slice(0, 4000),
|
||||
updatedAt: timestamp,
|
||||
...(args.status === "working" && run.startedAt === undefined
|
||||
? { startedAt: timestamp }
|
||||
: {}),
|
||||
...(terminal ? { completedAt: timestamp } : {}),
|
||||
});
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { summary: args.summary?.slice(0, 1000) },
|
||||
issueId: issue._id,
|
||||
kind: `agent.${args.status}`,
|
||||
projectId: issue.projectId,
|
||||
runId: run._id,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const lifecycleStatus = v.union(
|
||||
v.literal("no_changes"),
|
||||
v.literal("committed"),
|
||||
v.literal("pushed"),
|
||||
v.literal("pull_request_open"),
|
||||
v.literal("failed")
|
||||
);
|
||||
|
||||
const pullRequestMetadata = v.object({
|
||||
baseBranch: v.string(),
|
||||
branch: v.string(),
|
||||
number: v.number(),
|
||||
status: v.union(v.literal("open"), v.literal("closed"), v.literal("merged")),
|
||||
url: v.string(),
|
||||
});
|
||||
|
||||
export const recordGiteaLifecycle = mutation({
|
||||
args: {
|
||||
baseBranch: v.string(),
|
||||
branch: v.string(),
|
||||
commitSha: v.optional(v.string()),
|
||||
error: v.optional(v.string()),
|
||||
issueId: v.id("projectIssues"),
|
||||
pullRequest: v.optional(pullRequestMetadata),
|
||||
status: lifecycleStatus,
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
const run = await ctx.db
|
||||
.query("projectWorkRuns")
|
||||
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
|
||||
.unique();
|
||||
if (!run) {
|
||||
throw new ConvexError("Agent work run not found");
|
||||
}
|
||||
|
||||
const events = await ctx.db
|
||||
.query("projectEvents")
|
||||
.withIndex("by_issue_and_createdAt", (q) => q.eq("issueId", issue._id))
|
||||
.order("desc")
|
||||
.take(100);
|
||||
const existing = events.find((event) => {
|
||||
if (!event.kind.startsWith("gitea.")) {
|
||||
return false;
|
||||
}
|
||||
const data = event.data as {
|
||||
branch?: unknown;
|
||||
commitSha?: unknown;
|
||||
status?: unknown;
|
||||
};
|
||||
return (
|
||||
data.branch === args.branch &&
|
||||
data.commitSha === args.commitSha &&
|
||||
data.status === args.status
|
||||
);
|
||||
});
|
||||
if (existing) {
|
||||
return existing.data;
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const data = {
|
||||
baseBranch: args.baseBranch,
|
||||
branch: args.branch,
|
||||
...(args.commitSha === undefined ? {} : { commitSha: args.commitSha }),
|
||||
...(args.error === undefined ? {} : { error: args.error.slice(0, 2000) }),
|
||||
...(args.pullRequest === undefined
|
||||
? {}
|
||||
: { pullRequest: args.pullRequest }),
|
||||
status: args.status,
|
||||
};
|
||||
const artifact = await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project_and_path", (q) =>
|
||||
q.eq("projectId", issue.projectId).eq("path", "artifacts.md")
|
||||
)
|
||||
.unique();
|
||||
if (artifact) {
|
||||
const pullRequestLine = args.pullRequest
|
||||
? `- Pull request: [#${args.pullRequest.number}](${args.pullRequest.url}) (${args.pullRequest.status})\n`
|
||||
: "";
|
||||
const commitLine = args.commitSha ? `- Commit: ${args.commitSha}\n` : "";
|
||||
await ctx.db.patch("projectArtifacts", artifact._id, {
|
||||
content: `${artifact.content}\n## Git lifecycle\n\n- Status: ${args.status}\n- Branch: ${args.branch}\n- Base branch: ${args.baseBranch}\n${commitLine}${pullRequestLine}${args.error ? `- Error: ${args.error.slice(0, 1000)}\n` : ""}`,
|
||||
revision: artifact.revision + 1,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data,
|
||||
issueId: issue._id,
|
||||
kind:
|
||||
args.pullRequest === undefined
|
||||
? "gitea.lifecycle.updated"
|
||||
: "gitea.pull_request.created",
|
||||
projectId: issue.projectId,
|
||||
runId: run._id,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
export const ARTIFACT_PATHS = [
|
||||
"agent.md",
|
||||
"work.md",
|
||||
"steps.md",
|
||||
"artifacts.md",
|
||||
"signals.md",
|
||||
"agent-manager.md",
|
||||
"context.md",
|
||||
"card.md",
|
||||
] as const;
|
||||
|
||||
export type ArtifactPath = (typeof ARTIFACT_PATHS)[number];
|
||||
|
||||
export const artifactPath = v.union(
|
||||
v.literal("agent.md"),
|
||||
v.literal("work.md"),
|
||||
v.literal("steps.md"),
|
||||
v.literal("artifacts.md"),
|
||||
v.literal("signals.md"),
|
||||
v.literal("agent-manager.md"),
|
||||
v.literal("context.md"),
|
||||
v.literal("card.md")
|
||||
);
|
||||
|
||||
export const createInitialArtifacts = (): readonly {
|
||||
path: ArtifactPath;
|
||||
content: string;
|
||||
}[] => [
|
||||
{
|
||||
content:
|
||||
"# Agent\n\n## Role\n\nOwn one project issue at a time. Read the project artifacts before editing code, ask a focused question when blocked, and support completion claims with command output.\n\n## Guardrails\n\n- Keep issue work isolated in its AgentOS workspace.\n- Update work.md, steps.md, artifacts.md, and context.md as facts change.\n- Never mark work complete without a relevant runtime check.\n",
|
||||
path: "agent.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Work\n\nNo issue has entered the agent queue yet. New issues are appended here by the control plane.\n",
|
||||
path: "work.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Steps\n\n1. Read the issue and project artifacts.\n2. Inspect the repository context.\n3. Ask for missing decisions only when work cannot continue safely.\n4. Implement the smallest complete change.\n5. Run the relevant command or scenario.\n6. Publish changed artifacts and the final signal.\n",
|
||||
path: "steps.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Artifacts\n\nThis file indexes concrete outputs from issue runs, including changed paths, command results, and preview links.\n",
|
||||
path: "artifacts.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Project events\n\nThe agent manager emits `issue.queued`, `agent.started`, `agent.needs-input`, `agent.completed`, and `agent.failed`. These are durable project events and drive the web status.\n",
|
||||
path: "signals.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Agent manager\n\n## State machine\n\n`open -> queued -> working -> completed`\n\nA blocked run moves from `working` to `needs-input`; a resumed run returns to `queued`. An unrecoverable run moves to `failed`.\n\nEach issue owns one Flue agent identity and one AgentOS actor key, so conversation and workspace state remain isolated.\n",
|
||||
path: "agent-manager.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Context\n\nCanonical project knowledge is staged under /workspace/context. Issue-specific facts belong below.\n",
|
||||
path: "context.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Project card\n\nThe web project workspace reads and writes Projects, context documents, artifacts, and issues through the generated Convex interface. Agent work uses the issue-scoped workspace and durable daemon commands.\n",
|
||||
path: "card.md",
|
||||
},
|
||||
];
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* Targeted runtime smoke for the authenticated conversation ingress.
|
||||
*
|
||||
* Simulates the exact sequence the Flue route middleware drives, end to end,
|
||||
* against the real Convex schema and modules via convexTest:
|
||||
*
|
||||
* 1. ensurePersonalOrganization (bearer auth → org resolution)
|
||||
* 2. beginUserMessage (evidence before Flue admission)
|
||||
* 3. markAdmitted (Flue receipt submission id)
|
||||
*
|
||||
* Then verifies the stored normalized evidence has exact raw text, organization
|
||||
* scope, conversation/message identity, request id, role user, and the admitted
|
||||
* submission id.
|
||||
*
|
||||
* This is a smoke, not a unit test: delete after the live browser smoke is
|
||||
* confirmed.
|
||||
*/
|
||||
import { convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import schema from "./schema";
|
||||
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
|
||||
const ID_A = "https://convex.test|smoke-user";
|
||||
const identityA = { tokenIdentifier: ID_A };
|
||||
|
||||
const newTest = () => convexTest({ schema, modules });
|
||||
|
||||
describe("conversation ingress smoke", () => {
|
||||
test("one user message produces normalized admitted evidence end to end", async () => {
|
||||
const t = newTest();
|
||||
|
||||
// 1. Auth + org resolution (the middleware resolves this from the bearer
|
||||
// token via a request-scoped ConvexHttpClient).
|
||||
const org = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
|
||||
const rawText = " Fix the login bug \n\t please ";
|
||||
const clientRequestId = "smoke-req-001";
|
||||
|
||||
// 2. Begin evidence before Flue admission.
|
||||
const begun = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId,
|
||||
organizationId: org._id,
|
||||
rawText,
|
||||
});
|
||||
expect(begun.status).toBe("admitting");
|
||||
expect(begun.rawText).toBe(rawText);
|
||||
|
||||
// 3. Mark admitted with the Flue receipt submission id.
|
||||
const submissionId = "flue-sub-abc123";
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId,
|
||||
organizationId: org._id,
|
||||
submissionId,
|
||||
});
|
||||
|
||||
// 4. Verify normalized evidence via authorized observation.
|
||||
const observed = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.conversationMessages.getByRequest, {
|
||||
clientRequestId,
|
||||
organizationId: org._id,
|
||||
});
|
||||
|
||||
expect(observed).not.toBeNull();
|
||||
expect(observed?.rawText).toBe(rawText);
|
||||
expect(observed?.organizationId).toBe(org._id);
|
||||
expect(observed?.conversationId).toBe(org._id);
|
||||
expect(typeof observed?.messageId).toBe("string");
|
||||
expect(observed?.clientRequestId).toBe(clientRequestId);
|
||||
expect(observed?.role).toBe("user");
|
||||
expect(observed?.status).toBe("admitted");
|
||||
expect(observed?.submissionId).toBe(submissionId);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { requireOrganizationMember } from "./authz";
|
||||
import schema from "./schema";
|
||||
|
||||
declare global {
|
||||
@@ -13,288 +12,101 @@ declare global {
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
|
||||
const ID_A = "https://convex.test|user-a";
|
||||
const ID_B = "https://convex.test|user-b";
|
||||
|
||||
const identityA = { tokenIdentifier: ID_A };
|
||||
const identityB = { tokenIdentifier: ID_B };
|
||||
|
||||
const newTest = () => convexTest({ schema, modules });
|
||||
const identityA = { tokenIdentifier: "https://convex.test|user-a" };
|
||||
const identityB = { tokenIdentifier: "https://convex.test|user-b" };
|
||||
const newTest = () => convexTest({ modules, schema });
|
||||
|
||||
const ensureOrg = async (
|
||||
t: ReturnType<typeof newTest>,
|
||||
identity: { readonly tokenIdentifier: string }
|
||||
) => {
|
||||
const org = await t
|
||||
) =>
|
||||
await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
return org._id as string;
|
||||
};
|
||||
|
||||
describe("conversationMessages", () => {
|
||||
test("unauthenticated access is rejected", async () => {
|
||||
test("queues one durable user turn and one assistant placeholder", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const organization = await ensureOrg(t, identityA);
|
||||
|
||||
await expect(
|
||||
t.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-1",
|
||||
organizationId: orgId,
|
||||
rawText: "hello",
|
||||
})
|
||||
).rejects.toThrow(/Authentication required/u);
|
||||
await t.withIdentity(identityA).mutation(api.conversationMessages.send, {
|
||||
clientRequestId: "request-1",
|
||||
images: [],
|
||||
organizationId: organization._id,
|
||||
rawText: " Keep this exact text. ",
|
||||
});
|
||||
|
||||
await expect(
|
||||
t.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "req-1",
|
||||
organizationId: orgId,
|
||||
submissionId: "sub-1",
|
||||
})
|
||||
).rejects.toThrow(/Authentication required/u);
|
||||
|
||||
await expect(
|
||||
t.query(api.conversationMessages.getByRequest, {
|
||||
clientRequestId: "req-1",
|
||||
organizationId: orgId,
|
||||
})
|
||||
).rejects.toThrow(/Authentication required/u);
|
||||
const rows = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.conversationMessages.listForCurrentOrganization, {
|
||||
organizationId: organization._id,
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toMatchObject({
|
||||
rawText: " Keep this exact text. ",
|
||||
role: "user",
|
||||
status: "queued",
|
||||
});
|
||||
expect(rows[1]).toMatchObject({
|
||||
rawText: "",
|
||||
role: "assistant",
|
||||
status: "queued",
|
||||
});
|
||||
});
|
||||
|
||||
test("cross-organization access is denied for begin and observation", async () => {
|
||||
test("is idempotent by client request id", async () => {
|
||||
const t = newTest();
|
||||
const orgA = await ensureOrg(t, identityA);
|
||||
const _orgB = await ensureOrg(t, identityB);
|
||||
const organization = await ensureOrg(t, identityA);
|
||||
const input = {
|
||||
clientRequestId: "request-duplicate",
|
||||
images: [],
|
||||
organizationId: organization._id,
|
||||
rawText: "First payload wins",
|
||||
};
|
||||
|
||||
// User A captures a message in its own organization.
|
||||
const created = await t
|
||||
const first = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-cross",
|
||||
organizationId: orgA,
|
||||
rawText: "private to org A",
|
||||
.mutation(api.conversationMessages.send, input);
|
||||
const second = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.send, {
|
||||
...input,
|
||||
rawText: "Ignored duplicate payload",
|
||||
});
|
||||
|
||||
// User B cannot begin a message under organization A.
|
||||
await expect(
|
||||
t
|
||||
.withIdentity(identityB)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-cross",
|
||||
organizationId: orgA,
|
||||
rawText: "intruder",
|
||||
})
|
||||
).rejects.toThrow(/Organization membership required/u);
|
||||
expect(second).toEqual(first);
|
||||
const rows = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.conversationMessages.listForCurrentOrganization, {
|
||||
organizationId: organization._id,
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]?.rawText).toBe("First payload wins");
|
||||
});
|
||||
|
||||
// User B cannot observe organization A's message by request id.
|
||||
await expect(
|
||||
t.withIdentity(identityB).query(api.conversationMessages.getByRequest, {
|
||||
clientRequestId: "req-cross",
|
||||
organizationId: orgA,
|
||||
})
|
||||
).rejects.toThrow(/Organization membership required/u);
|
||||
test("rejects unauthenticated and cross-organization access", async () => {
|
||||
const t = newTest();
|
||||
const organization = await ensureOrg(t, identityA);
|
||||
await ensureOrg(t, identityB);
|
||||
const input = {
|
||||
clientRequestId: "request-private",
|
||||
images: [],
|
||||
organizationId: organization._id,
|
||||
rawText: "Private",
|
||||
};
|
||||
|
||||
// User B cannot list organization A's conversation.
|
||||
await expect(
|
||||
t.mutation(api.conversationMessages.send, input)
|
||||
).rejects.toThrow(/Authentication required/u);
|
||||
await expect(
|
||||
t.withIdentity(identityB).mutation(api.conversationMessages.send, input)
|
||||
).rejects.toThrow(/Organization membership required/u);
|
||||
await expect(
|
||||
t
|
||||
.withIdentity(identityB)
|
||||
.query(api.conversationMessages.listForCurrentOrganization, {
|
||||
organizationId: orgA,
|
||||
organizationId: organization._id,
|
||||
})
|
||||
).rejects.toThrow(/Organization membership required/u);
|
||||
|
||||
// The membership boundary independently denies cross-org access.
|
||||
await expect(
|
||||
t
|
||||
.withIdentity(identityB)
|
||||
.query((ctx) => requireOrganizationMember(ctx, orgA as never))
|
||||
).rejects.toThrow(/Organization membership required/u);
|
||||
|
||||
expect(created.status).toBe("admitting");
|
||||
});
|
||||
|
||||
test("duplicate request id is idempotent and preserves exact whitespace and casing", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const rawText = " Hello WORLD \n\t with spaces ";
|
||||
|
||||
const first = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-dup",
|
||||
organizationId: orgId,
|
||||
rawText,
|
||||
});
|
||||
|
||||
// A second begin with the same request id returns the same row — no
|
||||
// duplicate is created.
|
||||
const second = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-dup",
|
||||
organizationId: orgId,
|
||||
rawText: "DIFFERENT TEXT MUST BE IGNORED",
|
||||
});
|
||||
|
||||
expect(second._id).toBe(first._id);
|
||||
expect(second.messageId).toBe(first.messageId);
|
||||
expect(second.rawText).toBe(rawText);
|
||||
expect(second.clientRequestId).toBe("req-dup");
|
||||
expect(second.role).toBe("user");
|
||||
expect(second.status).toBe("admitting");
|
||||
|
||||
// The conversation id equals the organization id (global agent scope).
|
||||
expect(first.conversationId).toBe(orgId);
|
||||
});
|
||||
|
||||
test("admitted transition records the submission id", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
|
||||
const begun = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-admit",
|
||||
organizationId: orgId,
|
||||
rawText: "please help",
|
||||
});
|
||||
expect(begun.status).toBe("admitting");
|
||||
expect(begun.submissionId).toBeNull();
|
||||
|
||||
const admitted = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "req-admit",
|
||||
organizationId: orgId,
|
||||
submissionId: "sub-receipt-123",
|
||||
});
|
||||
|
||||
expect(admitted.status).toBe("admitted");
|
||||
expect(admitted.submissionId).toBe("sub-receipt-123");
|
||||
|
||||
// Marking admitted again is idempotent and keeps the same submission id.
|
||||
const again = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "req-admit",
|
||||
organizationId: orgId,
|
||||
submissionId: "sub-receipt-123",
|
||||
});
|
||||
expect(again.status).toBe("admitted");
|
||||
expect(again.submissionId).toBe("sub-receipt-123");
|
||||
});
|
||||
|
||||
test("failed transition records the failed status and preserves evidence", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-fail",
|
||||
organizationId: orgId,
|
||||
rawText: "doomed message",
|
||||
});
|
||||
|
||||
const failed = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markFailed, {
|
||||
clientRequestId: "req-fail",
|
||||
organizationId: orgId,
|
||||
});
|
||||
|
||||
expect(failed.status).toBe("failed");
|
||||
expect(failed.submissionId).toBeNull();
|
||||
expect(failed.rawText).toBe("doomed message");
|
||||
|
||||
// A failed message is still observable as evidence.
|
||||
const observed = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.conversationMessages.getByRequest, {
|
||||
clientRequestId: "req-fail",
|
||||
organizationId: orgId,
|
||||
});
|
||||
expect(observed?.status).toBe("failed");
|
||||
expect(observed?.rawText).toBe("doomed message");
|
||||
});
|
||||
|
||||
test("admission wins over a concurrent failure and cannot be overwritten", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
|
||||
const begun = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-race",
|
||||
organizationId: orgId,
|
||||
rawText: "race",
|
||||
});
|
||||
|
||||
// Admission completes first.
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "req-race",
|
||||
organizationId: orgId,
|
||||
submissionId: "sub-won",
|
||||
});
|
||||
|
||||
// A late failure is a no-op: an admitted message keeps its submission id.
|
||||
const afterFail = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markFailed, {
|
||||
clientRequestId: "req-race",
|
||||
organizationId: orgId,
|
||||
});
|
||||
expect(afterFail.status).toBe("admitted");
|
||||
expect(afterFail.submissionId).toBe("sub-won");
|
||||
|
||||
expect(begun.status).toBe("admitting");
|
||||
});
|
||||
|
||||
test("list returns only the authenticated organization's messages", async () => {
|
||||
const t = newTest();
|
||||
const orgA = await ensureOrg(t, identityA);
|
||||
const orgB = await ensureOrg(t, identityB);
|
||||
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-a1",
|
||||
organizationId: orgA,
|
||||
rawText: "a-one",
|
||||
});
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-a2",
|
||||
organizationId: orgA,
|
||||
rawText: "a-two",
|
||||
});
|
||||
await t
|
||||
.withIdentity(identityB)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-b1",
|
||||
organizationId: orgB,
|
||||
rawText: "b-one",
|
||||
});
|
||||
|
||||
const forA = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.conversationMessages.listForCurrentOrganization, {
|
||||
organizationId: orgA,
|
||||
});
|
||||
const forB = await t
|
||||
.withIdentity(identityB)
|
||||
.query(api.conversationMessages.listForCurrentOrganization, {
|
||||
organizationId: orgB,
|
||||
});
|
||||
|
||||
expect(forA).toHaveLength(2);
|
||||
expect(
|
||||
forA.map((m: { clientRequestId: string }) => m.clientRequestId)
|
||||
).toEqual(expect.arrayContaining(["req-a1", "req-a2"]));
|
||||
expect(forB).toHaveLength(1);
|
||||
expect(forB[0]?.clientRequestId).toBe("req-b1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,220 +1,381 @@
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import {
|
||||
env,
|
||||
internalAction,
|
||||
internalMutation,
|
||||
internalQuery,
|
||||
mutation,
|
||||
query,
|
||||
} from "./_generated/server";
|
||||
import { requireOrganizationMember } from "./authz";
|
||||
|
||||
export type ConversationMessageStatus = "admitting" | "admitted" | "failed";
|
||||
const MAX_ATTEMPTS = 3;
|
||||
|
||||
interface ConversationMessageView {
|
||||
readonly _id: Id<"conversationMessages">;
|
||||
readonly _creationTime: number;
|
||||
readonly organizationId: Id<"organizations">;
|
||||
readonly conversationId: string;
|
||||
readonly messageId: string;
|
||||
readonly submissionId: string | null;
|
||||
readonly clientRequestId: string;
|
||||
readonly role: "user";
|
||||
readonly rawText: string;
|
||||
readonly status: ConversationMessageStatus;
|
||||
readonly createdAt: number;
|
||||
}
|
||||
const runTurnRef = makeFunctionReference<
|
||||
"action",
|
||||
{ turnId: Id<"conversationTurns">; attempt: number }
|
||||
>("conversationMessages:runTurn");
|
||||
const getTurnRef = makeFunctionReference<
|
||||
"query",
|
||||
{ turnId: Id<"conversationTurns"> },
|
||||
{
|
||||
turn: Doc<"conversationTurns">;
|
||||
organizationId: Id<"organizations">;
|
||||
user: Doc<"conversationMessages">;
|
||||
assistant: Doc<"conversationMessages">;
|
||||
attachments: Doc<"conversationAttachments">[];
|
||||
} | null
|
||||
>("conversationMessages:getTurn");
|
||||
const markProcessingRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ turnId: Id<"conversationTurns"> },
|
||||
boolean
|
||||
>("conversationMessages:markProcessing");
|
||||
const completeTurnRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ turnId: Id<"conversationTurns">; submissionId: string; text: string },
|
||||
null
|
||||
>("conversationMessages:completeTurn");
|
||||
const failTurnRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ turnId: Id<"conversationTurns">; error: string; retry: boolean },
|
||||
null
|
||||
>("conversationMessages:failTurn");
|
||||
|
||||
const toView = (doc: Doc<"conversationMessages">): ConversationMessageView => ({
|
||||
_creationTime: doc._creationTime,
|
||||
_id: doc._id,
|
||||
clientRequestId: doc.clientRequestId,
|
||||
conversationId: doc.conversationId,
|
||||
createdAt: doc.createdAt,
|
||||
messageId: doc.messageId,
|
||||
organizationId: doc.organizationId,
|
||||
rawText: doc.rawText,
|
||||
role: doc.role,
|
||||
status: doc.status,
|
||||
submissionId: doc.submissionId ?? null,
|
||||
export const generateUploadUrl = mutation({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<string> => {
|
||||
if (!(await ctx.auth.getUserIdentity())) {
|
||||
throw new ConvexError("Authentication required");
|
||||
}
|
||||
return await ctx.storage.generateUploadUrl();
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The global Zopu conversation uses the organization ID as its conversation
|
||||
* identity (and agent instance ID). This resolves the conversation ID for the
|
||||
* authenticated user's current personal organization, so callers never supply
|
||||
* tenancy themselves.
|
||||
*/
|
||||
const resolveConversationId = (organizationId: Id<"organizations">): string =>
|
||||
organizationId;
|
||||
|
||||
/**
|
||||
* Idempotently begin a user message at the conversation ingress. If a row with
|
||||
* the same (organizationId, clientRequestId) already exists, return it instead
|
||||
* of creating a duplicate — Flue retries that replay the same request id must
|
||||
* not produce duplicate evidence. The raw text is stored verbatim; it is never
|
||||
* trimmed, collapsed, or rewritten.
|
||||
*
|
||||
* Authorization: the authenticated identity must be a member of the target
|
||||
* organization. The conversation ID, message ID, and timestamp are all
|
||||
* generated server-side; none are trusted from the caller.
|
||||
*/
|
||||
export const beginUserMessage = mutation({
|
||||
export const send = mutation({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
clientRequestId: v.string(),
|
||||
rawText: v.string(),
|
||||
images: v.array(
|
||||
v.object({
|
||||
storageId: v.id("_storage"),
|
||||
filename: v.optional(v.string()),
|
||||
mimeType: v.string(),
|
||||
})
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ConversationMessageView> => {
|
||||
handler: async (ctx, args) => {
|
||||
await requireOrganizationMember(ctx, args.organizationId);
|
||||
let conversation = await ctx.db
|
||||
.query("conversations")
|
||||
.withIndex("by_organizationId", (q) =>
|
||||
q.eq("organizationId", args.organizationId)
|
||||
)
|
||||
.unique();
|
||||
if (!conversation) {
|
||||
const conversationId = await ctx.db.insert("conversations", {
|
||||
createdAt: Date.now(),
|
||||
organizationId: args.organizationId,
|
||||
});
|
||||
conversation = await ctx.db.get(conversationId);
|
||||
}
|
||||
if (!conversation) {
|
||||
throw new Error("Conversation could not be created");
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_organization_and_request", (q) =>
|
||||
.query("conversationTurns")
|
||||
.withIndex("by_conversationId_and_clientRequestId", (q) =>
|
||||
q
|
||||
.eq("organizationId", args.organizationId)
|
||||
.eq("conversationId", conversation._id)
|
||||
.eq("clientRequestId", args.clientRequestId)
|
||||
)
|
||||
.unique();
|
||||
if (existing) {
|
||||
return toView(existing);
|
||||
const user = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_turnId_and_role", (q) =>
|
||||
q.eq("turnId", existing._id).eq("role", "user")
|
||||
)
|
||||
.unique();
|
||||
return { messageId: user?._id ?? null, turnId: existing._id };
|
||||
}
|
||||
|
||||
const createdAt = Date.now();
|
||||
const messageId = crypto.randomUUID();
|
||||
const conversationId = resolveConversationId(args.organizationId);
|
||||
const rowId = await ctx.db.insert("conversationMessages", {
|
||||
clientRequestId: args.clientRequestId,
|
||||
conversationId,
|
||||
createdAt,
|
||||
messageId,
|
||||
organizationId: args.organizationId,
|
||||
rawText: args.rawText,
|
||||
role: "user",
|
||||
status: "admitting",
|
||||
});
|
||||
const created = await ctx.db.get(rowId);
|
||||
if (!created) {
|
||||
throw new Error("Failed to read created conversation message");
|
||||
}
|
||||
return toView(created);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Mark a begun message as admitted after Flue accepts the submission. The
|
||||
* submission id links the normalized evidence to the Flue transcript. Only an
|
||||
* `admitting` message may transition to `admitted`; an already-admitted or
|
||||
* failed message is returned unchanged so a retry is idempotent.
|
||||
*/
|
||||
export const markAdmitted = mutation({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
clientRequestId: v.string(),
|
||||
submissionId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ConversationMessageView> => {
|
||||
await requireOrganizationMember(ctx, args.organizationId);
|
||||
const existing = await ctx.db
|
||||
const lastMessage = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_organization_and_request", (q) =>
|
||||
q
|
||||
.eq("organizationId", args.organizationId)
|
||||
.eq("clientRequestId", args.clientRequestId)
|
||||
)
|
||||
.unique();
|
||||
if (!existing) {
|
||||
throw new ConvexError("Conversation message not found");
|
||||
}
|
||||
if (existing.status === "admitting") {
|
||||
await ctx.db.patch(existing._id, {
|
||||
status: "admitted",
|
||||
submissionId: args.submissionId,
|
||||
});
|
||||
}
|
||||
const updated = await ctx.db.get(existing._id);
|
||||
if (!updated) {
|
||||
throw new Error("Failed to read updated conversation message");
|
||||
}
|
||||
return toView(updated);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Mark a begun message as failed when Flue admission rejects it. Failed
|
||||
* messages remain as evidence with an explicit status; they are never silently
|
||||
* deleted. Only an `admitting` message may transition to `failed`; an
|
||||
* already-admitted message is never overwritten (admission won), and an
|
||||
* already-failed message is returned unchanged.
|
||||
*/
|
||||
export const markFailed = mutation({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
clientRequestId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ConversationMessageView> => {
|
||||
await requireOrganizationMember(ctx, args.organizationId);
|
||||
const existing = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_organization_and_request", (q) =>
|
||||
q
|
||||
.eq("organizationId", args.organizationId)
|
||||
.eq("clientRequestId", args.clientRequestId)
|
||||
)
|
||||
.unique();
|
||||
if (!existing) {
|
||||
throw new ConvexError("Conversation message not found");
|
||||
}
|
||||
if (existing.status === "admitting") {
|
||||
await ctx.db.patch(existing._id, { status: "failed" });
|
||||
}
|
||||
const updated = await ctx.db.get(existing._id);
|
||||
if (!updated) {
|
||||
throw new Error("Failed to read updated conversation message");
|
||||
}
|
||||
return toView(updated);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Read a single message by its client request id. Authorization requires
|
||||
* membership in the message's organization; cross-organization observation is
|
||||
* denied. Returns null when no message matches so observers cannot probe for
|
||||
* another organization's request ids.
|
||||
*/
|
||||
export const getByRequest = query({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
clientRequestId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ConversationMessageView | null> => {
|
||||
await requireOrganizationMember(ctx, args.organizationId);
|
||||
const existing = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_organization_and_request", (q) =>
|
||||
q
|
||||
.eq("organizationId", args.organizationId)
|
||||
.eq("clientRequestId", args.clientRequestId)
|
||||
)
|
||||
.unique();
|
||||
return existing ? toView(existing) : null;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* List the user messages for the authenticated user's current organization
|
||||
* conversation, newest first. This is the authorized observation path used by
|
||||
* GET/SSE routes and product clients. Cross-organization access is denied.
|
||||
*/
|
||||
export const listForCurrentOrganization = query({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ConversationMessageView[]> => {
|
||||
await requireOrganizationMember(ctx, args.organizationId);
|
||||
const conversationId = resolveConversationId(args.organizationId);
|
||||
const rows = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_organization_and_message", (q) =>
|
||||
q
|
||||
.eq("organizationId", args.organizationId)
|
||||
.eq("conversationId", conversationId)
|
||||
.withIndex("by_conversationId_and_ordinal", (q) =>
|
||||
q.eq("conversationId", conversation._id)
|
||||
)
|
||||
.order("desc")
|
||||
.take(100);
|
||||
return rows.map(toView);
|
||||
.first();
|
||||
const ordinal = (lastMessage?.ordinal ?? -1) + 1;
|
||||
const turnId = await ctx.db.insert("conversationTurns", {
|
||||
clientRequestId: args.clientRequestId,
|
||||
conversationId: conversation._id,
|
||||
createdAt,
|
||||
status: "queued",
|
||||
});
|
||||
const messageId = await ctx.db.insert("conversationMessages", {
|
||||
content: args.rawText,
|
||||
conversationId: conversation._id,
|
||||
createdAt,
|
||||
ordinal,
|
||||
role: "user",
|
||||
turnId,
|
||||
});
|
||||
for (const image of args.images) {
|
||||
await ctx.db.insert("conversationAttachments", {
|
||||
...image,
|
||||
createdAt,
|
||||
messageId,
|
||||
});
|
||||
}
|
||||
await ctx.db.insert("conversationMessages", {
|
||||
content: "",
|
||||
conversationId: conversation._id,
|
||||
createdAt: createdAt + 1,
|
||||
ordinal: ordinal + 1,
|
||||
role: "assistant",
|
||||
turnId,
|
||||
});
|
||||
await ctx.scheduler.runAfter(0, runTurnRef, { attempt: 1, turnId });
|
||||
return { messageId, turnId };
|
||||
},
|
||||
});
|
||||
|
||||
export const listForCurrentOrganization = query({
|
||||
args: { organizationId: v.id("organizations") },
|
||||
handler: async (ctx, args) => {
|
||||
await requireOrganizationMember(ctx, args.organizationId);
|
||||
const conversation = await ctx.db
|
||||
.query("conversations")
|
||||
.withIndex("by_organizationId", (q) =>
|
||||
q.eq("organizationId", args.organizationId)
|
||||
)
|
||||
.unique();
|
||||
if (!conversation) {
|
||||
return [];
|
||||
}
|
||||
const messages = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_conversationId_and_ordinal", (q) =>
|
||||
q.eq("conversationId", conversation._id)
|
||||
)
|
||||
.order("asc")
|
||||
.take(200);
|
||||
return await Promise.all(
|
||||
messages.map(async (message) => {
|
||||
const turn = await ctx.db.get(message.turnId);
|
||||
const attachments = await ctx.db
|
||||
.query("conversationAttachments")
|
||||
.withIndex("by_messageId", (q) => q.eq("messageId", message._id))
|
||||
.collect();
|
||||
return {
|
||||
attachments: await Promise.all(
|
||||
attachments.map(async (attachment) => ({
|
||||
filename: attachment.filename ?? null,
|
||||
id: String(attachment._id),
|
||||
mediaType: attachment.mimeType,
|
||||
url: await ctx.storage.getUrl(attachment.storageId),
|
||||
}))
|
||||
),
|
||||
error: turn?.error ?? null,
|
||||
messageId: String(message._id),
|
||||
rawText: message.content,
|
||||
role: message.role,
|
||||
status: turn?.status ?? "failed",
|
||||
};
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const getTurn = internalQuery({
|
||||
args: { turnId: v.id("conversationTurns") },
|
||||
handler: async (ctx, args) => {
|
||||
const turn = await ctx.db.get(args.turnId);
|
||||
if (!turn) {
|
||||
return null;
|
||||
}
|
||||
const conversation = await ctx.db.get(turn.conversationId);
|
||||
const user = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_turnId_and_role", (q) =>
|
||||
q.eq("turnId", turn._id).eq("role", "user")
|
||||
)
|
||||
.unique();
|
||||
const assistant = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_turnId_and_role", (q) =>
|
||||
q.eq("turnId", turn._id).eq("role", "assistant")
|
||||
)
|
||||
.unique();
|
||||
if (!conversation || !user || !assistant) {
|
||||
return null;
|
||||
}
|
||||
const attachments = await ctx.db
|
||||
.query("conversationAttachments")
|
||||
.withIndex("by_messageId", (q) => q.eq("messageId", user._id))
|
||||
.collect();
|
||||
return {
|
||||
assistant,
|
||||
attachments,
|
||||
organizationId: conversation.organizationId,
|
||||
turn,
|
||||
user,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const markProcessing = internalMutation({
|
||||
args: { turnId: v.id("conversationTurns") },
|
||||
handler: async (ctx, args): Promise<boolean> => {
|
||||
const turn = await ctx.db.get(args.turnId);
|
||||
if (!turn || turn.status === "completed") {
|
||||
return false;
|
||||
}
|
||||
await ctx.db.patch(turn._id, { error: undefined, status: "processing" });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const completeTurn = internalMutation({
|
||||
args: {
|
||||
turnId: v.id("conversationTurns"),
|
||||
submissionId: v.string(),
|
||||
text: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<null> => {
|
||||
const assistant = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_turnId_and_role", (q) =>
|
||||
q.eq("turnId", args.turnId).eq("role", "assistant")
|
||||
)
|
||||
.unique();
|
||||
if (assistant) {
|
||||
await ctx.db.patch(assistant._id, { content: args.text });
|
||||
}
|
||||
await ctx.db.patch(args.turnId, {
|
||||
completedAt: Date.now(),
|
||||
error: undefined,
|
||||
status: "completed",
|
||||
submissionId: args.submissionId,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const failTurn = internalMutation({
|
||||
args: {
|
||||
turnId: v.id("conversationTurns"),
|
||||
error: v.string(),
|
||||
retry: v.boolean(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<null> => {
|
||||
const turn = await ctx.db.get(args.turnId);
|
||||
if (turn && turn.status !== "completed") {
|
||||
await ctx.db.patch(turn._id, {
|
||||
completedAt: args.retry ? undefined : Date.now(),
|
||||
error: args.error,
|
||||
status: args.retry ? "queued" : "failed",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
const toBase64 = (buffer: ArrayBuffer): string => {
|
||||
let binary = "";
|
||||
for (const byte of new Uint8Array(buffer)) {
|
||||
binary += String.fromCodePoint(byte);
|
||||
}
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
export const runTurn = internalAction({
|
||||
args: { turnId: v.id("conversationTurns"), attempt: v.number() },
|
||||
handler: async (ctx, args): Promise<null> => {
|
||||
const turn = await ctx.runQuery(getTurnRef, { turnId: args.turnId });
|
||||
if (
|
||||
!turn ||
|
||||
!(await ctx.runMutation(markProcessingRef, { turnId: args.turnId }))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const flueUrl = (env as typeof env & { readonly FLUE_URL?: string })
|
||||
.FLUE_URL;
|
||||
if (!flueUrl) {
|
||||
throw new Error("FLUE_URL is not configured in Convex");
|
||||
}
|
||||
const images = await Promise.all(
|
||||
turn.attachments.map(async (attachment) => {
|
||||
const url = await ctx.storage.getUrl(attachment.storageId);
|
||||
const response = url ? await fetch(url) : null;
|
||||
if (!response?.ok) {
|
||||
throw new Error("Conversation image could not be loaded");
|
||||
}
|
||||
return {
|
||||
data: toBase64(await response.arrayBuffer()),
|
||||
mimeType: attachment.mimeType,
|
||||
type: "image" as const,
|
||||
};
|
||||
})
|
||||
);
|
||||
const endpoint = new URL(
|
||||
`agents/zopu/${encodeURIComponent(String(turn.organizationId))}`,
|
||||
`${flueUrl.replace(/\/+$/u, "")}/`
|
||||
);
|
||||
endpoint.searchParams.set("wait", "result");
|
||||
const response = await fetch(endpoint, {
|
||||
body: JSON.stringify({ images, message: turn.user.content }),
|
||||
headers: {
|
||||
authorization: `Bearer ${env.FLUE_DB_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
"x-zopu-organization-id": String(turn.organizationId),
|
||||
"x-zopu-request-id": turn.turn.clientRequestId,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (
|
||||
!response.ok ||
|
||||
typeof payload !== "object" ||
|
||||
payload === null ||
|
||||
!("submissionId" in payload) ||
|
||||
typeof payload.submissionId !== "string" ||
|
||||
!("result" in payload) ||
|
||||
typeof payload.result !== "object" ||
|
||||
payload.result === null ||
|
||||
!("text" in payload.result) ||
|
||||
typeof payload.result.text !== "string"
|
||||
) {
|
||||
throw new Error(`Flue turn failed (${response.status})`);
|
||||
}
|
||||
await ctx.runMutation(completeTurnRef, {
|
||||
submissionId: payload.submissionId,
|
||||
text: payload.result.text,
|
||||
turnId: args.turnId,
|
||||
});
|
||||
} catch (error) {
|
||||
const retry = args.attempt < MAX_ATTEMPTS;
|
||||
await ctx.runMutation(failTurnRef, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
retry,
|
||||
turnId: args.turnId,
|
||||
});
|
||||
if (retry) {
|
||||
await ctx.scheduler.runAfter(args.attempt * 1000, runTurnRef, {
|
||||
attempt: args.attempt + 1,
|
||||
turnId: args.turnId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ const app = defineApp({
|
||||
NATIVE_APP_URL: v.optional(v.string()),
|
||||
SITE_URL: v.string(),
|
||||
FLUE_DB_TOKEN: v.string(),
|
||||
FLUE_URL: v.optional(v.string()),
|
||||
GITEA_URL: v.optional(v.string()),
|
||||
GITEA_TOKEN: v.optional(v.string()),
|
||||
},
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { mutation, query } from "./_generated/server";
|
||||
|
||||
const commandStatus = v.union(
|
||||
v.literal("queued"),
|
||||
v.literal("claimed"),
|
||||
v.literal("running"),
|
||||
v.literal("succeeded"),
|
||||
v.literal("failed"),
|
||||
v.literal("cancelled")
|
||||
);
|
||||
|
||||
export const enqueue = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
method: v.string(),
|
||||
args: v.any(),
|
||||
actorKey: v.array(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const timestamp = Date.now();
|
||||
return await ctx.db.insert("daemonCommands", {
|
||||
...args,
|
||||
status: "queued",
|
||||
attempts: 0,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
status: v.optional(commandStatus),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
if (args.status) {
|
||||
return await ctx.db
|
||||
.query("daemonCommands")
|
||||
.withIndex("by_daemonId_and_status", (q) =>
|
||||
q.eq("daemonId", args.daemonId).eq("status", args.status!)
|
||||
)
|
||||
.order("desc")
|
||||
.take(100);
|
||||
}
|
||||
return await ctx.db
|
||||
.query("daemonCommands")
|
||||
.filter((q) => q.eq(q.field("daemonId"), args.daemonId))
|
||||
.order("desc")
|
||||
.take(100);
|
||||
},
|
||||
});
|
||||
export const get = query({
|
||||
args: { commandId: v.id("daemonCommands") },
|
||||
handler: async (ctx, args) =>
|
||||
await ctx.db.get("daemonCommands", args.commandId),
|
||||
});
|
||||
|
||||
export const available = query({
|
||||
args: { daemonId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const queued = await ctx.db
|
||||
.query("daemonCommands")
|
||||
.withIndex("by_daemonId_and_status", (q) =>
|
||||
q.eq("daemonId", args.daemonId).eq("status", "queued")
|
||||
)
|
||||
.order("asc")
|
||||
.take(25);
|
||||
const expiredClaims = await ctx.db
|
||||
.query("daemonCommands")
|
||||
.withIndex("by_daemonId_and_status", (q) =>
|
||||
q.eq("daemonId", args.daemonId).eq("status", "claimed")
|
||||
)
|
||||
.filter((q) => q.lt(q.field("leaseExpiresAt"), Date.now()))
|
||||
.order("asc")
|
||||
.take(25);
|
||||
return [...expiredClaims, ...queued].slice(0, 25);
|
||||
},
|
||||
});
|
||||
|
||||
export const claim = mutation({
|
||||
args: {
|
||||
commandId: v.id("daemonCommands"),
|
||||
daemonId: v.string(),
|
||||
sessionId: v.string(),
|
||||
leaseDurationMs: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const command = await ctx.db.get("daemonCommands", args.commandId);
|
||||
if (!command || command.daemonId !== args.daemonId) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
const canClaim =
|
||||
command.status === "queued" ||
|
||||
(command.status === "claimed" &&
|
||||
command.leaseExpiresAt !== undefined &&
|
||||
command.leaseExpiresAt <= timestamp);
|
||||
if (!canClaim) {
|
||||
return null;
|
||||
}
|
||||
await ctx.db.patch("daemonCommands", command._id, {
|
||||
status: "claimed",
|
||||
claimedBySessionId: args.sessionId,
|
||||
leaseExpiresAt: timestamp + args.leaseDurationMs,
|
||||
attempts: command.attempts + 1,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
return { ...command, status: "claimed" as const };
|
||||
},
|
||||
});
|
||||
|
||||
export const start = mutation({
|
||||
args: {
|
||||
commandId: v.id("daemonCommands"),
|
||||
sessionId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const command = await ctx.db.get("daemonCommands", args.commandId);
|
||||
if (
|
||||
!command ||
|
||||
command.status !== "claimed" ||
|
||||
command.claimedBySessionId !== args.sessionId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("daemonCommands", command._id, {
|
||||
status: "running",
|
||||
startedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const succeed = mutation({
|
||||
args: {
|
||||
commandId: v.id("daemonCommands"),
|
||||
sessionId: v.string(),
|
||||
result: v.any(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const command = await ctx.db.get("daemonCommands", args.commandId);
|
||||
if (!command || command.claimedBySessionId !== args.sessionId) {
|
||||
return false;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("daemonCommands", command._id, {
|
||||
status: "succeeded",
|
||||
result: args.result,
|
||||
completedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
leaseExpiresAt: undefined,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const fail = mutation({
|
||||
args: {
|
||||
commandId: v.id("daemonCommands"),
|
||||
sessionId: v.string(),
|
||||
error: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const command = await ctx.db.get("daemonCommands", args.commandId);
|
||||
if (!command || command.claimedBySessionId !== args.sessionId) {
|
||||
return false;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("daemonCommands", command._id, {
|
||||
status: "failed",
|
||||
error: args.error,
|
||||
completedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
leaseExpiresAt: undefined,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const cancel = mutation({
|
||||
args: { commandId: v.id("daemonCommands") },
|
||||
handler: async (ctx, args) => {
|
||||
const command = await ctx.db.get("daemonCommands", args.commandId);
|
||||
if (
|
||||
!command ||
|
||||
["succeeded", "failed", "cancelled"].includes(command.status)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("daemonCommands", command._id, {
|
||||
status: "cancelled",
|
||||
completedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
leaseExpiresAt: undefined,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { mutation, query } from "./_generated/server";
|
||||
|
||||
export const connect = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
sessionId: v.string(),
|
||||
hostname: v.string(),
|
||||
platform: v.string(),
|
||||
architecture: v.string(),
|
||||
version: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("daemonPresence")
|
||||
.withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId))
|
||||
.unique();
|
||||
const timestamp = Date.now();
|
||||
if (existing) {
|
||||
await ctx.db.patch("daemonPresence", existing._id, {
|
||||
status: "online",
|
||||
lastHeartbeatAt: timestamp,
|
||||
});
|
||||
return existing._id;
|
||||
}
|
||||
return await ctx.db.insert("daemonPresence", {
|
||||
...args,
|
||||
status: "online",
|
||||
startedAt: timestamp,
|
||||
lastHeartbeatAt: timestamp,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const heartbeat = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
sessionId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const presence = await ctx.db
|
||||
.query("daemonPresence")
|
||||
.withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId))
|
||||
.unique();
|
||||
if (!presence || presence.daemonId !== args.daemonId) {
|
||||
return false;
|
||||
}
|
||||
await ctx.db.patch("daemonPresence", presence._id, {
|
||||
status: "online",
|
||||
lastHeartbeatAt: Date.now(),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const disconnect = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
sessionId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const presence = await ctx.db
|
||||
.query("daemonPresence")
|
||||
.withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId))
|
||||
.unique();
|
||||
if (!presence || presence.daemonId !== args.daemonId) {
|
||||
return false;
|
||||
}
|
||||
await ctx.db.patch("daemonPresence", presence._id, {
|
||||
status: "offline",
|
||||
lastHeartbeatAt: Date.now(),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export const recordEvent = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
commandId: v.optional(v.id("daemonCommands")),
|
||||
kind: v.string(),
|
||||
data: v.any(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db.insert("daemonCommandEvents", {
|
||||
...args,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const listEvents = query({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db
|
||||
.query("daemonCommandEvents")
|
||||
.withIndex("by_daemonId_and_createdAt", (q) =>
|
||||
q.eq("daemonId", args.daemonId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(Math.min(args.limit ?? 100, 500));
|
||||
},
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { mutation, query } from "./_generated/server";
|
||||
|
||||
const now = () => Date.now();
|
||||
|
||||
export const upsert = mutation({
|
||||
args: {
|
||||
daemonId: v.string(),
|
||||
name: v.string(),
|
||||
desiredState: v.union(v.literal("online"), v.literal("offline")),
|
||||
agentOsConfig: v.any(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("daemonDefinitions")
|
||||
.withIndex("by_daemonId", (q) => q.eq("daemonId", args.daemonId))
|
||||
.unique();
|
||||
const timestamp = now();
|
||||
if (existing) {
|
||||
await ctx.db.patch("daemonDefinitions", existing._id, {
|
||||
name: args.name,
|
||||
desiredState: args.desiredState,
|
||||
agentOsConfig: args.agentOsConfig,
|
||||
configVersion: existing.configVersion + 1,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
return existing._id;
|
||||
}
|
||||
return await ctx.db.insert("daemonDefinitions", {
|
||||
...args,
|
||||
configVersion: 1,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const get = query({
|
||||
args: { daemonId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const definition = await ctx.db
|
||||
.query("daemonDefinitions")
|
||||
.withIndex("by_daemonId", (q) => q.eq("daemonId", args.daemonId))
|
||||
.unique();
|
||||
const presence = await ctx.db
|
||||
.query("daemonPresence")
|
||||
.withIndex("by_daemonId", (q) => q.eq("daemonId", args.daemonId))
|
||||
.order("desc")
|
||||
.first();
|
||||
return { definition, presence };
|
||||
},
|
||||
});
|
||||
|
||||
export const list = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
return await ctx.db.query("daemonDefinitions").order("desc").collect();
|
||||
},
|
||||
});
|
||||
@@ -1,387 +0,0 @@
|
||||
import { type TestConvex, convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import schema from "./schema";
|
||||
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
|
||||
const ID_A = "https://convex.test|user-a";
|
||||
const ID_B = "https://convex.test|user-b";
|
||||
const identityA = { tokenIdentifier: ID_A };
|
||||
const identityB = { tokenIdentifier: ID_B };
|
||||
|
||||
const newTest = () => convexTest({ schema, modules });
|
||||
|
||||
const ensureOrg = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
identity: { readonly tokenIdentifier: string }
|
||||
) => {
|
||||
const org = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
return org._id as Id<"organizations">;
|
||||
};
|
||||
|
||||
const problemStatement = {
|
||||
title: "Deploy fails on staging",
|
||||
summary: "The staging deploy command exits with a DNS resolution error.",
|
||||
desiredOutcome: "Staging deploys successfully without DNS errors.",
|
||||
constraints: ["Must not change the production pipeline"],
|
||||
};
|
||||
|
||||
const processedBy = { agentInstanceId: "zopu-instance-1", agentName: "zopu" };
|
||||
|
||||
const seedMessage = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
identity: { readonly tokenIdentifier: string },
|
||||
orgId: Id<"organizations">,
|
||||
clientRequestId: string,
|
||||
rawText: string
|
||||
): Promise<string> => {
|
||||
const begun = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId,
|
||||
organizationId: orgId,
|
||||
rawText,
|
||||
});
|
||||
await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId,
|
||||
organizationId: orgId,
|
||||
submissionId: `sub-${clientRequestId}`,
|
||||
});
|
||||
return begun.messageId;
|
||||
};
|
||||
|
||||
const createProject = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
ownerId: string,
|
||||
suffix: string
|
||||
): Promise<Id<"projects">> => {
|
||||
const ownerIdentity = { tokenIdentifier: ownerId };
|
||||
await t
|
||||
.withIdentity(ownerIdentity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization);
|
||||
const outcome = await t
|
||||
.withIdentity(ownerIdentity)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
userId: ownerId,
|
||||
source: {
|
||||
host: "github.com",
|
||||
projectName: `test-repo-${suffix}`,
|
||||
repositoryPath: `owner-${suffix}/test-repo`,
|
||||
normalizedUrl: `https://github.com/owner-${suffix}/test-repo`,
|
||||
url: `https://github.com/owner-${suffix}/test-repo`,
|
||||
},
|
||||
remote: {
|
||||
defaultBranch: "main",
|
||||
documents: [
|
||||
{
|
||||
kind: "readme" as const,
|
||||
path: "README.md",
|
||||
content: "# test\n",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
});
|
||||
return outcome.id as unknown as Id<"projects">;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for creating signals and attachments via the DB directly (the
|
||||
// query under test is read-only; we seed through mutations/DB).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const createSignalInProject = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
identity: { readonly tokenIdentifier: string },
|
||||
orgId: Id<"organizations">,
|
||||
projectId: Id<"projects">,
|
||||
msgSuffix: string,
|
||||
ps: typeof problemStatement
|
||||
): Promise<Id<"signals">> => {
|
||||
const messageId = await seedMessage(
|
||||
t,
|
||||
identity,
|
||||
orgId,
|
||||
`req-${msgSuffix}`,
|
||||
`evidence ${msgSuffix}`
|
||||
);
|
||||
const result = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement: ps,
|
||||
projectId,
|
||||
processedBy,
|
||||
});
|
||||
return result.signalId;
|
||||
};
|
||||
|
||||
/** Insert a signalIssueAttachment directly via the test DB. */
|
||||
const linkSignalToIssue = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
signalId: Id<"signals">,
|
||||
issueId: Id<"projectIssues">,
|
||||
organizationId: Id<"organizations">,
|
||||
projectId: Id<"projects">
|
||||
): Promise<void> => {
|
||||
await t.mutation((ctx) =>
|
||||
ctx.db.insert("signalIssueAttachments", {
|
||||
createdAt: Date.now(),
|
||||
issueId,
|
||||
organizationId,
|
||||
projectId,
|
||||
signalId,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const createIssue = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
identity: { readonly tokenIdentifier: string },
|
||||
projectId: Id<"projects">,
|
||||
title: string
|
||||
): Promise<Id<"projectIssues">> =>
|
||||
t.withIdentity(identity).mutation(api.projectIssues.create, {
|
||||
body: "A representative issue body long enough to pass validation",
|
||||
projectId,
|
||||
title,
|
||||
});
|
||||
|
||||
describe("listLinkedSignalsForProject", () => {
|
||||
test("groups linked signals by issue with correct source counts", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const projectId = await createProject(t, ID_A, "group");
|
||||
|
||||
const issue1 = await createIssue(t, identityA, projectId, "Issue one");
|
||||
const issue2 = await createIssue(t, identityA, projectId, "Issue two");
|
||||
|
||||
// Signal 1: one source, linked to issue1
|
||||
const sig1 = await createSignalInProject(
|
||||
t,
|
||||
identityA,
|
||||
orgId,
|
||||
projectId,
|
||||
"s1",
|
||||
problemStatement
|
||||
);
|
||||
await linkSignalToIssue(t, sig1, issue1, orgId, projectId);
|
||||
|
||||
// Signal 2: one source, linked to issue2
|
||||
const sig2 = await createSignalInProject(
|
||||
t,
|
||||
identityA,
|
||||
orgId,
|
||||
projectId,
|
||||
"s2",
|
||||
{
|
||||
...problemStatement,
|
||||
title: "Different signal",
|
||||
}
|
||||
);
|
||||
await linkSignalToIssue(t, sig2, issue2, orgId, projectId);
|
||||
|
||||
const result = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.listLinkedSignalsForProject, { projectId });
|
||||
|
||||
expect(Object.keys(result)).toHaveLength(2);
|
||||
expect(result[String(issue1)]).toHaveLength(1);
|
||||
expect(result[String(issue2)]).toHaveLength(1);
|
||||
expect(result[String(issue1)]![0]!.title).toBe("Deploy fails on staging");
|
||||
expect(result[String(issue2)]![0]!.title).toBe("Different signal");
|
||||
expect(result[String(issue1)]![0]!.sourceCount).toBe(1);
|
||||
});
|
||||
|
||||
test("multiple signals linked to one issue are returned newest first", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const projectId = await createProject(t, ID_A, "order");
|
||||
const issue = await createIssue(t, identityA, projectId, "Single issue");
|
||||
|
||||
const ps1 = { ...problemStatement, title: "First signal" };
|
||||
const ps2 = { ...problemStatement, title: "Second signal" };
|
||||
|
||||
const sig1 = await createSignalInProject(
|
||||
t,
|
||||
identityA,
|
||||
orgId,
|
||||
projectId,
|
||||
"order1",
|
||||
ps1
|
||||
);
|
||||
// Small delay so createdAt differs.
|
||||
await t.mutation(async (ctx) => {
|
||||
// Patch createdAt to be earlier.
|
||||
const s = await ctx.db.get(sig1);
|
||||
if (s) {
|
||||
await ctx.db.patch(sig1, { createdAt: s.createdAt - 1000 });
|
||||
}
|
||||
});
|
||||
const sig2 = await createSignalInProject(
|
||||
t,
|
||||
identityA,
|
||||
orgId,
|
||||
projectId,
|
||||
"order2",
|
||||
ps2
|
||||
);
|
||||
|
||||
await linkSignalToIssue(t, sig1, issue, orgId, projectId);
|
||||
await linkSignalToIssue(t, sig2, issue, orgId, projectId);
|
||||
|
||||
const result = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.listLinkedSignalsForProject, { projectId });
|
||||
|
||||
const linked = result[String(issue)]!;
|
||||
expect(linked).toHaveLength(2);
|
||||
// Newest first: sig2 was created later.
|
||||
expect(linked[0]!.title).toBe("Second signal");
|
||||
expect(linked[1]!.title).toBe("First signal");
|
||||
});
|
||||
|
||||
test("requires project membership — denies non-member access", async () => {
|
||||
const t = newTest();
|
||||
const _orgA = await ensureOrg(t, identityA);
|
||||
const _orgB = await ensureOrg(t, identityB);
|
||||
const projectId = await createProject(t, ID_A, "auth");
|
||||
|
||||
// User B is NOT a member of org A / project.
|
||||
await expect(
|
||||
t
|
||||
.withIdentity(identityB)
|
||||
.query(api.signals.listLinkedSignalsForProject, { projectId })
|
||||
).rejects.toThrow(/Project not found|Organization membership required/u);
|
||||
});
|
||||
|
||||
test("does not return signals from a different project", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
|
||||
// Two projects in the same org.
|
||||
const projectId1 = await createProject(t, ID_A, "p1");
|
||||
const projectId2 = await createProject(t, ID_A, "p2");
|
||||
|
||||
const issue1 = await createIssue(t, identityA, projectId1, "Issue P1");
|
||||
const issue2 = await createIssue(t, identityA, projectId2, "Issue P2");
|
||||
|
||||
// Signal linked to issue in project2.
|
||||
const sig = await createSignalInProject(
|
||||
t,
|
||||
identityA,
|
||||
orgId,
|
||||
projectId2,
|
||||
"xproj",
|
||||
problemStatement
|
||||
);
|
||||
await linkSignalToIssue(t, sig, issue2, orgId, projectId2);
|
||||
|
||||
// Querying project1 should NOT see project2's signal.
|
||||
const result1 = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.listLinkedSignalsForProject, {
|
||||
projectId: projectId1,
|
||||
});
|
||||
expect(result1[String(issue1)] ?? []).toHaveLength(0);
|
||||
|
||||
// Querying project2 SHOULD see it.
|
||||
const result2 = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.listLinkedSignalsForProject, {
|
||||
projectId: projectId2,
|
||||
});
|
||||
expect(result2[String(issue2)] ?? []).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("returns correct source count for multi-source signals", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const projectId = await createProject(t, ID_A, "sources");
|
||||
const issue = await createIssue(t, identityA, projectId, "Multi-source");
|
||||
|
||||
// Create a signal with two sources.
|
||||
const m1 = await seedMessage(t, identityA, orgId, "ms-1", "evidence a");
|
||||
const m2 = await seedMessage(t, identityA, orgId, "ms-2", "evidence b");
|
||||
const result = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [m1, m2],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
projectId,
|
||||
processedBy,
|
||||
});
|
||||
await linkSignalToIssue(t, result.signalId, issue, orgId, projectId);
|
||||
|
||||
const linked = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.listLinkedSignalsForProject, { projectId });
|
||||
|
||||
const signals = linked[String(issue)]!;
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0]!.sourceCount).toBe(2);
|
||||
});
|
||||
|
||||
test("returns empty record for a project with no attachments", async () => {
|
||||
const t = newTest();
|
||||
const _orgId = await ensureOrg(t, identityA);
|
||||
const projectId = await createProject(t, ID_A, "empty");
|
||||
await createIssue(t, identityA, projectId, "Orphan issue");
|
||||
|
||||
const result = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.listLinkedSignalsForProject, { projectId });
|
||||
|
||||
expect(Object.keys(result)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("issue with no linked signals is omitted from the result", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const projectId = await createProject(t, ID_A, "mixed");
|
||||
const issueWith = await createIssue(t, identityA, projectId, "Has signal");
|
||||
const issueWithout = await createIssue(
|
||||
t,
|
||||
identityA,
|
||||
projectId,
|
||||
"No signal"
|
||||
);
|
||||
|
||||
const sig = await createSignalInProject(
|
||||
t,
|
||||
identityA,
|
||||
orgId,
|
||||
projectId,
|
||||
"mixed1",
|
||||
problemStatement
|
||||
);
|
||||
await linkSignalToIssue(t, sig, issueWith, orgId, projectId);
|
||||
|
||||
const result = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.listLinkedSignalsForProject, { projectId });
|
||||
|
||||
expect(Object.keys(result)).toHaveLength(1);
|
||||
expect(result[String(issueWith)]).toHaveLength(1);
|
||||
expect(result[String(issueWithout)]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { query } from "./_generated/server";
|
||||
import { requireProjectMember } from "./authz";
|
||||
|
||||
export const list = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
try {
|
||||
await requireProjectMember(ctx, args.projectId);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", args.projectId))
|
||||
.take(25);
|
||||
},
|
||||
});
|
||||
@@ -1,285 +0,0 @@
|
||||
import { env } from "@code/env/convex";
|
||||
import { type TestConvex, convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import schema from "./schema";
|
||||
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
|
||||
const FLUE_DB_TOKEN = env.FLUE_DB_TOKEN;
|
||||
|
||||
const ID_A = "https://convex.test|user-a";
|
||||
const identityA = { tokenIdentifier: ID_A };
|
||||
|
||||
const SOURCE = {
|
||||
host: "github.com",
|
||||
projectName: "zopu",
|
||||
repositoryPath: "puter/zopu",
|
||||
normalizedUrl: "https://github.com/puter/zopu",
|
||||
url: "https://github.com/puter/zopu",
|
||||
};
|
||||
|
||||
const REMOTE = {
|
||||
defaultBranch: "main",
|
||||
documents: [
|
||||
{
|
||||
kind: "readme" as const,
|
||||
path: "README.md",
|
||||
content: "# zopu\n\nRepository: https://github.com/puter/zopu\n",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const createTestProject = async (
|
||||
t: TestConvex<typeof schema>
|
||||
): Promise<Id<"projects">> => {
|
||||
// Ensure personal organization for the user
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization);
|
||||
// Persist the public Git import
|
||||
const outcome = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
userId: ID_A,
|
||||
source: SOURCE,
|
||||
remote: REMOTE,
|
||||
});
|
||||
return outcome.id as unknown as Id<"projects">;
|
||||
};
|
||||
|
||||
const eventsForProject = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
projectId: Id<"projects">
|
||||
) => {
|
||||
return await t.query((ctx) =>
|
||||
ctx.db
|
||||
.query("projectEvents")
|
||||
.withIndex("by_project_and_createdAt", (q) =>
|
||||
q.eq("projectId", projectId)
|
||||
)
|
||||
.collect()
|
||||
);
|
||||
};
|
||||
|
||||
describe("projectEvents", () => {
|
||||
test("project connect emits a project.connected event", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await createTestProject(t);
|
||||
|
||||
const events = await eventsForProject(t, projectId);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].kind).toBe("project.connected");
|
||||
expect(events[0].projectId).toBe(projectId);
|
||||
expect(events[0].issueId).toBeUndefined();
|
||||
expect(events[0].runId).toBeUndefined();
|
||||
});
|
||||
|
||||
test("issue create and begin emit issue.created then issue.queued events", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await createTestProject(t);
|
||||
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
body: "A representative issue body long enough to pass validation",
|
||||
projectId,
|
||||
title: "Representative issue",
|
||||
});
|
||||
|
||||
const afterCreate = await eventsForProject(t, projectId);
|
||||
expect(afterCreate.map((e) => e.kind)).toEqual([
|
||||
"project.connected",
|
||||
"issue.created",
|
||||
]);
|
||||
const created = afterCreate[1];
|
||||
expect(created.issueId).toBe(issueId);
|
||||
expect(created.data).toMatchObject({
|
||||
number: 1,
|
||||
title: "Representative issue",
|
||||
});
|
||||
|
||||
const status = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.begin, {
|
||||
issueId,
|
||||
});
|
||||
expect(status).toBe("queued");
|
||||
|
||||
const afterBegin = await eventsForProject(t, projectId);
|
||||
expect(afterBegin.map((e) => e.kind)).toEqual([
|
||||
"project.connected",
|
||||
"issue.created",
|
||||
"issue.queued",
|
||||
]);
|
||||
expect(afterBegin[2].issueId).toBe(issueId);
|
||||
expect(afterBegin[2].data).toMatchObject({ number: 1 });
|
||||
});
|
||||
|
||||
test("artifact update emits an artifact.updated event", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await createTestProject(t);
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
body: "A representative issue body long enough to pass validation",
|
||||
projectId,
|
||||
title: "Representative issue",
|
||||
});
|
||||
|
||||
const revision = await t.mutation(api.agentWorkspace.updateArtifact, {
|
||||
content: "# Updated work\n\nRevised content.",
|
||||
issueId,
|
||||
path: "work.md",
|
||||
token: FLUE_DB_TOKEN,
|
||||
});
|
||||
|
||||
expect(revision).toBe(3);
|
||||
|
||||
const events = await eventsForProject(t, projectId);
|
||||
const artifactEvent = events.find((e) => e.kind === "artifact.updated");
|
||||
expect(artifactEvent).toBeDefined();
|
||||
expect(artifactEvent?.issueId).toBe(issueId);
|
||||
expect(artifactEvent?.data).toMatchObject({ path: "work.md", revision: 3 });
|
||||
});
|
||||
|
||||
test("agent status emits an agent.<status> event linked to the run", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await createTestProject(t);
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
body: "A representative issue body long enough to pass validation",
|
||||
projectId,
|
||||
title: "Representative issue",
|
||||
});
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.begin, { issueId });
|
||||
|
||||
const run = await t.mutation(api.agentWorkspace.ensureRun, {
|
||||
daemonId: "daemon-1",
|
||||
issueId,
|
||||
token: FLUE_DB_TOKEN,
|
||||
});
|
||||
expect(run).toMatchObject({
|
||||
baseBranch: "main",
|
||||
checkoutPath: "/workspace/repository",
|
||||
sourceUrl: SOURCE.url,
|
||||
});
|
||||
expect(run?.branchName).toMatch(/^work\/issue-1-/u);
|
||||
await t.mutation(api.agentWorkspace.setStatus, {
|
||||
issueId,
|
||||
status: "completed",
|
||||
summary: "Done.",
|
||||
token: FLUE_DB_TOKEN,
|
||||
});
|
||||
|
||||
const events = await eventsForProject(t, projectId);
|
||||
const workspaceEvent = events.find(
|
||||
(e) => e.kind === "agent.workspace.created"
|
||||
);
|
||||
expect(workspaceEvent).toBeDefined();
|
||||
expect(workspaceEvent?.runId).toBeDefined();
|
||||
|
||||
const statusEvent = events.find((e) => e.kind === "agent.completed");
|
||||
expect(statusEvent).toBeDefined();
|
||||
expect(statusEvent?.issueId).toBe(issueId);
|
||||
expect(statusEvent?.runId).toBe(workspaceEvent?.runId);
|
||||
expect(statusEvent?.data).toMatchObject({ summary: "Done." });
|
||||
});
|
||||
|
||||
test("wrong agent token is rejected before any event is written", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await createTestProject(t);
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
body: "A representative issue body long enough to pass validation",
|
||||
projectId,
|
||||
title: "Representative issue",
|
||||
});
|
||||
|
||||
await expect(
|
||||
t.mutation(api.agentWorkspace.setStatus, {
|
||||
issueId,
|
||||
status: "completed",
|
||||
summary: "Done.",
|
||||
token: "wrong-token",
|
||||
})
|
||||
).rejects.toThrow(/Invalid agent control token/u);
|
||||
|
||||
const events = await eventsForProject(t, projectId);
|
||||
expect(events.some((e) => e.kind === "agent.completed")).toBe(false);
|
||||
});
|
||||
|
||||
test("Gitea lifecycle persists PR metadata in an event and artifact", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await createTestProject(t);
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
body: "A representative issue body long enough to pass validation",
|
||||
projectId,
|
||||
title: "Representative issue",
|
||||
});
|
||||
await t.mutation(api.agentWorkspace.ensureRun, {
|
||||
daemonId: "daemon-1",
|
||||
issueId,
|
||||
token: FLUE_DB_TOKEN,
|
||||
});
|
||||
|
||||
await t.mutation(api.agentWorkspace.recordGiteaLifecycle, {
|
||||
baseBranch: "main",
|
||||
branch: "work/issue-1",
|
||||
commitSha: "abc123",
|
||||
issueId,
|
||||
pullRequest: {
|
||||
baseBranch: "main",
|
||||
branch: "work/issue-1",
|
||||
number: 5,
|
||||
status: "open",
|
||||
url: "https://git.openputer.com/puter/zopu-code/pulls/5",
|
||||
},
|
||||
status: "pull_request_open",
|
||||
token: FLUE_DB_TOKEN,
|
||||
});
|
||||
|
||||
const events = await eventsForProject(t, projectId);
|
||||
const event = events.find(
|
||||
(item) => item.kind === "gitea.pull_request.created"
|
||||
);
|
||||
expect(event?.data).toMatchObject({
|
||||
branch: "work/issue-1",
|
||||
commitSha: "abc123",
|
||||
pullRequest: {
|
||||
number: 5,
|
||||
status: "open",
|
||||
},
|
||||
status: "pull_request_open",
|
||||
});
|
||||
|
||||
const artifacts = await t.query((ctx) =>
|
||||
ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project_and_path", (q) =>
|
||||
q.eq("projectId", projectId).eq("path", "artifacts.md")
|
||||
)
|
||||
.unique()
|
||||
);
|
||||
expect(artifacts?.content).toContain(
|
||||
"https://git.openputer.com/puter/zopu-code/pulls/5"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
import { type TestConvex, convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import schema from "./schema";
|
||||
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
|
||||
const ID_A = "https://convex.test|project-request-a";
|
||||
const ID_B = "https://convex.test|project-request-b";
|
||||
const identityA = { tokenIdentifier: ID_A };
|
||||
const identityB = { tokenIdentifier: ID_B };
|
||||
|
||||
const createProject = async (
|
||||
t: TestConvex<typeof schema>
|
||||
): Promise<Id<"projects">> => {
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const outcome = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
userId: ID_A,
|
||||
source: {
|
||||
host: "github.com",
|
||||
normalizedUrl: "https://github.com/test/project-request",
|
||||
projectName: "project-request",
|
||||
repositoryPath: "test/project-request",
|
||||
url: "https://github.com/test/project-request",
|
||||
},
|
||||
remote: {
|
||||
defaultBranch: "main",
|
||||
documents: [
|
||||
{
|
||||
content: "# Project Request\n",
|
||||
kind: "readme" as const,
|
||||
path: "README.md",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
});
|
||||
return outcome.id as unknown as Id<"projects">;
|
||||
};
|
||||
|
||||
describe("project issue request smoke", () => {
|
||||
test("project Signal becomes a queued project issue with durable evidence", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await createProject(t);
|
||||
const organization = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const begun = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "project-request-1",
|
||||
organizationId: organization._id,
|
||||
rawText: "Staging deploys fail during DNS resolution.",
|
||||
});
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "project-request-1",
|
||||
organizationId: organization._id,
|
||||
submissionId: "submission-project-request-1",
|
||||
});
|
||||
const signal = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: organization._id,
|
||||
messageIds: [begun.messageId],
|
||||
organizationId: organization._id,
|
||||
problemStatement: {
|
||||
constraints: ["Do not change production"],
|
||||
desiredOutcome: "Staging deploys successfully",
|
||||
summary: "The staging deploy fails during DNS resolution.",
|
||||
title: "Fix the staging deploy DNS failure",
|
||||
},
|
||||
processedBy: {
|
||||
agentInstanceId: String(organization._id),
|
||||
agentName: "zopu",
|
||||
},
|
||||
projectId,
|
||||
});
|
||||
|
||||
const created = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.createFromSignal, {
|
||||
signalId: signal.signalId,
|
||||
});
|
||||
const issue = await t.run(async (ctx) => ctx.db.get(created.issueId));
|
||||
|
||||
expect(issue).toMatchObject({
|
||||
body: expect.stringContaining("Source Signal"),
|
||||
projectId,
|
||||
status: "open",
|
||||
title: "Fix the staging deploy DNS failure",
|
||||
});
|
||||
|
||||
const status = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.begin, { issueId: created.issueId });
|
||||
expect(status).toBe("queued");
|
||||
|
||||
const events = await t.run(async (ctx) =>
|
||||
ctx.db
|
||||
.query("projectEvents")
|
||||
.withIndex("by_project_and_createdAt", (q) =>
|
||||
q.eq("projectId", projectId)
|
||||
)
|
||||
.collect()
|
||||
);
|
||||
expect(events.map((event) => event.kind)).toEqual([
|
||||
"project.connected",
|
||||
"issue.created",
|
||||
"issue.queued",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a different organization cannot consume the project Signal", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await createProject(t);
|
||||
await t
|
||||
.withIdentity(identityB)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const organization = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const begun = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "project-request-cross-org",
|
||||
organizationId: organization._id,
|
||||
rawText: "Private project request",
|
||||
});
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "project-request-cross-org",
|
||||
organizationId: organization._id,
|
||||
submissionId: "submission-project-request-cross-org",
|
||||
});
|
||||
const signal = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: organization._id,
|
||||
messageIds: [begun.messageId],
|
||||
organizationId: organization._id,
|
||||
problemStatement: {
|
||||
constraints: [],
|
||||
desiredOutcome: "Keep the request private",
|
||||
summary: "This request belongs to the first organization.",
|
||||
title: "Private project request",
|
||||
},
|
||||
processedBy: {
|
||||
agentInstanceId: String(organization._id),
|
||||
agentName: "zopu",
|
||||
},
|
||||
projectId,
|
||||
});
|
||||
|
||||
await expect(
|
||||
t.withIdentity(identityB).mutation(api.projectIssues.createFromSignal, {
|
||||
signalId: signal.signalId,
|
||||
})
|
||||
).rejects.toThrow(/membership required/u);
|
||||
});
|
||||
});
|
||||
@@ -1,223 +0,0 @@
|
||||
import {
|
||||
projectIssueDraftFromSignal,
|
||||
queueProjectIssue,
|
||||
validateProjectIssueDraft,
|
||||
type ProjectIssueDraft,
|
||||
} from "@code/primitives/project-issue";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { mutation, type MutationCtx, query } from "./_generated/server";
|
||||
import { requireProjectMember } from "./authz";
|
||||
|
||||
const toDomainError = (error: unknown) => {
|
||||
return new ConvexError(
|
||||
error instanceof Error ? error.message : "Invalid project issue"
|
||||
);
|
||||
};
|
||||
|
||||
const decodeDraft = async (input: unknown): Promise<ProjectIssueDraft> => {
|
||||
try {
|
||||
return await Effect.runPromise(validateProjectIssueDraft(input));
|
||||
} catch (error) {
|
||||
throw toDomainError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const draftFromSignal = async (input: unknown): Promise<ProjectIssueDraft> => {
|
||||
try {
|
||||
return await Effect.runPromise(projectIssueDraftFromSignal(input));
|
||||
} catch (error) {
|
||||
throw toDomainError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const insertIssue = async (
|
||||
ctx: MutationCtx,
|
||||
projectId: Id<"projects">,
|
||||
draft: ProjectIssueDraft
|
||||
): Promise<Id<"projectIssues">> => {
|
||||
const latest = await ctx.db
|
||||
.query("projectIssues")
|
||||
.withIndex("by_project_and_number", (q) => q.eq("projectId", projectId))
|
||||
.order("desc")
|
||||
.first();
|
||||
const timestamp = Date.now();
|
||||
const number = (latest?.number ?? 0) + 1;
|
||||
const issueId = await ctx.db.insert("projectIssues", {
|
||||
body: draft.body,
|
||||
createdAt: timestamp,
|
||||
number,
|
||||
projectId,
|
||||
status: "open",
|
||||
title: draft.title,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
const workArtifact = await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project_and_path", (q) =>
|
||||
q.eq("projectId", projectId).eq("path", "work.md")
|
||||
)
|
||||
.unique();
|
||||
if (workArtifact) {
|
||||
await ctx.db.patch("projectArtifacts", workArtifact._id, {
|
||||
content: `${workArtifact.content}\n## Issue ${number}: ${draft.title}\n\nStatus: open\n\n${draft.body}\n`,
|
||||
revision: workArtifact.revision + 1,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { number, title: draft.title },
|
||||
issueId,
|
||||
kind: "issue.created",
|
||||
projectId,
|
||||
});
|
||||
return issueId;
|
||||
};
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
body: v.string(),
|
||||
projectId: v.id("projects"),
|
||||
title: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireProjectMember(ctx, args.projectId);
|
||||
const draft = await decodeDraft({ body: args.body, title: args.title });
|
||||
return insertIssue(ctx, args.projectId, draft);
|
||||
},
|
||||
});
|
||||
|
||||
export const createFromSignal = mutation({
|
||||
args: { signalId: v.id("signals") },
|
||||
handler: async (ctx, args) => {
|
||||
const signal = await ctx.db.get("signals", args.signalId);
|
||||
if (!signal) {
|
||||
throw new ConvexError("Signal not found");
|
||||
}
|
||||
if (!signal.projectId) {
|
||||
throw new ConvexError("Signal is not project-scoped");
|
||||
}
|
||||
const { organizationId } = await requireProjectMember(
|
||||
ctx,
|
||||
signal.projectId
|
||||
);
|
||||
if (signal.organizationId !== organizationId) {
|
||||
throw new ConvexError(
|
||||
"Signal does not belong to the project organization"
|
||||
);
|
||||
}
|
||||
const draft = await draftFromSignal({
|
||||
problemStatement: signal.problemStatement,
|
||||
signalId: String(signal._id),
|
||||
});
|
||||
const issueId = await insertIssue(ctx, signal.projectId, draft);
|
||||
return { issueId, projectId: signal.projectId };
|
||||
},
|
||||
});
|
||||
|
||||
export const getForDispatch = query({
|
||||
args: { issueId: v.id("projectIssues") },
|
||||
handler: async (ctx, args) => {
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
return null;
|
||||
}
|
||||
await requireProjectMember(ctx, issue.projectId);
|
||||
return { issueId: issue._id, projectId: issue.projectId };
|
||||
},
|
||||
});
|
||||
|
||||
export const begin = mutation({
|
||||
args: { issueId: v.id("projectIssues") },
|
||||
handler: async (ctx, args) => {
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
await requireProjectMember(ctx, issue.projectId);
|
||||
let status: "queued" | "working";
|
||||
try {
|
||||
status = await Effect.runPromise(queueProjectIssue(issue.status));
|
||||
} catch (error) {
|
||||
throw toDomainError(error);
|
||||
}
|
||||
if (status === issue.status) {
|
||||
return status;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("projectIssues", issue._id, {
|
||||
status,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { number: issue.number },
|
||||
issueId: issue._id,
|
||||
kind: "issue.queued",
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
return "queued" as const;
|
||||
},
|
||||
});
|
||||
|
||||
export const markDispatchFailed = mutation({
|
||||
args: { error: v.string(), issueId: v.id("projectIssues") },
|
||||
handler: async (ctx, args) => {
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
await requireProjectMember(ctx, issue.projectId);
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("projectIssues", issue._id, {
|
||||
status: "failed",
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { error: args.error.slice(0, 1000), phase: "dispatch" },
|
||||
issueId: issue._id,
|
||||
kind: "agent.failed",
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const list = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
try {
|
||||
await requireProjectMember(ctx, args.projectId);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return await ctx.db
|
||||
.query("projectIssues")
|
||||
.withIndex("by_project_and_number", (q) =>
|
||||
q.eq("projectId", args.projectId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(100);
|
||||
},
|
||||
});
|
||||
|
||||
export const events = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
try {
|
||||
await requireProjectMember(ctx, args.projectId);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return await ctx.db
|
||||
.query("projectEvents")
|
||||
.withIndex("by_project_and_createdAt", (q) =>
|
||||
q.eq("projectId", args.projectId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(100);
|
||||
},
|
||||
});
|
||||
@@ -1,430 +0,0 @@
|
||||
import {
|
||||
CONTEXT_KINDS,
|
||||
contextPathForKind,
|
||||
decideContextWrite,
|
||||
makeInitialContext,
|
||||
type ContextDocumentState,
|
||||
type ContextKind,
|
||||
type ContextWrite,
|
||||
type PreparedPublicGitSource,
|
||||
type ProjectImportOutcome,
|
||||
type ProjectView,
|
||||
type PublicGitImportResult,
|
||||
} from "@code/primitives/project";
|
||||
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { requireCurrentOrganization, requireProjectMember } from "./authz";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View mappers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SourceRow extends Doc<"projectSources"> {}
|
||||
|
||||
const toContextDocumentState = (
|
||||
doc: Doc<"projectContextDocuments">
|
||||
): ContextDocumentState => ({
|
||||
content: doc.content,
|
||||
kind: doc.kind,
|
||||
origin: doc.origin,
|
||||
path: doc.path,
|
||||
revision: doc.revision,
|
||||
sourceUrl: doc.sourceUrl ?? undefined,
|
||||
});
|
||||
|
||||
const toProjectView = async (
|
||||
ctx: QueryCtx,
|
||||
project: Doc<"projects">
|
||||
): Promise<ProjectView> => {
|
||||
const [sources, contextDocs] = await Promise.all([
|
||||
ctx.db
|
||||
.query("projectSources")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", project._id))
|
||||
.collect(),
|
||||
ctx.db
|
||||
.query("projectContextDocuments")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", project._id))
|
||||
.collect(),
|
||||
]);
|
||||
|
||||
return {
|
||||
contextDocuments: contextDocs.map(toContextDocumentState),
|
||||
createdAt: project.createdAt as never,
|
||||
id: project._id as never,
|
||||
name: project.name,
|
||||
organizationId: project.organizationId as never,
|
||||
sources: sources.map((s) => ({
|
||||
createdAt: s.createdAt as never,
|
||||
defaultBranch: s.defaultBranch,
|
||||
host: s.host,
|
||||
kind: "git" as const,
|
||||
normalizedUrl: s.normalizedUrl,
|
||||
projectId: s.projectId as never,
|
||||
repositoryPath: s.repositoryPath,
|
||||
updatedAt: s.updatedAt as never,
|
||||
url: s.url,
|
||||
})),
|
||||
updatedAt: project.updatedAt as never,
|
||||
};
|
||||
};
|
||||
|
||||
const toImportOutcome = async (
|
||||
ctx: QueryCtx,
|
||||
project: Doc<"projects">,
|
||||
source: SourceRow
|
||||
): Promise<ProjectImportOutcome> => {
|
||||
const contextDocs = await ctx.db
|
||||
.query("projectContextDocuments")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", project._id))
|
||||
.collect();
|
||||
|
||||
return {
|
||||
contextDocuments: contextDocs.map(toContextDocumentState),
|
||||
createdAt: project.createdAt as never,
|
||||
id: project._id as never,
|
||||
name: project.name,
|
||||
organizationId: project.organizationId as never,
|
||||
source: {
|
||||
createdAt: source.createdAt as never,
|
||||
defaultBranch: source.defaultBranch,
|
||||
host: source.host,
|
||||
kind: "git" as const,
|
||||
normalizedUrl: source.normalizedUrl,
|
||||
projectId: source.projectId as never,
|
||||
repositoryPath: source.repositoryPath,
|
||||
updatedAt: source.updatedAt as never,
|
||||
url: source.url,
|
||||
},
|
||||
updatedAt: project.updatedAt as never,
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query store — read-only ctx.db access
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const makeProjectQueryStore = (ctx: QueryCtx) => ({
|
||||
listProjects: async (userId: string): Promise<ProjectView[]> => {
|
||||
const { organizationId } = await resolveOrgForUser(ctx, userId);
|
||||
const projects = await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_organization_and_createdAt", (q) =>
|
||||
q.eq("organizationId", organizationId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(50);
|
||||
return Promise.all(projects.map((p) => toProjectView(ctx, p)));
|
||||
},
|
||||
|
||||
getProject: async (
|
||||
userId: string,
|
||||
projectId: Id<"projects">
|
||||
): Promise<ProjectView | null> => {
|
||||
const { organizationId } = await resolveOrgForUser(ctx, userId);
|
||||
const project = await ctx.db.get(projectId);
|
||||
if (!project || project.organizationId !== organizationId) {
|
||||
return null;
|
||||
}
|
||||
return toProjectView(ctx, project);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutation store — transactional ctx.db writes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const makeProjectMutationStore = (ctx: MutationCtx) => ({
|
||||
putContext: async (
|
||||
userId: string,
|
||||
projectId: Id<"projects">,
|
||||
write: ContextWrite
|
||||
): Promise<{ revision: number }> => {
|
||||
const { organizationId } = await resolveProjectOrg(ctx, projectId, userId);
|
||||
const path = contextPathForKind(write.kind);
|
||||
const existing = await ctx.db
|
||||
.query("projectContextDocuments")
|
||||
.withIndex("by_project_and_path", (q) =>
|
||||
q.eq("projectId", projectId).eq("path", path)
|
||||
)
|
||||
.unique();
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
decideContextWrite({
|
||||
existing: existing ? toContextDocumentState(existing) : undefined,
|
||||
write,
|
||||
})
|
||||
);
|
||||
const now = Date.now();
|
||||
|
||||
if (!result.changed) {
|
||||
return { revision: result.document.revision };
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
content: result.document.content,
|
||||
origin: result.document.origin,
|
||||
revision: result.document.revision,
|
||||
sourceUrl: result.document.sourceUrl,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("projectContextDocuments", {
|
||||
content: result.document.content,
|
||||
createdAt: now,
|
||||
kind: result.document.kind,
|
||||
organizationId,
|
||||
origin: result.document.origin,
|
||||
path: result.document.path,
|
||||
projectId,
|
||||
revision: result.document.revision,
|
||||
sourceUrl: result.document.sourceUrl,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
return { revision: result.document.revision };
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action store — calls narrowly scoped internal mutations/queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const makeProjectActionStore = (_ctx: ActionCtx) => ({
|
||||
getCurrentOrganization: async (userId: string) => {
|
||||
return userId;
|
||||
},
|
||||
|
||||
listProjects: async (userId: string) => {
|
||||
return userId;
|
||||
},
|
||||
|
||||
getProject: async (userId: string, _projectId: Id<"projects">) => {
|
||||
return userId;
|
||||
},
|
||||
|
||||
persistPublicGitImport: async (
|
||||
_userId: string,
|
||||
_source: PreparedPublicGitSource,
|
||||
_remote: PublicGitImportResult
|
||||
): Promise<ProjectImportOutcome> => {
|
||||
throw new Error(
|
||||
"persistPublicGitImport must be called via internal mutation"
|
||||
);
|
||||
},
|
||||
|
||||
putContext: async (
|
||||
_userId: string,
|
||||
_projectId: Id<"projects">,
|
||||
_write: ContextWrite
|
||||
): Promise<{ revision: number }> => {
|
||||
throw new Error("putContext must be called via internal mutation");
|
||||
},
|
||||
});
|
||||
|
||||
// We need Effect for decideContextWrite at runtime.
|
||||
import { Effect } from "effect";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal mutation store — the actual transactional persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const persistPublicGitImportTransaction = async (
|
||||
ctx: MutationCtx,
|
||||
userId: string,
|
||||
source: PreparedPublicGitSource,
|
||||
remote: PublicGitImportResult
|
||||
): Promise<ProjectImportOutcome> => {
|
||||
const { organizationId } = await resolveOrgForUser(ctx, userId);
|
||||
|
||||
// Idempotency: lookup by normalizedUrl within the organization
|
||||
const existingSource = await ctx.db
|
||||
.query("projectSources")
|
||||
.withIndex("by_organization_and_normalizedUrl", (q) =>
|
||||
q
|
||||
.eq("organizationId", organizationId)
|
||||
.eq("normalizedUrl", source.normalizedUrl)
|
||||
)
|
||||
.unique();
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (existingSource) {
|
||||
const project = await ctx.db.get(existingSource.projectId);
|
||||
if (!project) {
|
||||
throw new Error("Project not found for existing source");
|
||||
}
|
||||
// Update source metadata
|
||||
await ctx.db.patch(existingSource._id, {
|
||||
defaultBranch: remote.defaultBranch,
|
||||
updatedAt: now,
|
||||
});
|
||||
// Apply remote documents as repository writes
|
||||
for (const doc of remote.documents) {
|
||||
await applyRepositoryWrite(
|
||||
ctx,
|
||||
project._id,
|
||||
organizationId,
|
||||
doc,
|
||||
source.url,
|
||||
now
|
||||
);
|
||||
}
|
||||
await ctx.db.patch(project._id, { updatedAt: now });
|
||||
const updatedSource = await ctx.db.get(existingSource._id);
|
||||
return toImportOutcome(ctx, project, updatedSource ?? existingSource);
|
||||
}
|
||||
|
||||
// Create new project
|
||||
const projectId = await ctx.db.insert("projects", {
|
||||
createdAt: now,
|
||||
name: source.projectName,
|
||||
organizationId,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
// Create source
|
||||
const sourceId = await ctx.db.insert("projectSources", {
|
||||
createdAt: now,
|
||||
defaultBranch: remote.defaultBranch,
|
||||
host: source.host,
|
||||
kind: "git",
|
||||
normalizedUrl: source.normalizedUrl,
|
||||
organizationId,
|
||||
projectId,
|
||||
repositoryPath: source.repositoryPath,
|
||||
updatedAt: now,
|
||||
url: source.url,
|
||||
});
|
||||
|
||||
// Seed six canonical context documents
|
||||
const seeds = makeInitialContext(source.projectName, source.url);
|
||||
for (const seed of seeds) {
|
||||
await ctx.db.insert("projectContextDocuments", {
|
||||
content: seed.content,
|
||||
createdAt: now,
|
||||
kind: seed.kind,
|
||||
organizationId,
|
||||
origin: seed.origin,
|
||||
path: seed.path,
|
||||
projectId,
|
||||
revision: seed.revision,
|
||||
sourceUrl: seed.sourceUrl,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
// Apply remote documents (overrides seeds)
|
||||
for (const doc of remote.documents) {
|
||||
await applyRepositoryWrite(
|
||||
ctx,
|
||||
projectId,
|
||||
organizationId,
|
||||
doc,
|
||||
source.url,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
const createdProject = await ctx.db.get(projectId);
|
||||
const createdSource = await ctx.db.get(sourceId);
|
||||
if (!createdProject || !createdSource) {
|
||||
throw new Error("Failed to read created project");
|
||||
}
|
||||
return toImportOutcome(ctx, createdProject, createdSource);
|
||||
};
|
||||
|
||||
const applyRepositoryWrite = async (
|
||||
ctx: MutationCtx,
|
||||
projectId: Id<"projects">,
|
||||
organizationId: Id<"organizations">,
|
||||
doc: { kind: ContextKind; path: string; content: string },
|
||||
sourceUrl: string,
|
||||
now: number
|
||||
) => {
|
||||
const path = contextPathForKind(doc.kind);
|
||||
const existing = await ctx.db
|
||||
.query("projectContextDocuments")
|
||||
.withIndex("by_project_and_path", (q) =>
|
||||
q.eq("projectId", projectId).eq("path", path)
|
||||
)
|
||||
.unique();
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
decideContextWrite({
|
||||
existing: existing ? toContextDocumentState(existing) : undefined,
|
||||
write: {
|
||||
_tag: "Repository" as const,
|
||||
content: doc.content,
|
||||
kind: doc.kind,
|
||||
sourceUrl,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
if (!result.changed) return;
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
content: result.document.content,
|
||||
origin: result.document.origin,
|
||||
revision: result.document.revision,
|
||||
sourceUrl: result.document.sourceUrl,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("projectContextDocuments", {
|
||||
content: result.document.content,
|
||||
createdAt: now,
|
||||
kind: result.document.kind,
|
||||
organizationId,
|
||||
origin: result.document.origin,
|
||||
path: result.document.path,
|
||||
projectId,
|
||||
revision: result.document.revision,
|
||||
sourceUrl: result.document.sourceUrl,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Organization resolvers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const resolveOrgForUser = async (
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
userId: string
|
||||
): Promise<{ organizationId: Id<"organizations"> }> => {
|
||||
const organization = await ctx.db
|
||||
.query("organizations")
|
||||
.withIndex("by_createdBy_and_kind", (q) =>
|
||||
q.eq("createdBy", userId).eq("kind", "personal")
|
||||
)
|
||||
.unique();
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
return { organizationId: organization._id };
|
||||
};
|
||||
|
||||
const resolveProjectOrg = async (
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
projectId: Id<"projects">,
|
||||
userId: string
|
||||
): Promise<{ organizationId: Id<"organizations"> }> => {
|
||||
const project = await ctx.db.get(projectId);
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
const { organizationId } = await resolveOrgForUser(ctx, userId);
|
||||
if (project.organizationId !== organizationId) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
return { organizationId };
|
||||
};
|
||||
|
||||
// Re-export for convenience
|
||||
export { CONTEXT_KINDS, requireCurrentOrganization, requireProjectMember };
|
||||
@@ -3,27 +3,54 @@ import {
|
||||
preparePublicGitSource,
|
||||
type ProjectImportOutcome,
|
||||
type ProjectView,
|
||||
type PublicGitImportResult,
|
||||
type PreparedPublicGitSource,
|
||||
} from "@code/primitives/project";
|
||||
import { v } from "convex/values";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import { action, internalMutation, query } from "./_generated/server";
|
||||
import { createInitialArtifacts } from "./artifactModel";
|
||||
import { requireAuthUserId, requireCurrentOrganization } from "./authz";
|
||||
import {
|
||||
makeProjectMutationStore,
|
||||
makeProjectQueryStore,
|
||||
persistPublicGitImportTransaction,
|
||||
} from "./projectStore";
|
||||
import { inspectPublicGit } from "./publicGit";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: persist a public Git import in one transaction
|
||||
// ---------------------------------------------------------------------------
|
||||
const toProjectView = async (
|
||||
ctx: Parameters<typeof requireCurrentOrganization>[0],
|
||||
project: Doc<"projects">
|
||||
): Promise<ProjectView> => {
|
||||
const documents = await ctx.db
|
||||
.query("projectContextDocuments")
|
||||
.withIndex("by_projectId_and_path", (q) => q.eq("projectId", project._id))
|
||||
.collect();
|
||||
const view = {
|
||||
contextDocuments: documents.map((document) => ({
|
||||
content: document.content,
|
||||
kind: document.kind,
|
||||
origin: document.origin,
|
||||
path: document.path,
|
||||
revision: 1,
|
||||
sourceUrl: document.sourceUrl,
|
||||
})),
|
||||
createdAt: project.createdAt,
|
||||
id: String(project._id),
|
||||
name: project.name,
|
||||
organizationId: String(project.organizationId),
|
||||
sources: [
|
||||
{
|
||||
createdAt: project.createdAt,
|
||||
defaultBranch: project.defaultBranch,
|
||||
host: project.sourceHost,
|
||||
kind: "git",
|
||||
normalizedUrl: project.normalizedSourceUrl,
|
||||
projectId: String(project._id),
|
||||
repositoryPath: project.repositoryPath,
|
||||
updatedAt: project.updatedAt,
|
||||
url: project.sourceUrl,
|
||||
},
|
||||
],
|
||||
updatedAt: project.updatedAt,
|
||||
};
|
||||
return view as unknown as ProjectView;
|
||||
};
|
||||
|
||||
export const persistPublicGitImport = internalMutation({
|
||||
args: {
|
||||
@@ -51,143 +78,106 @@ export const persistPublicGitImport = internalMutation({
|
||||
content: v.string(),
|
||||
})
|
||||
),
|
||||
warnings: v.array(
|
||||
v.object({
|
||||
path: v.string(),
|
||||
message: v.string(),
|
||||
})
|
||||
),
|
||||
warnings: v.array(v.object({ path: v.string(), message: v.string() })),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ProjectImportOutcome> => {
|
||||
const result = await persistPublicGitImportTransaction(
|
||||
ctx,
|
||||
args.userId,
|
||||
args.source as PreparedPublicGitSource,
|
||||
args.remote as PublicGitImportResult
|
||||
);
|
||||
|
||||
// Seed operational artifacts on first creation (detected by checking
|
||||
// existing artifacts)
|
||||
const existingArtifacts = await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project", (q) =>
|
||||
q.eq("projectId", result.id as unknown as Id<"projects">)
|
||||
const organization = await ctx.db
|
||||
.query("organizations")
|
||||
.withIndex("by_createdBy_and_kind", (q) =>
|
||||
q.eq("createdBy", args.userId).eq("kind", "personal")
|
||||
)
|
||||
.first();
|
||||
if (!existingArtifacts) {
|
||||
const now = Date.now();
|
||||
const artifacts = createInitialArtifacts();
|
||||
await Promise.all(
|
||||
artifacts.map(({ content, path }) =>
|
||||
ctx.db.insert("projectArtifacts", {
|
||||
content,
|
||||
createdAt: now,
|
||||
path,
|
||||
projectId: result.id as unknown as Id<"projects">,
|
||||
revision: 1,
|
||||
updatedAt: now,
|
||||
})
|
||||
)
|
||||
);
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: now,
|
||||
data: { host: result.source.host, url: result.source.url },
|
||||
kind: "project.connected",
|
||||
projectId: result.id as unknown as Id<"projects">,
|
||||
.unique();
|
||||
if (!organization) {
|
||||
throw new ConvexError("Organization not found");
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
const existing = await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_organizationId_and_normalizedSourceUrl", (q) =>
|
||||
q
|
||||
.eq("organizationId", organization._id)
|
||||
.eq("normalizedSourceUrl", args.source.normalizedUrl)
|
||||
)
|
||||
.unique();
|
||||
let projectId: Id<"projects">;
|
||||
if (existing) {
|
||||
projectId = existing._id;
|
||||
await ctx.db.patch(projectId, {
|
||||
defaultBranch: args.remote.defaultBranch,
|
||||
name: args.source.projectName,
|
||||
sourceHost: args.source.host,
|
||||
sourceUrl: args.source.url,
|
||||
repositoryPath: args.source.repositoryPath,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
const oldDocuments = await ctx.db
|
||||
.query("projectContextDocuments")
|
||||
.withIndex("by_projectId_and_path", (q) => q.eq("projectId", projectId))
|
||||
.collect();
|
||||
for (const document of oldDocuments) {
|
||||
await ctx.db.delete(document._id);
|
||||
}
|
||||
} else {
|
||||
projectId = await ctx.db.insert("projects", {
|
||||
createdAt: timestamp,
|
||||
defaultBranch: args.remote.defaultBranch,
|
||||
name: args.source.projectName,
|
||||
normalizedSourceUrl: args.source.normalizedUrl,
|
||||
organizationId: organization._id,
|
||||
repositoryPath: args.source.repositoryPath,
|
||||
sourceHost: args.source.host,
|
||||
sourceUrl: args.source.url,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
for (const document of args.remote.documents) {
|
||||
await ctx.db.insert("projectContextDocuments", {
|
||||
...document,
|
||||
createdAt: timestamp,
|
||||
origin: "repository",
|
||||
projectId,
|
||||
sourceUrl: args.source.url,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
const project = await ctx.db.get(projectId);
|
||||
if (!project) {
|
||||
throw new Error("Project could not be read after import");
|
||||
}
|
||||
const view = await toProjectView(ctx, project);
|
||||
return { ...view, source: view.sources[0]! };
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: put a context document
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const putContextDocument = internalMutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
projectId: v.id("projects"),
|
||||
write: v.union(
|
||||
v.object({
|
||||
_tag: v.literal("Repository"),
|
||||
content: v.string(),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
sourceUrl: v.string(),
|
||||
}),
|
||||
v.object({
|
||||
_tag: v.literal("PublicTextUrl"),
|
||||
content: v.string(),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
sourceUrl: v.string(),
|
||||
}),
|
||||
v.object({
|
||||
_tag: v.literal("UserText"),
|
||||
content: v.string(),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
origin: v.union(v.literal("paste"), v.literal("upload")),
|
||||
})
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const store = makeProjectMutationStore(ctx);
|
||||
return store.putContext(args.userId, args.projectId, args.write as never);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public query: list projects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const list = query({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<ProjectView[]> => {
|
||||
const { userId } = await requireCurrentOrganization(ctx);
|
||||
const store = makeProjectQueryStore(ctx);
|
||||
return store.listProjects(userId);
|
||||
const { organizationId } = await requireCurrentOrganization(ctx);
|
||||
const projects = await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_organizationId_and_createdAt", (q) =>
|
||||
q.eq("organizationId", organizationId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(50);
|
||||
return await Promise.all(
|
||||
projects.map((project) => toProjectView(ctx, project))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public query: get a project
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const get = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args): Promise<ProjectView | null> => {
|
||||
const { userId } = await requireCurrentOrganization(ctx);
|
||||
const store = makeProjectQueryStore(ctx);
|
||||
return store.getProject(userId, args.projectId);
|
||||
const { organizationId } = await requireCurrentOrganization(ctx);
|
||||
const project = await ctx.db.get(args.projectId);
|
||||
return project?.organizationId === organizationId
|
||||
? await toProjectView(ctx, project)
|
||||
: null;
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public action: import a supported public Git repository
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const importPublicGit = action({
|
||||
args: { repositoryUrl: v.string() },
|
||||
handler: async (ctx, args): Promise<ProjectImportOutcome> => {
|
||||
@@ -195,50 +185,17 @@ export const importPublicGit = action({
|
||||
const source = await Effect.runPromise(
|
||||
preparePublicGitSource(args.repositoryUrl)
|
||||
);
|
||||
const remote = await inspectPublicGit(source);
|
||||
const validatedRemote = await Effect.runPromise(
|
||||
decodePublicGitImportResult(remote)
|
||||
const remote = await Effect.runPromise(
|
||||
decodePublicGitImportResult(await inspectPublicGit(source))
|
||||
);
|
||||
return await ctx.runMutation(internal.projects.persistPublicGitImport, {
|
||||
remote: {
|
||||
defaultBranch: validatedRemote.defaultBranch,
|
||||
documents: validatedRemote.documents.map((document) => ({
|
||||
...document,
|
||||
})),
|
||||
warnings: validatedRemote.warnings.map((warning) => ({ ...warning })),
|
||||
defaultBranch: remote.defaultBranch,
|
||||
documents: remote.documents.map((document) => ({ ...document })),
|
||||
warnings: remote.warnings.map((warning) => ({ ...warning })),
|
||||
},
|
||||
source,
|
||||
userId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public mutation: put context text (paste/upload)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const putText = internalMutation({
|
||||
args: {
|
||||
projectId: v.id("projects"),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
content: v.string(),
|
||||
origin: v.union(v.literal("paste"), v.literal("upload")),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireCurrentOrganization(ctx);
|
||||
const store = makeProjectMutationStore(ctx);
|
||||
return store.putContext(userId, args.projectId, {
|
||||
_tag: "UserText" as const,
|
||||
content: args.content,
|
||||
kind: args.kind,
|
||||
origin: args.origin,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,192 +2,6 @@ import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export default defineSchema({
|
||||
daemonDefinitions: defineTable({
|
||||
daemonId: v.string(),
|
||||
name: v.string(),
|
||||
desiredState: v.union(v.literal("online"), v.literal("offline")),
|
||||
agentOsConfig: v.any(),
|
||||
configVersion: v.number(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
}).index("by_daemonId", ["daemonId"]),
|
||||
daemonPresence: defineTable({
|
||||
daemonId: v.string(),
|
||||
sessionId: v.string(),
|
||||
status: v.union(
|
||||
v.literal("online"),
|
||||
v.literal("draining"),
|
||||
v.literal("offline")
|
||||
),
|
||||
hostname: v.string(),
|
||||
platform: v.string(),
|
||||
architecture: v.string(),
|
||||
version: v.string(),
|
||||
startedAt: v.number(),
|
||||
lastHeartbeatAt: v.number(),
|
||||
})
|
||||
.index("by_daemonId", ["daemonId"])
|
||||
.index("by_sessionId", ["sessionId"]),
|
||||
daemonCommands: defineTable({
|
||||
daemonId: v.string(),
|
||||
method: v.string(),
|
||||
args: v.any(),
|
||||
actorKey: v.array(v.string()),
|
||||
status: v.union(
|
||||
v.literal("queued"),
|
||||
v.literal("claimed"),
|
||||
v.literal("running"),
|
||||
v.literal("succeeded"),
|
||||
v.literal("failed"),
|
||||
v.literal("cancelled")
|
||||
),
|
||||
attempts: v.number(),
|
||||
claimedBySessionId: v.optional(v.string()),
|
||||
leaseExpiresAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
startedAt: v.optional(v.number()),
|
||||
completedAt: v.optional(v.number()),
|
||||
result: v.optional(v.any()),
|
||||
error: v.optional(v.string()),
|
||||
})
|
||||
.index("by_daemonId_and_status", ["daemonId", "status"])
|
||||
.index("by_status_and_leaseExpiresAt", ["status", "leaseExpiresAt"]),
|
||||
daemonCommandEvents: defineTable({
|
||||
daemonId: v.string(),
|
||||
commandId: v.optional(v.id("daemonCommands")),
|
||||
kind: v.string(),
|
||||
data: v.any(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_daemonId_and_createdAt", ["daemonId", "createdAt"])
|
||||
.index("by_commandId_and_createdAt", ["commandId", "createdAt"]),
|
||||
projects: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
}).index("by_organization_and_createdAt", ["organizationId", "createdAt"]),
|
||||
projectSources: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
kind: v.literal("git"),
|
||||
url: v.string(),
|
||||
normalizedUrl: v.string(),
|
||||
host: v.string(),
|
||||
repositoryPath: v.string(),
|
||||
defaultBranch: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_project", ["projectId"])
|
||||
.index("by_organization_and_normalizedUrl", [
|
||||
"organizationId",
|
||||
"normalizedUrl",
|
||||
])
|
||||
.index("by_organization_and_createdAt", ["organizationId", "createdAt"]),
|
||||
projectContextDocuments: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
path: v.string(),
|
||||
content: v.string(),
|
||||
revision: v.number(),
|
||||
origin: v.union(
|
||||
v.literal("repository"),
|
||||
v.literal("paste"),
|
||||
v.literal("upload"),
|
||||
v.literal("public-url")
|
||||
),
|
||||
sourceUrl: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_project", ["projectId"])
|
||||
.index("by_project_and_kind", ["projectId", "kind"])
|
||||
.index("by_project_and_path", ["projectId", "path"]),
|
||||
projectArtifacts: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
path: v.string(),
|
||||
content: v.string(),
|
||||
revision: v.number(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_project", ["projectId"])
|
||||
.index("by_project_and_path", ["projectId", "path"]),
|
||||
projectIssues: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
number: v.number(),
|
||||
title: v.string(),
|
||||
body: v.string(),
|
||||
status: v.union(
|
||||
v.literal("open"),
|
||||
v.literal("queued"),
|
||||
v.literal("working"),
|
||||
v.literal("needs-input"),
|
||||
v.literal("completed"),
|
||||
v.literal("failed")
|
||||
),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_project_and_number", ["projectId", "number"])
|
||||
.index("by_project_and_status", ["projectId", "status"]),
|
||||
projectWorkRuns: defineTable({
|
||||
baseBranch: v.optional(v.string()),
|
||||
branchName: v.optional(v.string()),
|
||||
checkoutPath: v.optional(v.string()),
|
||||
projectId: v.id("projects"),
|
||||
issueId: v.id("projectIssues"),
|
||||
agentId: v.string(),
|
||||
daemonId: v.string(),
|
||||
actorKey: v.array(v.string()),
|
||||
sourceUrl: v.optional(v.string()),
|
||||
status: v.union(
|
||||
v.literal("ready"),
|
||||
v.literal("queued"),
|
||||
v.literal("working"),
|
||||
v.literal("needs-input"),
|
||||
v.literal("completed"),
|
||||
v.literal("failed")
|
||||
),
|
||||
summary: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
startedAt: v.optional(v.number()),
|
||||
completedAt: v.optional(v.number()),
|
||||
})
|
||||
.index("by_issue", ["issueId"])
|
||||
.index("by_project_and_createdAt", ["projectId", "createdAt"]),
|
||||
projectEvents: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
issueId: v.optional(v.id("projectIssues")),
|
||||
runId: v.optional(v.id("projectWorkRuns")),
|
||||
kind: v.string(),
|
||||
data: v.any(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_project_and_createdAt", ["projectId", "createdAt"])
|
||||
.index("by_issue_and_createdAt", ["issueId", "createdAt"]),
|
||||
todos: defineTable({
|
||||
text: v.string(),
|
||||
completed: v.boolean(),
|
||||
}),
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Organization tenancy. Organizations are hard tenant boundaries; deny by
|
||||
// default. The membership user ID is the Better Auth Convex identity
|
||||
// `tokenIdentifier`.
|
||||
// -----------------------------------------------------------------
|
||||
organizations: defineTable({
|
||||
name: v.string(),
|
||||
kind: v.union(v.literal("personal"), v.literal("team")),
|
||||
@@ -203,58 +17,86 @@ export default defineSchema({
|
||||
.index("by_userId", ["userId"])
|
||||
.index("by_organizationId", ["organizationId"])
|
||||
.index("by_organizationId_and_userId", ["organizationId", "userId"]),
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Normalized conversation message evidence. Each row is one user
|
||||
// message captured at the conversation ingress, before/with Flue
|
||||
// admission. The raw text is stored exactly as the user typed it
|
||||
// (never trimmed or rewritten). Organization scope is authoritative;
|
||||
// an agent instance may only observe its own organization's messages.
|
||||
// -----------------------------------------------------------------
|
||||
conversationMessages: defineTable({
|
||||
projects: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
conversationId: v.string(),
|
||||
messageId: v.string(),
|
||||
submissionId: v.optional(v.string()),
|
||||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
sourceUrl: v.string(),
|
||||
normalizedSourceUrl: v.string(),
|
||||
sourceHost: v.string(),
|
||||
repositoryPath: v.string(),
|
||||
defaultBranch: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_organizationId_and_createdAt", ["organizationId", "createdAt"])
|
||||
.index("by_organizationId_and_normalizedSourceUrl", [
|
||||
"organizationId",
|
||||
"normalizedSourceUrl",
|
||||
]),
|
||||
projectContextDocuments: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
path: v.string(),
|
||||
content: v.string(),
|
||||
origin: v.literal("repository"),
|
||||
sourceUrl: v.string(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
}).index("by_projectId_and_path", ["projectId", "path"]),
|
||||
conversations: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
createdAt: v.number(),
|
||||
}).index("by_organizationId", ["organizationId"]),
|
||||
conversationTurns: defineTable({
|
||||
conversationId: v.id("conversations"),
|
||||
clientRequestId: v.string(),
|
||||
role: v.literal("user"),
|
||||
rawText: v.string(),
|
||||
status: v.union(
|
||||
v.literal("admitting"),
|
||||
v.literal("admitted"),
|
||||
v.literal("queued"),
|
||||
v.literal("processing"),
|
||||
v.literal("completed"),
|
||||
v.literal("failed")
|
||||
),
|
||||
submissionId: v.optional(v.string()),
|
||||
error: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
completedAt: v.optional(v.number()),
|
||||
}).index("by_conversationId_and_clientRequestId", [
|
||||
"conversationId",
|
||||
"clientRequestId",
|
||||
]),
|
||||
conversationMessages: defineTable({
|
||||
conversationId: v.id("conversations"),
|
||||
turnId: v.id("conversationTurns"),
|
||||
role: v.union(v.literal("user"), v.literal("assistant")),
|
||||
content: v.string(),
|
||||
ordinal: v.number(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_organization_and_message", [
|
||||
"organizationId",
|
||||
"conversationId",
|
||||
"messageId",
|
||||
])
|
||||
.index("by_organization_and_request", [
|
||||
"organizationId",
|
||||
"clientRequestId",
|
||||
]),
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Product Signals. A Signal is the agent-produced interpretation of one
|
||||
// or more exact user messages into a structured problem statement. It is
|
||||
// organization-scoped, optionally project-scoped, and idempotent by
|
||||
// organization + ordered source-message selection (`sourceKey`). Raw text
|
||||
// is never accepted from the agent: it is copied server-side from the
|
||||
// normalized conversation messages into immutable source snapshots.
|
||||
// -----------------------------------------------------------------
|
||||
.index("by_conversationId_and_ordinal", ["conversationId", "ordinal"])
|
||||
.index("by_turnId_and_role", ["turnId", "role"]),
|
||||
conversationAttachments: defineTable({
|
||||
messageId: v.id("conversationMessages"),
|
||||
storageId: v.id("_storage"),
|
||||
filename: v.optional(v.string()),
|
||||
mimeType: v.string(),
|
||||
createdAt: v.number(),
|
||||
}).index("by_messageId", ["messageId"]),
|
||||
signals: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.optional(v.id("projects")),
|
||||
conversationId: v.string(),
|
||||
projectId: v.id("projects"),
|
||||
conversationId: v.id("conversations"),
|
||||
sourceKey: v.string(),
|
||||
problemStatement: v.object({
|
||||
title: v.string(),
|
||||
summary: v.string(),
|
||||
desiredOutcome: v.string(),
|
||||
constraints: v.array(v.string()),
|
||||
}),
|
||||
title: v.string(),
|
||||
summary: v.string(),
|
||||
desiredOutcome: v.string(),
|
||||
processedByAgentName: v.string(),
|
||||
processedByAgentInstanceId: v.string(),
|
||||
createdAt: v.number(),
|
||||
@@ -262,26 +104,20 @@ export default defineSchema({
|
||||
.index("by_organization_and_createdAt", ["organizationId", "createdAt"])
|
||||
.index("by_organization_and_sourceKey", ["organizationId", "sourceKey"])
|
||||
.index("by_project_and_createdAt", ["projectId", "createdAt"]),
|
||||
|
||||
// Immutable ordered source snapshots copied from `conversationMessages`.
|
||||
// `ordinal` is the zero-based position in the agent's ordered selection.
|
||||
signalConstraints: defineTable({
|
||||
signalId: v.id("signals"),
|
||||
ordinal: v.number(),
|
||||
value: v.string(),
|
||||
}).index("by_signalId_and_ordinal", ["signalId", "ordinal"]),
|
||||
signalSources: defineTable({
|
||||
signalId: v.id("signals"),
|
||||
organizationId: v.id("organizations"),
|
||||
conversationId: v.string(),
|
||||
messageId: v.string(),
|
||||
messageId: v.id("conversationMessages"),
|
||||
ordinal: v.number(),
|
||||
rawTextSnapshot: v.string(),
|
||||
sourceCreatedAt: v.number(),
|
||||
submissionId: v.optional(v.string()),
|
||||
})
|
||||
.index("by_signal_and_ordinal", ["signalId", "ordinal"])
|
||||
.index("by_organization_and_message", [
|
||||
"organizationId",
|
||||
"conversationId",
|
||||
"messageId",
|
||||
]),
|
||||
|
||||
.index("by_signalId_and_ordinal", ["signalId", "ordinal"])
|
||||
.index("by_messageId", ["messageId"]),
|
||||
works: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
@@ -295,8 +131,6 @@ export default defineSchema({
|
||||
.index("by_organization_and_createdAt", ["organizationId", "createdAt"]),
|
||||
|
||||
signalWorkAttachments: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
signalId: v.id("signals"),
|
||||
workId: v.id("works"),
|
||||
createdAt: v.number(),
|
||||
@@ -306,34 +140,15 @@ export default defineSchema({
|
||||
.index("by_signal_and_work", ["signalId", "workId"]),
|
||||
|
||||
workEvents: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
workId: v.id("works"),
|
||||
signalId: v.id("signals"),
|
||||
kind: v.union(v.literal("work.proposed"), v.literal("signal.attached")),
|
||||
idempotencyKey: v.string(),
|
||||
data: v.any(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_work_and_createdAt", ["workId", "createdAt"])
|
||||
.index("by_work_and_idempotencyKey", ["workId", "idempotencyKey"]),
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Signal-to-issue attachments. A proper relation that links one Signal
|
||||
// to one ProjectIssue, idempotent by (signalId, issueId). Multiple
|
||||
// Signals may attach to the same ProjectIssue. The relation is scoped
|
||||
// by organization and project to prevent cross-tenant leakage.
|
||||
// -----------------------------------------------------------------
|
||||
signalIssueAttachments: defineTable({
|
||||
signalId: v.id("signals"),
|
||||
issueId: v.id("projectIssues"),
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_signal", ["signalId"])
|
||||
.index("by_issue", ["issueId"])
|
||||
.index("by_signal_and_issue", ["signalId", "issueId"]),
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Flue persistence stores (schema/format version 4).
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { env } from "@code/env/convex";
|
||||
import { type TestConvex, convexTest } from "convex-test";
|
||||
import { convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import schema from "./schema";
|
||||
|
||||
declare global {
|
||||
@@ -16,552 +14,74 @@ declare global {
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
|
||||
const TOKEN = env.FLUE_DB_TOKEN;
|
||||
const BAD_TOKEN = "not-the-right-token";
|
||||
|
||||
const ID_A = "https://convex.test|user-a";
|
||||
const identityA = { tokenIdentifier: ID_A };
|
||||
|
||||
const newTest = () => convexTest({ schema, modules });
|
||||
|
||||
const ensureOrg = async (t: TestConvex<typeof schema>) => {
|
||||
const org = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
return org._id as Id<"organizations">;
|
||||
};
|
||||
|
||||
const problemStatement = {
|
||||
title: "Deploy fails on staging",
|
||||
summary: "The staging deploy command exits with a DNS resolution error.",
|
||||
desiredOutcome: "Staging deploys successfully without DNS errors.",
|
||||
constraints: ["Must not change the production pipeline"],
|
||||
};
|
||||
|
||||
const seedMessage = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
orgId: Id<"organizations">,
|
||||
clientRequestId: string,
|
||||
rawText: string
|
||||
): Promise<string> => {
|
||||
const begun = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId,
|
||||
organizationId: orgId,
|
||||
rawText,
|
||||
describe("signalRouting", () => {
|
||||
test("creates one normalized Signal from admitted conversation evidence", async () => {
|
||||
const t = convexTest({ modules, schema });
|
||||
const organization = await t.run(async (ctx) => {
|
||||
const organizationId = await ctx.db.insert("organizations", {
|
||||
createdAt: 1,
|
||||
createdBy: "user",
|
||||
kind: "personal",
|
||||
name: "Personal",
|
||||
});
|
||||
const projectId = await ctx.db.insert("projects", {
|
||||
createdAt: 1,
|
||||
name: "Zopu",
|
||||
normalizedSourceUrl: "https://example.com/zopu",
|
||||
organizationId,
|
||||
repositoryPath: "puter/zopu",
|
||||
sourceHost: "example.com",
|
||||
sourceUrl: "https://example.com/zopu",
|
||||
updatedAt: 1,
|
||||
});
|
||||
const conversationId = await ctx.db.insert("conversations", {
|
||||
createdAt: 1,
|
||||
organizationId,
|
||||
});
|
||||
const turnId = await ctx.db.insert("conversationTurns", {
|
||||
clientRequestId: "request-1",
|
||||
conversationId,
|
||||
createdAt: 1,
|
||||
status: "processing",
|
||||
});
|
||||
const messageId = await ctx.db.insert("conversationMessages", {
|
||||
content: "Fix the deploy",
|
||||
conversationId,
|
||||
createdAt: 1,
|
||||
ordinal: 0,
|
||||
role: "user",
|
||||
turnId,
|
||||
});
|
||||
return { messageId, organizationId, projectId };
|
||||
});
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId,
|
||||
organizationId: orgId,
|
||||
submissionId: `sub-${clientRequestId}`,
|
||||
});
|
||||
return begun.messageId;
|
||||
};
|
||||
|
||||
const createProject = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
ownerId: string,
|
||||
repoName: string
|
||||
): Promise<Id<"projects">> => {
|
||||
const ownerIdentity = { tokenIdentifier: ownerId };
|
||||
await t
|
||||
.withIdentity(ownerIdentity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization);
|
||||
const outcome = await t
|
||||
.withIdentity(ownerIdentity)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
userId: ownerId,
|
||||
source: {
|
||||
host: "github.com",
|
||||
projectName: repoName,
|
||||
repositoryPath: `owner/${repoName}`,
|
||||
normalizedUrl: `https://github.com/owner/${repoName}`,
|
||||
url: `https://github.com/owner/${repoName}`,
|
||||
},
|
||||
remote: {
|
||||
defaultBranch: "main",
|
||||
documents: [
|
||||
{
|
||||
kind: "readme" as const,
|
||||
path: "README.md",
|
||||
content: `# ${repoName}\n\nTest.\n`,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
const result = await t.mutation(api.signalRouting.createSignal, {
|
||||
messageIds: [String(organization.messageId)],
|
||||
organizationId: organization.organizationId,
|
||||
problemStatement: {
|
||||
constraints: ["Keep production stable"],
|
||||
desiredOutcome: "Deploy succeeds",
|
||||
summary: "The deploy is failing",
|
||||
title: "Fix deploy",
|
||||
},
|
||||
processedByAgentInstanceId: String(organization.organizationId),
|
||||
projectId: organization.projectId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
return outcome.id as unknown as Id<"projects">;
|
||||
};
|
||||
|
||||
// Create a project-scoped signal via the routing backend.
|
||||
const createRoutingSignal = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
orgId: Id<"organizations">,
|
||||
projectId: Id<"projects">,
|
||||
messageIds: string[]
|
||||
): Promise<Id<"signals">> => {
|
||||
const result = await t.mutation(api.signalRouting.createSignal, {
|
||||
messageIds,
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedByAgentInstanceId: orgId,
|
||||
projectId,
|
||||
token: TOKEN,
|
||||
});
|
||||
return result.signalId as Id<"signals">;
|
||||
};
|
||||
|
||||
describe("signalRouting authentication", () => {
|
||||
test("invalid token is rejected on every function", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "auth-test");
|
||||
const messageId = await seedMessage(t, orgId, "req-auth", "test message");
|
||||
|
||||
await expect(
|
||||
t.mutation(api.signalRouting.createSignal, {
|
||||
messageIds: [messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedByAgentInstanceId: orgId,
|
||||
projectId,
|
||||
token: BAD_TOKEN,
|
||||
})
|
||||
).rejects.toThrow(/Invalid agent control token/u);
|
||||
|
||||
await expect(
|
||||
t.query(api.signalRouting.listEvidence, {
|
||||
organizationId: orgId,
|
||||
token: BAD_TOKEN,
|
||||
})
|
||||
).rejects.toThrow(/Invalid agent control token/u);
|
||||
|
||||
await expect(
|
||||
t.query(api.signalRouting.listActiveIssues, {
|
||||
organizationId: orgId,
|
||||
projectId,
|
||||
token: BAD_TOKEN,
|
||||
})
|
||||
).rejects.toThrow(/Invalid agent control token/u);
|
||||
|
||||
await expect(
|
||||
t.query(api.signalRouting.listProjects, {
|
||||
organizationId: orgId,
|
||||
token: BAD_TOKEN,
|
||||
})
|
||||
).rejects.toThrow(/Invalid agent control token/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signalRouting create and route", () => {
|
||||
test("create signal, list evidence, and list signals", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "route-test");
|
||||
const messageId = await seedMessage(
|
||||
t,
|
||||
orgId,
|
||||
"req-route",
|
||||
"The work-unit card should expose the generated PR."
|
||||
);
|
||||
|
||||
// Evidence appears before signal creation.
|
||||
const evidenceBefore = await t.query(api.signalRouting.listEvidence, {
|
||||
organizationId: orgId,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(evidenceBefore).toHaveLength(1);
|
||||
expect(evidenceBefore[0]?.rawText).toBe(
|
||||
"The work-unit card should expose the generated PR."
|
||||
);
|
||||
|
||||
// Create the signal.
|
||||
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
||||
messageId,
|
||||
]);
|
||||
|
||||
// Evidence is consumed after signal creation.
|
||||
const evidenceAfter = await t.query(api.signalRouting.listEvidence, {
|
||||
organizationId: orgId,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(evidenceAfter).toHaveLength(0);
|
||||
|
||||
// Signal appears in the project list.
|
||||
const signals = await t.query(api.signalRouting.listSignals, {
|
||||
organizationId: orgId,
|
||||
projectId,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0]?._id).toBe(signalId);
|
||||
expect(signals[0]?.problemStatement.title).toBe("Deploy fails on staging");
|
||||
});
|
||||
|
||||
test("attach signal to existing issue", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "attach-test");
|
||||
const messageId = await seedMessage(t, orgId, "req-attach", "problem");
|
||||
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
||||
messageId,
|
||||
]);
|
||||
|
||||
// Create an issue via the existing projectIssues backend.
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
body: "Existing issue body that is long enough.",
|
||||
projectId,
|
||||
title: "Existing UI issue",
|
||||
});
|
||||
|
||||
const result = await t.mutation(api.signalRouting.attachSignalToIssue, {
|
||||
issueId: issueId as string,
|
||||
organizationId: orgId,
|
||||
signalId: signalId as string,
|
||||
token: TOKEN,
|
||||
});
|
||||
|
||||
expect(result.alreadyAttached).toBe(false);
|
||||
|
||||
// List active issues includes the attached issue.
|
||||
const issues = await t.query(api.signalRouting.listActiveIssues, {
|
||||
organizationId: orgId,
|
||||
projectId,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]?._id).toBe(issueId);
|
||||
});
|
||||
|
||||
test("create issue from signal (attach-vs-create fallback)", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "create-test");
|
||||
const messageId = await seedMessage(t, orgId, "req-create", "new problem");
|
||||
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
||||
messageId,
|
||||
]);
|
||||
|
||||
const result = await t.mutation(api.signalRouting.createIssueFromSignal, {
|
||||
organizationId: orgId,
|
||||
signalId: signalId as string,
|
||||
token: TOKEN,
|
||||
});
|
||||
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.projectId).toBe(projectId);
|
||||
|
||||
// The issue exists and is open.
|
||||
const issues = await t.query(api.signalRouting.listActiveIssues, {
|
||||
organizationId: orgId,
|
||||
projectId,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]?.title).toBe("Deploy fails on staging");
|
||||
});
|
||||
});
|
||||
|
||||
describe("signalRouting idempotency", () => {
|
||||
test("duplicate attach retry returns existing without duplicate event", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "idem-attach");
|
||||
const messageId = await seedMessage(t, orgId, "req-idem-att", "problem");
|
||||
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
||||
messageId,
|
||||
]);
|
||||
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
body: "Existing issue body that is long enough for validation.",
|
||||
projectId,
|
||||
title: "Pre-existing issue",
|
||||
});
|
||||
|
||||
// First attach.
|
||||
const first = await t.mutation(api.signalRouting.attachSignalToIssue, {
|
||||
issueId: issueId as string,
|
||||
organizationId: orgId,
|
||||
signalId: signalId as string,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(first.alreadyAttached).toBe(false);
|
||||
|
||||
// Retry the same attach.
|
||||
const second = await t.mutation(api.signalRouting.attachSignalToIssue, {
|
||||
issueId: issueId as string,
|
||||
organizationId: orgId,
|
||||
signalId: signalId as string,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(second.alreadyAttached).toBe(true);
|
||||
expect(second.attachmentId).toBe(first.attachmentId);
|
||||
|
||||
// Verify only one signal.attached event was emitted (not duplicated).
|
||||
const events = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.projectIssues.events, { projectId });
|
||||
const attached = events.filter((e: { kind: string }) =>
|
||||
e.kind.includes("signal.attached")
|
||||
);
|
||||
expect(attached).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("duplicate create-from-signal retry returns existing issue", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "idem-create");
|
||||
const messageId = await seedMessage(t, orgId, "req-idem-crt", "problem");
|
||||
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
||||
messageId,
|
||||
]);
|
||||
|
||||
// First create.
|
||||
const first = await t.mutation(api.signalRouting.createIssueFromSignal, {
|
||||
organizationId: orgId,
|
||||
signalId: signalId as string,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(first.created).toBe(true);
|
||||
|
||||
// Retry — should return existing, not create a new issue.
|
||||
const second = await t.mutation(api.signalRouting.createIssueFromSignal, {
|
||||
organizationId: orgId,
|
||||
signalId: signalId as string,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.issueId).toBe(first.issueId);
|
||||
|
||||
// Only one issue exists.
|
||||
const issues = await t.query(api.signalRouting.listActiveIssues, {
|
||||
organizationId: orgId,
|
||||
projectId,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(issues).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("duplicate signal creation is idempotent (sourceKey)", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "idem-signal");
|
||||
const messageId = await seedMessage(t, orgId, "req-idem-sig", "test");
|
||||
|
||||
const first = await t.mutation(api.signalRouting.createSignal, {
|
||||
messageIds: [messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedByAgentInstanceId: orgId,
|
||||
projectId,
|
||||
token: TOKEN,
|
||||
});
|
||||
const second = await t.mutation(api.signalRouting.createSignal, {
|
||||
messageIds: [messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedByAgentInstanceId: orgId,
|
||||
projectId,
|
||||
token: TOKEN,
|
||||
});
|
||||
|
||||
expect(second.signalId).toBe(first.signalId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signalRouting cross-project rejection", () => {
|
||||
test("attach rejects when signal and issue are in different projects", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectA = await createProject(t, ID_A, "cross-a");
|
||||
const projectB = await createProject(t, ID_A, "cross-b");
|
||||
|
||||
const messageId = await seedMessage(t, orgId, "req-cross", "problem");
|
||||
// Signal is scoped to project A.
|
||||
const signalId = await createRoutingSignal(t, orgId, projectA, [messageId]);
|
||||
|
||||
// Issue is in project B.
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
body: "Issue in project B with enough body text.",
|
||||
projectId: projectB,
|
||||
title: "Project B issue",
|
||||
});
|
||||
|
||||
await expect(
|
||||
t.mutation(api.signalRouting.attachSignalToIssue, {
|
||||
issueId: issueId as string,
|
||||
organizationId: orgId,
|
||||
signalId: signalId as string,
|
||||
token: TOKEN,
|
||||
})
|
||||
).rejects.toThrow(/must belong to the same project/u);
|
||||
});
|
||||
|
||||
test("attach rejects org-scoped signal (no projectId)", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "org-scope-test");
|
||||
|
||||
const messageId = await seedMessage(t, orgId, "req-org", "problem");
|
||||
|
||||
// Create an org-scoped signal (no projectId).
|
||||
const signalId = await t.mutation(api.signalRouting.createSignal, {
|
||||
messageIds: [messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedByAgentInstanceId: orgId,
|
||||
token: TOKEN,
|
||||
});
|
||||
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
body: "Issue body that is long enough for the validator.",
|
||||
projectId,
|
||||
title: "Some issue",
|
||||
});
|
||||
|
||||
await expect(
|
||||
t.mutation(api.signalRouting.attachSignalToIssue, {
|
||||
issueId: issueId as string,
|
||||
organizationId: orgId,
|
||||
signalId: signalId.signalId as string,
|
||||
token: TOKEN,
|
||||
})
|
||||
).rejects.toThrow(/not project-scoped/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signalRouting provenance", () => {
|
||||
test("exact message raw text is preserved through the routing loop", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "prov-test");
|
||||
const rawText = " The work-unit card should expose the generated PR. ";
|
||||
const messageId = await seedMessage(t, orgId, "req-prov", rawText);
|
||||
|
||||
// Evidence shows exact raw text.
|
||||
const evidence = await t.query(api.signalRouting.listEvidence, {
|
||||
organizationId: orgId,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(evidence[0]?.rawText).toBe(rawText);
|
||||
|
||||
// Signal creation preserves exact snapshot.
|
||||
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
||||
messageId,
|
||||
]);
|
||||
|
||||
// Verify via the existing signals.get that the snapshot is exact.
|
||||
const composed = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.get, { signalId });
|
||||
expect(composed?.sources[0]?.rawTextSnapshot).toBe(rawText);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signalRouting begin issue", () => {
|
||||
test("begin transitions an open issue to queued", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "begin-test");
|
||||
const messageId = await seedMessage(t, orgId, "req-begin", "start this");
|
||||
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
||||
messageId,
|
||||
]);
|
||||
|
||||
const result = await t.mutation(api.signalRouting.createIssueFromSignal, {
|
||||
organizationId: orgId,
|
||||
signalId: signalId as string,
|
||||
token: TOKEN,
|
||||
});
|
||||
|
||||
const status = await t.mutation(api.signalRouting.beginIssue, {
|
||||
issueId: result.issueId as string,
|
||||
organizationId: orgId,
|
||||
token: TOKEN,
|
||||
});
|
||||
|
||||
expect(status).toEqual({
|
||||
dispatchRequired: true,
|
||||
projectId,
|
||||
status: "queued",
|
||||
});
|
||||
|
||||
const repeated = await t.mutation(api.signalRouting.beginIssue, {
|
||||
issueId: result.issueId as string,
|
||||
organizationId: orgId,
|
||||
token: TOKEN,
|
||||
});
|
||||
expect(repeated).toEqual({
|
||||
dispatchRequired: false,
|
||||
projectId,
|
||||
status: "queued",
|
||||
});
|
||||
});
|
||||
|
||||
test("begin rejects invalid token", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "begin-auth");
|
||||
const messageId = await seedMessage(t, orgId, "req-begin-auth", "msg");
|
||||
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
||||
messageId,
|
||||
]);
|
||||
|
||||
const result = await t.mutation(api.signalRouting.createIssueFromSignal, {
|
||||
organizationId: orgId,
|
||||
signalId: signalId as string,
|
||||
token: TOKEN,
|
||||
});
|
||||
|
||||
await expect(
|
||||
t.mutation(api.signalRouting.beginIssue, {
|
||||
issueId: result.issueId as string,
|
||||
organizationId: orgId,
|
||||
token: BAD_TOKEN,
|
||||
})
|
||||
).rejects.toThrow(/Invalid agent control token/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signalRouting project context", () => {
|
||||
test("get project context returns project and documents", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "ctx-test");
|
||||
|
||||
const context = await t.query(api.signalRouting.getProjectContext, {
|
||||
organizationId: orgId,
|
||||
projectId,
|
||||
token: TOKEN,
|
||||
});
|
||||
|
||||
expect(context.project._id).toBe(projectId);
|
||||
expect(context.project.name).toBe("ctx-test");
|
||||
expect(context.contextDocuments.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("list projects returns organization projects", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t);
|
||||
const projectId = await createProject(t, ID_A, "list-proj-test");
|
||||
|
||||
const projects = await t.query(api.signalRouting.listProjects, {
|
||||
organizationId: orgId,
|
||||
token: TOKEN,
|
||||
});
|
||||
|
||||
expect(projects).toHaveLength(1);
|
||||
expect(projects[0]?._id).toBe(projectId);
|
||||
const stored = await t.run(async (ctx) => ({
|
||||
constraints: await ctx.db.query("signalConstraints").collect(),
|
||||
signal: await ctx.db.get(result.signalId),
|
||||
sources: await ctx.db.query("signalSources").collect(),
|
||||
}));
|
||||
expect(stored.signal).toMatchObject({
|
||||
desiredOutcome: "Deploy succeeds",
|
||||
summary: "The deploy is failing",
|
||||
title: "Fix deploy",
|
||||
});
|
||||
expect(stored.constraints.map((row) => row.value)).toEqual([
|
||||
"Keep production stable",
|
||||
]);
|
||||
expect(stored.sources[0]?.rawTextSnapshot).toBe("Fix the deploy");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,248 +1,115 @@
|
||||
import { env } from "@code/env/convex";
|
||||
import {
|
||||
projectIssueDraftFromSignal,
|
||||
queueProjectIssue,
|
||||
type ProjectIssueDraft,
|
||||
} from "@code/primitives/project-issue";
|
||||
import { composeSignal, SignalValidationError } from "@code/primitives/signal";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import {
|
||||
mutation,
|
||||
type MutationCtx,
|
||||
query,
|
||||
type QueryCtx,
|
||||
} from "./_generated/server";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent token guard (service-level auth for Zopu tools).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const requireAgent = (token: string) => {
|
||||
const requireAgent = (token: string): void => {
|
||||
if (token !== env.FLUE_DB_TOKEN) {
|
||||
throw new ConvexError("Invalid agent control token");
|
||||
}
|
||||
};
|
||||
|
||||
const PROBLEM_STATEMENT = v.object({
|
||||
title: v.string(),
|
||||
summary: v.string(),
|
||||
desiredOutcome: v.string(),
|
||||
constraints: v.array(v.string()),
|
||||
export const listProjects = query({
|
||||
args: { organizationId: v.id("organizations"), token: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const projects = await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_organizationId_and_createdAt", (q) =>
|
||||
q.eq("organizationId", args.organizationId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(50);
|
||||
return projects.map((project) => ({
|
||||
_id: project._id,
|
||||
description: project.description ?? null,
|
||||
name: project.name,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EvidenceMessage {
|
||||
readonly messageId: string;
|
||||
readonly rawText: string;
|
||||
readonly createdAt: number;
|
||||
readonly submissionId: string | null;
|
||||
}
|
||||
|
||||
interface ActiveIssueView {
|
||||
readonly _id: Id<"projectIssues">;
|
||||
readonly number: number;
|
||||
readonly title: string;
|
||||
readonly body: string;
|
||||
readonly status: string;
|
||||
readonly projectId: Id<"projects">;
|
||||
readonly updatedAt: number;
|
||||
}
|
||||
|
||||
interface ProjectSummaryView {
|
||||
readonly _id: Id<"projects">;
|
||||
readonly name: string;
|
||||
readonly description: string | null;
|
||||
readonly organizationId: Id<"organizations">;
|
||||
}
|
||||
|
||||
interface ContextDocumentView {
|
||||
readonly kind: string;
|
||||
readonly path: string;
|
||||
readonly content: string;
|
||||
readonly revision: number;
|
||||
}
|
||||
|
||||
interface SignalSummaryView {
|
||||
readonly _id: Id<"signals">;
|
||||
readonly problemStatement: {
|
||||
readonly title: string;
|
||||
readonly summary: string;
|
||||
readonly desiredOutcome: string;
|
||||
readonly constraints: readonly string[];
|
||||
};
|
||||
readonly projectId: Id<"projects"> | null;
|
||||
readonly createdAt: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Organization + project authorization (agent-gated, no user identity).
|
||||
// The agent instance id IS the organization id; the tool never supplies
|
||||
// tenancy. We verify the organization exists and the project belongs to it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const authorizeProjectInOrg = async (
|
||||
ctx: MutationCtx | QueryCtx,
|
||||
organizationId: Id<"organizations">,
|
||||
projectId: Id<"projects">
|
||||
): Promise<Doc<"projects">> => {
|
||||
const project = await ctx.db.get(projectId);
|
||||
if (!project) {
|
||||
throw new ConvexError("Project not found");
|
||||
}
|
||||
if (project.organizationId !== organizationId) {
|
||||
throw new ConvexError("Project does not belong to the target organization");
|
||||
}
|
||||
return project;
|
||||
};
|
||||
|
||||
const authorizeSignalInOrg = async (
|
||||
ctx: MutationCtx | QueryCtx,
|
||||
organizationId: Id<"organizations">,
|
||||
signalId: Id<"signals">
|
||||
): Promise<Doc<"signals">> => {
|
||||
const signal = await ctx.db.get(signalId);
|
||||
if (!signal) {
|
||||
throw new ConvexError("Signal not found");
|
||||
}
|
||||
if (signal.organizationId !== organizationId) {
|
||||
throw new ConvexError("Signal does not belong to the target organization");
|
||||
}
|
||||
return signal;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. List admitted user messages not yet consumed by a Signal.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const listEvidence = query({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<EvidenceMessage[]> => {
|
||||
args: { organizationId: v.id("organizations"), token: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const conversationId = args.organizationId;
|
||||
|
||||
const admitted = await ctx.db
|
||||
const conversation = await ctx.db
|
||||
.query("conversations")
|
||||
.withIndex("by_organizationId", (q) =>
|
||||
q.eq("organizationId", args.organizationId)
|
||||
)
|
||||
.unique();
|
||||
if (!conversation) {
|
||||
return [];
|
||||
}
|
||||
const messages = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_organization_and_message", (q) =>
|
||||
q
|
||||
.eq("organizationId", args.organizationId)
|
||||
.eq("conversationId", conversationId)
|
||||
.withIndex("by_conversationId_and_ordinal", (q) =>
|
||||
q.eq("conversationId", conversation._id)
|
||||
)
|
||||
.order("desc")
|
||||
.take(100);
|
||||
|
||||
const candidates = admitted.filter(
|
||||
(message) => message.role === "user" && message.status === "admitted"
|
||||
);
|
||||
|
||||
const unused: EvidenceMessage[] = [];
|
||||
for (const message of candidates) {
|
||||
const evidence: {
|
||||
messageId: string;
|
||||
rawText: string;
|
||||
createdAt: number;
|
||||
submissionId: string | null;
|
||||
}[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.role !== "user") {
|
||||
continue;
|
||||
}
|
||||
const turn = await ctx.db.get(message.turnId);
|
||||
if (!turn || !["processing", "completed"].includes(turn.status)) {
|
||||
continue;
|
||||
}
|
||||
const consumed = await ctx.db
|
||||
.query("signalSources")
|
||||
.withIndex("by_organization_and_message", (q) =>
|
||||
q
|
||||
.eq("organizationId", args.organizationId)
|
||||
.eq("conversationId", conversationId)
|
||||
.eq("messageId", message.messageId)
|
||||
)
|
||||
.withIndex("by_messageId", (q) => q.eq("messageId", message._id))
|
||||
.first();
|
||||
if (!consumed) {
|
||||
unused.push({
|
||||
evidence.push({
|
||||
createdAt: message.createdAt,
|
||||
messageId: message.messageId,
|
||||
rawText: message.rawText,
|
||||
submissionId: message.submissionId ?? null,
|
||||
messageId: String(message._id),
|
||||
rawText: message.content,
|
||||
submissionId: turn.submissionId ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return unused;
|
||||
return evidence;
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Create a Signal from selected messages + structured problem statement.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const buildSourceKey = (
|
||||
organizationId: Id<"organizations">,
|
||||
conversationId: string,
|
||||
messageIds: readonly string[]
|
||||
): string => JSON.stringify([organizationId, conversationId, ...messageIds]);
|
||||
|
||||
const resolveSources = async (
|
||||
ctx: MutationCtx,
|
||||
organizationId: Id<"organizations">,
|
||||
conversationId: string,
|
||||
messageIds: readonly string[]
|
||||
): Promise<Doc<"conversationMessages">[]> => {
|
||||
if (messageIds.length === 0) {
|
||||
throw new ConvexError("At least one source message is required");
|
||||
}
|
||||
if (new Set(messageIds).size !== messageIds.length) {
|
||||
throw new ConvexError("Source message ids must be unique");
|
||||
}
|
||||
const resolved: Doc<"conversationMessages">[] = [];
|
||||
for (const messageId of messageIds) {
|
||||
const doc = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_organization_and_message", (q) =>
|
||||
q
|
||||
.eq("organizationId", organizationId)
|
||||
.eq("conversationId", conversationId)
|
||||
.eq("messageId", messageId)
|
||||
)
|
||||
.unique();
|
||||
if (!doc) {
|
||||
throw new ConvexError(`Source message not found: ${messageId}`);
|
||||
}
|
||||
if (doc.role !== "user") {
|
||||
throw new ConvexError(
|
||||
`Source message is not a user message: ${messageId}`
|
||||
);
|
||||
}
|
||||
if (doc.status !== "admitted") {
|
||||
throw new ConvexError(`Source message is not admitted: ${messageId}`);
|
||||
}
|
||||
resolved.push(doc);
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
export const createSignal = mutation({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.optional(v.id("projects")),
|
||||
projectId: v.id("projects"),
|
||||
messageIds: v.array(v.string()),
|
||||
problemStatement: PROBLEM_STATEMENT,
|
||||
problemStatement: v.object({
|
||||
title: v.string(),
|
||||
summary: v.string(),
|
||||
desiredOutcome: v.string(),
|
||||
constraints: v.array(v.string()),
|
||||
}),
|
||||
processedByAgentInstanceId: v.string(),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<{ signalId: Id<"signals"> }> => {
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
if (args.projectId) {
|
||||
await authorizeProjectInOrg(ctx, args.organizationId, args.projectId);
|
||||
const project = await ctx.db.get(args.projectId);
|
||||
if (!project || project.organizationId !== args.organizationId) {
|
||||
throw new ConvexError("Project not found");
|
||||
}
|
||||
|
||||
const conversationId = args.organizationId;
|
||||
if (conversationId !== args.organizationId) {
|
||||
throw new ConvexError("Conversation does not belong to organization");
|
||||
const conversation = await ctx.db
|
||||
.query("conversations")
|
||||
.withIndex("by_organizationId", (q) =>
|
||||
q.eq("organizationId", args.organizationId)
|
||||
)
|
||||
.unique();
|
||||
if (!conversation || args.messageIds.length === 0) {
|
||||
throw new ConvexError("Signal evidence is required");
|
||||
}
|
||||
|
||||
const sourceKey = buildSourceKey(
|
||||
args.organizationId,
|
||||
conversationId,
|
||||
args.messageIds
|
||||
);
|
||||
|
||||
const sourceKey = JSON.stringify(args.messageIds);
|
||||
const existing = await ctx.db
|
||||
.query("signals")
|
||||
.withIndex("by_organization_and_sourceKey", (q) =>
|
||||
@@ -253,518 +120,54 @@ export const createSignal = mutation({
|
||||
return { signalId: existing._id };
|
||||
}
|
||||
|
||||
const sources = await resolveSources(
|
||||
ctx,
|
||||
args.organizationId,
|
||||
conversationId,
|
||||
args.messageIds
|
||||
);
|
||||
|
||||
const now = Date.now();
|
||||
const prospectiveId = crypto.randomUUID();
|
||||
const scope =
|
||||
args.projectId === undefined
|
||||
? { _tag: "Organization" as const, organizationId: args.organizationId }
|
||||
: {
|
||||
_tag: "Project" as const,
|
||||
organizationId: args.organizationId,
|
||||
projectId: args.projectId,
|
||||
};
|
||||
|
||||
const signal = await Effect.runPromise(
|
||||
composeSignal({
|
||||
createdAt: now,
|
||||
id: prospectiveId,
|
||||
problemStatement: args.problemStatement,
|
||||
processedBy: {
|
||||
agentInstanceId: args.processedByAgentInstanceId,
|
||||
agentName: "zopu",
|
||||
},
|
||||
scope,
|
||||
sourceMessages: sources.map((doc) => ({
|
||||
conversationId: doc.conversationId,
|
||||
createdAt: doc.createdAt,
|
||||
messageId: doc.messageId,
|
||||
rawText: doc.rawText,
|
||||
})),
|
||||
})
|
||||
).catch((error: unknown) => {
|
||||
if (error instanceof SignalValidationError) {
|
||||
throw new ConvexError(`Invalid signal: ${error.message}`);
|
||||
const messages = [];
|
||||
for (const messageId of args.messageIds) {
|
||||
const message = await ctx.db.get(messageId as Id<"conversationMessages">);
|
||||
if (
|
||||
!message ||
|
||||
message.conversationId !== conversation._id ||
|
||||
message.role !== "user"
|
||||
) {
|
||||
throw new ConvexError(`Source message not found: ${messageId}`);
|
||||
}
|
||||
throw new ConvexError(
|
||||
`Invalid signal: ${error instanceof Error ? error.message : "unknown"}`
|
||||
);
|
||||
});
|
||||
const turn = await ctx.db.get(message.turnId);
|
||||
if (!turn || !["processing", "completed"].includes(turn.status)) {
|
||||
throw new ConvexError(`Source message is not admitted: ${messageId}`);
|
||||
}
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
const signalId = await ctx.db.insert("signals", {
|
||||
conversationId,
|
||||
createdAt: now,
|
||||
conversationId: conversation._id,
|
||||
createdAt: Date.now(),
|
||||
desiredOutcome: args.problemStatement.desiredOutcome,
|
||||
organizationId: args.organizationId,
|
||||
problemStatement: {
|
||||
constraints: [...signal.problemStatement.constraints],
|
||||
desiredOutcome: signal.problemStatement.desiredOutcome,
|
||||
summary: signal.problemStatement.summary,
|
||||
title: signal.problemStatement.title,
|
||||
},
|
||||
processedByAgentInstanceId: args.processedByAgentInstanceId,
|
||||
processedByAgentName: "zopu",
|
||||
...(args.projectId === undefined ? {} : { projectId: args.projectId }),
|
||||
projectId: args.projectId,
|
||||
sourceKey,
|
||||
summary: args.problemStatement.summary,
|
||||
title: args.problemStatement.title,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
sources.map((doc, ordinal) =>
|
||||
ctx.db.insert("signalSources", {
|
||||
conversationId: doc.conversationId,
|
||||
messageId: doc.messageId,
|
||||
ordinal,
|
||||
organizationId: args.organizationId,
|
||||
rawTextSnapshot: doc.rawText,
|
||||
signalId,
|
||||
...(doc.submissionId === undefined
|
||||
? {}
|
||||
: { submissionId: doc.submissionId }),
|
||||
sourceCreatedAt: doc.createdAt,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
for (const [
|
||||
ordinal,
|
||||
constraint,
|
||||
] of args.problemStatement.constraints.entries()) {
|
||||
await ctx.db.insert("signalConstraints", {
|
||||
ordinal,
|
||||
signalId,
|
||||
value: constraint,
|
||||
});
|
||||
}
|
||||
for (const [ordinal, message] of messages.entries()) {
|
||||
await ctx.db.insert("signalSources", {
|
||||
messageId: message._id,
|
||||
ordinal,
|
||||
rawTextSnapshot: message.content,
|
||||
signalId,
|
||||
sourceCreatedAt: message.createdAt,
|
||||
});
|
||||
}
|
||||
return { signalId };
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. List recent Signals for a project (or organization-wide when no project).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const listSignals = query({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.optional(v.id("projects")),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<SignalSummaryView[]> => {
|
||||
requireAgent(args.token);
|
||||
if (args.projectId) {
|
||||
await authorizeProjectInOrg(ctx, args.organizationId, args.projectId);
|
||||
const signals = await ctx.db
|
||||
.query("signals")
|
||||
.withIndex("by_project_and_createdAt", (q) =>
|
||||
q.eq("projectId", args.projectId as Id<"projects">)
|
||||
)
|
||||
.order("desc")
|
||||
.take(20);
|
||||
return signals.map((s) => ({
|
||||
_id: s._id,
|
||||
createdAt: s.createdAt,
|
||||
problemStatement: s.problemStatement,
|
||||
projectId: s.projectId ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
const signals = await ctx.db
|
||||
.query("signals")
|
||||
.withIndex("by_organization_and_createdAt", (q) =>
|
||||
q.eq("organizationId", args.organizationId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(20);
|
||||
return signals.map((s) => ({
|
||||
_id: s._id,
|
||||
createdAt: s.createdAt,
|
||||
problemStatement: s.problemStatement,
|
||||
projectId: s.projectId ?? null,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. List active (open/queued/working/needs-input) ProjectIssues for a project.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const listActiveIssues = query({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ActiveIssueView[]> => {
|
||||
requireAgent(args.token);
|
||||
await authorizeProjectInOrg(ctx, args.organizationId, args.projectId);
|
||||
|
||||
const issues = await ctx.db
|
||||
.query("projectIssues")
|
||||
.withIndex("by_project_and_status", (q) =>
|
||||
q.eq("projectId", args.projectId)
|
||||
)
|
||||
.take(100);
|
||||
|
||||
return issues
|
||||
.filter(
|
||||
(issue) =>
|
||||
issue.status === "open" ||
|
||||
issue.status === "queued" ||
|
||||
issue.status === "working" ||
|
||||
issue.status === "needs-input"
|
||||
)
|
||||
.map((issue) => ({
|
||||
_id: issue._id,
|
||||
body: issue.body,
|
||||
number: issue.number,
|
||||
projectId: issue.projectId,
|
||||
status: issue.status,
|
||||
title: issue.title,
|
||||
updatedAt: issue.updatedAt,
|
||||
}))
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Attach a Signal to an existing ProjectIssue (idempotent).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const attachSignalToIssue = mutation({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
signalId: v.id("signals"),
|
||||
issueId: v.id("projectIssues"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (
|
||||
ctx,
|
||||
args
|
||||
): Promise<{
|
||||
attachmentId: Id<"signalIssueAttachments">;
|
||||
alreadyAttached: boolean;
|
||||
}> => {
|
||||
requireAgent(args.token);
|
||||
const signal = await authorizeSignalInOrg(
|
||||
ctx,
|
||||
args.organizationId,
|
||||
args.signalId
|
||||
);
|
||||
const issue = await ctx.db.get(args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
await authorizeProjectInOrg(ctx, args.organizationId, issue.projectId);
|
||||
|
||||
// Cross-project rejection: the signal must be project-scoped and its
|
||||
// project must match the issue's project. Organization equality alone is
|
||||
// insufficient — it would permit attaching across different projects in
|
||||
// the same org.
|
||||
if (!signal.projectId) {
|
||||
throw new ConvexError("Signal is not project-scoped");
|
||||
}
|
||||
if (signal.projectId !== issue.projectId) {
|
||||
throw new ConvexError("Signal and issue must belong to the same project");
|
||||
}
|
||||
|
||||
// Idempotent: if the attachment already exists, return it without
|
||||
// emitting a duplicate event.
|
||||
const existing = await ctx.db
|
||||
.query("signalIssueAttachments")
|
||||
.withIndex("by_signal_and_issue", (q) =>
|
||||
q.eq("signalId", args.signalId).eq("issueId", args.issueId)
|
||||
)
|
||||
.unique();
|
||||
if (existing) {
|
||||
return {
|
||||
alreadyAttached: true,
|
||||
attachmentId: existing._id,
|
||||
};
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const attachmentId = await ctx.db.insert("signalIssueAttachments", {
|
||||
createdAt: now,
|
||||
issueId: args.issueId,
|
||||
organizationId: args.organizationId,
|
||||
projectId: issue.projectId,
|
||||
signalId: args.signalId,
|
||||
});
|
||||
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: now,
|
||||
data: {
|
||||
signalProblemTitle: signal.problemStatement.title,
|
||||
signalId: String(signal._id),
|
||||
},
|
||||
issueId: args.issueId,
|
||||
kind: "signal.attached",
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
|
||||
return { alreadyAttached: false, attachmentId };
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Create a new ProjectIssue from a Signal.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const insertIssue = async (
|
||||
ctx: MutationCtx,
|
||||
projectId: Id<"projects">,
|
||||
draft: ProjectIssueDraft
|
||||
): Promise<Id<"projectIssues">> => {
|
||||
const latest = await ctx.db
|
||||
.query("projectIssues")
|
||||
.withIndex("by_project_and_number", (q) => q.eq("projectId", projectId))
|
||||
.order("desc")
|
||||
.first();
|
||||
const timestamp = Date.now();
|
||||
const number = (latest?.number ?? 0) + 1;
|
||||
const issueId = await ctx.db.insert("projectIssues", {
|
||||
body: draft.body,
|
||||
createdAt: timestamp,
|
||||
number,
|
||||
projectId,
|
||||
status: "open",
|
||||
title: draft.title,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { number, source: "signal", title: draft.title },
|
||||
issueId,
|
||||
kind: "issue.created",
|
||||
projectId,
|
||||
});
|
||||
return issueId;
|
||||
};
|
||||
|
||||
export const createIssueFromSignal = mutation({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
signalId: v.id("signals"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (
|
||||
ctx,
|
||||
args
|
||||
): Promise<{
|
||||
created: boolean;
|
||||
issueId: Id<"projectIssues">;
|
||||
projectId: Id<"projects">;
|
||||
}> => {
|
||||
requireAgent(args.token);
|
||||
const signal = await authorizeSignalInOrg(
|
||||
ctx,
|
||||
args.organizationId,
|
||||
args.signalId
|
||||
);
|
||||
if (!signal.projectId) {
|
||||
throw new ConvexError("Signal is not project-scoped");
|
||||
}
|
||||
await authorizeProjectInOrg(ctx, args.organizationId, signal.projectId);
|
||||
|
||||
// Idempotent: if any attachment already exists for this signal, return
|
||||
// the existing issue without creating a duplicate. This prevents Flue
|
||||
// retries from producing duplicate issues/attachments.
|
||||
const existingAttachment = await ctx.db
|
||||
.query("signalIssueAttachments")
|
||||
.withIndex("by_signal", (q) => q.eq("signalId", args.signalId))
|
||||
.first();
|
||||
if (existingAttachment) {
|
||||
return {
|
||||
created: false,
|
||||
issueId: existingAttachment.issueId,
|
||||
projectId: existingAttachment.projectId,
|
||||
};
|
||||
}
|
||||
|
||||
const draft = await Effect.runPromise(
|
||||
projectIssueDraftFromSignal({
|
||||
problemStatement: signal.problemStatement,
|
||||
signalId: String(signal._id),
|
||||
})
|
||||
).catch((error: unknown) => {
|
||||
throw new ConvexError(
|
||||
error instanceof Error ? error.message : "Invalid project issue"
|
||||
);
|
||||
});
|
||||
|
||||
const issueId = await insertIssue(ctx, signal.projectId, draft);
|
||||
|
||||
// Auto-attach the signal to the new issue.
|
||||
const now = Date.now();
|
||||
await ctx.db.insert("signalIssueAttachments", {
|
||||
createdAt: now,
|
||||
issueId,
|
||||
organizationId: args.organizationId,
|
||||
projectId: signal.projectId,
|
||||
signalId: signal._id,
|
||||
});
|
||||
|
||||
return { created: true, issueId, projectId: signal.projectId };
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Begin a ProjectIssue (transition to queued/working).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const beginIssue = mutation({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
issueId: v.id("projectIssues"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (
|
||||
ctx,
|
||||
args
|
||||
): Promise<{
|
||||
dispatchRequired: boolean;
|
||||
projectId: Id<"projects">;
|
||||
status: "queued" | "working";
|
||||
}> => {
|
||||
requireAgent(args.token);
|
||||
const issue = await ctx.db.get(args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
await authorizeProjectInOrg(ctx, args.organizationId, issue.projectId);
|
||||
|
||||
const status = await Effect.runPromise(
|
||||
queueProjectIssue(issue.status)
|
||||
).catch((error: unknown) => {
|
||||
throw new ConvexError(
|
||||
error instanceof Error ? error.message : "Invalid issue status"
|
||||
);
|
||||
});
|
||||
if (status === issue.status) {
|
||||
return {
|
||||
dispatchRequired: false,
|
||||
projectId: issue.projectId,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch(issue._id, {
|
||||
status,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { number: issue.number },
|
||||
issueId: issue._id,
|
||||
kind: "issue.queued",
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
return {
|
||||
dispatchRequired: true,
|
||||
projectId: issue.projectId,
|
||||
status,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. Mark a failed Flue dispatch so retrying can safely queue it again.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const markDispatchFailed = mutation({
|
||||
args: {
|
||||
error: v.string(),
|
||||
issueId: v.id("projectIssues"),
|
||||
organizationId: v.id("organizations"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const issue = await ctx.db.get(args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
await authorizeProjectInOrg(ctx, args.organizationId, issue.projectId);
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch(issue._id, {
|
||||
status: "failed",
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { error: args.error.slice(0, 1000), phase: "dispatch" },
|
||||
issueId: issue._id,
|
||||
kind: "agent.failed",
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. Get project summary + context documents for routing decisions.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getProjectContext = query({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (
|
||||
ctx,
|
||||
args
|
||||
): Promise<{
|
||||
project: ProjectSummaryView;
|
||||
contextDocuments: ContextDocumentView[];
|
||||
}> => {
|
||||
requireAgent(args.token);
|
||||
const project = await authorizeProjectInOrg(
|
||||
ctx,
|
||||
args.organizationId,
|
||||
args.projectId
|
||||
);
|
||||
|
||||
const docs = await ctx.db
|
||||
.query("projectContextDocuments")
|
||||
.withIndex("by_project", (q) => q.eq("projectId", args.projectId))
|
||||
.take(25);
|
||||
|
||||
return {
|
||||
contextDocuments: docs.map((doc) => ({
|
||||
content: doc.content,
|
||||
kind: doc.kind,
|
||||
path: doc.path,
|
||||
revision: doc.revision,
|
||||
})),
|
||||
project: {
|
||||
_id: project._id,
|
||||
description: project.description ?? null,
|
||||
name: project.name,
|
||||
organizationId: project.organizationId,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. List projects in the organization (for selecting a project context).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const listProjects = query({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ProjectSummaryView[]> => {
|
||||
requireAgent(args.token);
|
||||
const projects = await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_organization_and_createdAt", (q) =>
|
||||
q.eq("organizationId", args.organizationId)
|
||||
)
|
||||
.take(50);
|
||||
return projects.map((p) => ({
|
||||
_id: p._id,
|
||||
description: p.description ?? null,
|
||||
name: p.name,
|
||||
organizationId: p.organizationId,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,620 +0,0 @@
|
||||
import { type TestConvex, convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import schema from "./schema";
|
||||
|
||||
// `import.meta.glob` is provided by the Vitest/Vite test runner at runtime; it
|
||||
// has no type definition under the Convex tsconfig (which scopes `types` to
|
||||
// node), so declare the minimal shape the test relies on.
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
|
||||
const ID_A = "https://convex.test|user-a";
|
||||
const ID_B = "https://convex.test|user-b";
|
||||
const identityA = { tokenIdentifier: ID_A };
|
||||
const identityB = { tokenIdentifier: ID_B };
|
||||
|
||||
const newTest = () => convexTest({ schema, modules });
|
||||
|
||||
const ensureOrg = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
identity: { readonly tokenIdentifier: string }
|
||||
) => {
|
||||
const org = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
return org._id as Id<"organizations">;
|
||||
};
|
||||
|
||||
const problemStatement = {
|
||||
title: "Deploy fails on staging",
|
||||
summary: "The staging deploy command exits with a DNS resolution error.",
|
||||
desiredOutcome: "Staging deploys successfully without DNS errors.",
|
||||
constraints: ["Must not change the production pipeline"],
|
||||
};
|
||||
|
||||
const processedBy = { agentInstanceId: "zopu-instance-1", agentName: "zopu" };
|
||||
|
||||
// Begin and admit one user message, returning its messageId.
|
||||
const seedMessage = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
identity: { readonly tokenIdentifier: string },
|
||||
orgId: Id<"organizations">,
|
||||
clientRequestId: string,
|
||||
rawText: string
|
||||
): Promise<string> => {
|
||||
const begun = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId,
|
||||
organizationId: orgId,
|
||||
rawText,
|
||||
});
|
||||
await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId,
|
||||
organizationId: orgId,
|
||||
submissionId: `sub-${clientRequestId}`,
|
||||
});
|
||||
return begun.messageId;
|
||||
};
|
||||
|
||||
const createProject = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
ownerId: string
|
||||
): Promise<Id<"projects">> => {
|
||||
// Resolve or create the owner's personal organization
|
||||
const ownerIdentity = { tokenIdentifier: ownerId };
|
||||
await t
|
||||
.withIdentity(ownerIdentity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization);
|
||||
const outcome = await t
|
||||
.withIdentity(ownerIdentity)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
userId: ownerId,
|
||||
source: {
|
||||
host: "github.com",
|
||||
projectName: "test-repo",
|
||||
repositoryPath: `owner-${ownerId.slice(-4)}/test-repo`,
|
||||
normalizedUrl: `https://github.com/owner-${ownerId.slice(-4)}/test-repo`,
|
||||
url: `https://github.com/owner-${ownerId.slice(-4)}/test-repo`,
|
||||
},
|
||||
remote: {
|
||||
defaultBranch: "main",
|
||||
documents: [
|
||||
{
|
||||
kind: "readme" as const,
|
||||
path: "README.md",
|
||||
content: "# test-repo\n\nTest.\n",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
});
|
||||
return outcome.id as unknown as Id<"projects">;
|
||||
};
|
||||
|
||||
describe("signals", () => {
|
||||
test("unauthenticated create is rejected", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
|
||||
await expect(
|
||||
t.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: ["m1"],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
})
|
||||
).rejects.toThrow(/Authentication required/u);
|
||||
});
|
||||
|
||||
test("creates one Signal from a single admitted user message", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const rawText = " I cannot deploy to staging ";
|
||||
const messageId = await seedMessage(t, identityA, orgId, "req-1", rawText);
|
||||
|
||||
const result = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
const composed = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.get, { signalId: result.signalId });
|
||||
|
||||
expect(composed).not.toBeNull();
|
||||
expect(composed?.signal.organizationId).toBe(orgId);
|
||||
expect(composed?.signal.projectId).toBeNull();
|
||||
expect(composed?.signal.conversationId).toBe(orgId);
|
||||
expect(composed?.sources).toHaveLength(1);
|
||||
expect(composed?.sources[0]?.messageId).toBe(messageId);
|
||||
// Exact raw snapshot preserved verbatim (whitespace intact).
|
||||
expect(composed?.sources[0]?.rawTextSnapshot).toBe(rawText);
|
||||
expect(composed?.sources[0]?.submissionId).toBe("sub-req-1");
|
||||
expect(composed?.sources[0]?.ordinal).toBe(0);
|
||||
});
|
||||
|
||||
test("composes several ordered user messages into one Signal", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const m1 = await seedMessage(t, identityA, orgId, "req-a", "first message");
|
||||
const m2 = await seedMessage(
|
||||
t,
|
||||
identityA,
|
||||
orgId,
|
||||
"req-b",
|
||||
"second message"
|
||||
);
|
||||
const m3 = await seedMessage(t, identityA, orgId, "req-c", "third message");
|
||||
|
||||
const result = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [m1, m2, m3],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
const composed = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.get, { signalId: result.signalId });
|
||||
|
||||
expect(composed?.sources).toHaveLength(3);
|
||||
expect(composed?.sources[0]?.messageId).toBe(m1);
|
||||
expect(composed?.sources[0]?.ordinal).toBe(0);
|
||||
expect(composed?.sources[1]?.messageId).toBe(m2);
|
||||
expect(composed?.sources[1]?.ordinal).toBe(1);
|
||||
expect(composed?.sources[2]?.messageId).toBe(m3);
|
||||
expect(composed?.sources[2]?.ordinal).toBe(2);
|
||||
expect(
|
||||
composed?.sources.map(
|
||||
(s: { rawTextSnapshot: string }) => s.rawTextSnapshot
|
||||
)
|
||||
).toEqual(["first message", "second message", "third message"]);
|
||||
});
|
||||
|
||||
test("preserves exact raw snapshots and a separate structured problem statement", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const rawText = "\n\t leading and trailing \n";
|
||||
const messageId = await seedMessage(
|
||||
t,
|
||||
identityA,
|
||||
orgId,
|
||||
"req-exact",
|
||||
rawText
|
||||
);
|
||||
|
||||
const result = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
const composed = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.get, { signalId: result.signalId });
|
||||
|
||||
// Raw evidence is exact and untouched.
|
||||
expect(composed?.sources[0]?.rawTextSnapshot).toBe(rawText);
|
||||
// The structured problem statement is stored separately.
|
||||
expect(composed?.signal.problemStatement).toEqual(problemStatement);
|
||||
});
|
||||
|
||||
test("rejects cross-organization messages", async () => {
|
||||
const t = newTest();
|
||||
const orgA = await ensureOrg(t, identityA);
|
||||
const orgB = await ensureOrg(t, identityB);
|
||||
// Message belongs to org A.
|
||||
const messageId = await seedMessage(
|
||||
t,
|
||||
identityA,
|
||||
orgA,
|
||||
"req-x",
|
||||
"secret A"
|
||||
);
|
||||
|
||||
// User B tries to build a signal under org B referencing org A's message.
|
||||
await expect(
|
||||
t.withIdentity(identityB).mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgB,
|
||||
messageIds: [messageId],
|
||||
organizationId: orgB,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
})
|
||||
).rejects.toThrow(/Source message not found/u);
|
||||
});
|
||||
|
||||
test("rejects cross-conversation mismatch", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const messageId = await seedMessage(t, identityA, orgId, "req-conv", "hi");
|
||||
|
||||
await expect(
|
||||
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
||||
conversationId: "different-conversation",
|
||||
messageIds: [messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
})
|
||||
).rejects.toThrow(/Conversation does not belong to organization/u);
|
||||
});
|
||||
|
||||
test("rejects a failed (non-admitted) message", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
|
||||
// Begin then fail a message.
|
||||
const begun = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-failed",
|
||||
organizationId: orgId,
|
||||
rawText: "doomed",
|
||||
});
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markFailed, {
|
||||
clientRequestId: "req-failed",
|
||||
organizationId: orgId,
|
||||
});
|
||||
|
||||
await expect(
|
||||
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [begun.messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
})
|
||||
).rejects.toThrow(/not admitted/u);
|
||||
});
|
||||
|
||||
test("rejects a missing message id", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
|
||||
await expect(
|
||||
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: ["nonexistent-id"],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
})
|
||||
).rejects.toThrow(/Source message not found/u);
|
||||
});
|
||||
|
||||
test("rejects duplicate message ids in the selection", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const messageId = await seedMessage(t, identityA, orgId, "req-dup", "dup");
|
||||
|
||||
await expect(
|
||||
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [messageId, messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
})
|
||||
).rejects.toThrow(/must be unique/u);
|
||||
});
|
||||
|
||||
test("rejects an empty message selection", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
|
||||
await expect(
|
||||
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
})
|
||||
).rejects.toThrow(/At least one source message is required/u);
|
||||
});
|
||||
|
||||
test("idempotent retry returns the same signal without duplicate sources", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const m1 = await seedMessage(t, identityA, orgId, "req-idem-1", "a");
|
||||
const m2 = await seedMessage(t, identityA, orgId, "req-idem-2", "b");
|
||||
|
||||
const first = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [m1, m2],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
// Retry with the same ordered selection.
|
||||
const second = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [m1, m2],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
expect(second.signalId).toBe(first.signalId);
|
||||
|
||||
// Exactly one signal and two sources exist.
|
||||
const list = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.list, { organizationId: orgId });
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0]?.sources).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("different ordered selections produce different signals", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const m1 = await seedMessage(t, identityA, orgId, "req-ord-1", "x");
|
||||
const m2 = await seedMessage(t, identityA, orgId, "req-ord-2", "y");
|
||||
|
||||
const first = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [m1, m2],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
const second = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [m2, m1],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
expect(second.signalId).not.toBe(first.signalId);
|
||||
|
||||
const list = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.list, { organizationId: orgId });
|
||||
expect(list).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("reversed selection preserves agent ordinals and original timestamps", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const m1 = await seedMessage(t, identityA, orgId, "req-rev-1", "first");
|
||||
const m2 = await seedMessage(t, identityA, orgId, "req-rev-2", "second");
|
||||
|
||||
// Chronological selection: m1 then m2.
|
||||
const chrono = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [m1, m2],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
// Reversed selection: m2 then m1 (non-chronological agent order).
|
||||
const reversed = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [m2, m1],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
expect(reversed.signalId).not.toBe(chrono.signalId);
|
||||
|
||||
const chronoSignal = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.get, { signalId: chrono.signalId });
|
||||
const reversedSignal = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.get, { signalId: reversed.signalId });
|
||||
|
||||
// Chronological: ordinals follow selection.
|
||||
expect(chronoSignal?.sources[0]?.messageId).toBe(m1);
|
||||
expect(chronoSignal?.sources[0]?.ordinal).toBe(0);
|
||||
expect(chronoSignal?.sources[1]?.messageId).toBe(m2);
|
||||
expect(chronoSignal?.sources[1]?.ordinal).toBe(1);
|
||||
|
||||
// Reversed: ordinals follow the agent's reversed selection, and each
|
||||
// source retains its original creation timestamp (unchanged by reordering).
|
||||
expect(reversedSignal?.sources[0]?.messageId).toBe(m2);
|
||||
expect(reversedSignal?.sources[0]?.ordinal).toBe(0);
|
||||
expect(reversedSignal?.sources[1]?.messageId).toBe(m1);
|
||||
expect(reversedSignal?.sources[1]?.ordinal).toBe(1);
|
||||
|
||||
// Exact raw text is preserved in both orderings.
|
||||
expect(
|
||||
chronoSignal?.sources.map(
|
||||
(s: { rawTextSnapshot: string }) => s.rawTextSnapshot
|
||||
)
|
||||
).toEqual(["first", "second"]);
|
||||
expect(
|
||||
reversedSignal?.sources.map(
|
||||
(s: { rawTextSnapshot: string }) => s.rawTextSnapshot
|
||||
)
|
||||
).toEqual(["second", "first"]);
|
||||
});
|
||||
|
||||
test("creates a project-scoped signal when the project owner is an org member", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
// Project owned by user A, who is a member of orgId.
|
||||
const projectId = await createProject(t, ID_A);
|
||||
const messageId = await seedMessage(
|
||||
t,
|
||||
identityA,
|
||||
orgId,
|
||||
"req-proj",
|
||||
"build"
|
||||
);
|
||||
|
||||
const result = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgId,
|
||||
messageIds: [messageId],
|
||||
organizationId: orgId,
|
||||
problemStatement,
|
||||
projectId,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
const composed = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.get, { signalId: result.signalId });
|
||||
|
||||
expect(composed?.signal.projectId).toBe(projectId);
|
||||
});
|
||||
|
||||
test("rejects a project whose owner is not an org member (cross-tenant)", async () => {
|
||||
const t = newTest();
|
||||
const orgA = await ensureOrg(t, identityA);
|
||||
const _orgB = await ensureOrg(t, identityB);
|
||||
// Project owned by user B.
|
||||
const projectId = await createProject(t, ID_B);
|
||||
const messageId = await seedMessage(
|
||||
t,
|
||||
identityA,
|
||||
orgA,
|
||||
"req-cross-proj",
|
||||
"z"
|
||||
);
|
||||
|
||||
// User A is a member of orgA but the project owner (B) is not.
|
||||
await expect(
|
||||
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgA,
|
||||
messageIds: [messageId],
|
||||
organizationId: orgA,
|
||||
problemStatement,
|
||||
projectId,
|
||||
processedBy,
|
||||
})
|
||||
).rejects.toThrow(/does not belong to the target organization/u);
|
||||
});
|
||||
|
||||
test("authorized list returns only the organization's signals with composed sources", async () => {
|
||||
const t = newTest();
|
||||
const orgA = await ensureOrg(t, identityA);
|
||||
const orgB = await ensureOrg(t, identityB);
|
||||
|
||||
const mA1 = await seedMessage(t, identityA, orgA, "req-list-1", "a1");
|
||||
const mA2 = await seedMessage(t, identityA, orgA, "req-list-2", "a2");
|
||||
await seedMessage(t, identityB, orgB, "req-list-b", "b1");
|
||||
|
||||
await t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgA,
|
||||
messageIds: [mA1],
|
||||
organizationId: orgA,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
await t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgA,
|
||||
messageIds: [mA1, mA2],
|
||||
organizationId: orgA,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
const forA = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.signals.list, { organizationId: orgA });
|
||||
const forB = await t
|
||||
.withIdentity(identityB)
|
||||
.query(api.signals.list, { organizationId: orgB });
|
||||
|
||||
expect(forA).toHaveLength(2);
|
||||
expect(forA[0]?.sources.length).toBeGreaterThan(0);
|
||||
expect(forB).toHaveLength(0);
|
||||
|
||||
// Cross-org list is denied.
|
||||
await expect(
|
||||
t
|
||||
.withIdentity(identityB)
|
||||
.query(api.signals.list, { organizationId: orgA })
|
||||
).rejects.toThrow(/Organization membership required/u);
|
||||
});
|
||||
|
||||
test("authorized get denies cross-organization signal access", async () => {
|
||||
const t = newTest();
|
||||
const orgA = await ensureOrg(t, identityA);
|
||||
const _orgB = await ensureOrg(t, identityB);
|
||||
|
||||
const messageId = await seedMessage(
|
||||
t,
|
||||
identityA,
|
||||
orgA,
|
||||
"req-get",
|
||||
"secret"
|
||||
);
|
||||
const result = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgA,
|
||||
messageIds: [messageId],
|
||||
organizationId: orgA,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
|
||||
// User B cannot read org A's signal.
|
||||
await expect(
|
||||
t
|
||||
.withIdentity(identityB)
|
||||
.query(api.signals.get, { signalId: result.signalId })
|
||||
).rejects.toThrow(/Organization membership required/u);
|
||||
|
||||
// get returns null for an unknown but well-formed id when authorized.
|
||||
const other = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.signals.createFromMessages, {
|
||||
conversationId: orgA,
|
||||
messageIds: [await seedMessage(t, identityA, orgA, "req-ghost", "g")],
|
||||
organizationId: orgA,
|
||||
problemStatement,
|
||||
processedBy,
|
||||
});
|
||||
expect(other.signalId).not.toBe(result.signalId);
|
||||
});
|
||||
});
|
||||
@@ -1,505 +0,0 @@
|
||||
import { composeSignal, SignalValidationError } from "@code/primitives/signal";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import {
|
||||
type MutationCtx,
|
||||
mutation,
|
||||
type QueryCtx,
|
||||
query,
|
||||
} from "./_generated/server";
|
||||
import { requireOrganizationMember, requireProjectMember } from "./authz";
|
||||
|
||||
const PROBLEM_STATEMENT = v.object({
|
||||
title: v.string(),
|
||||
summary: v.string(),
|
||||
desiredOutcome: v.string(),
|
||||
constraints: v.array(v.string()),
|
||||
});
|
||||
|
||||
interface SignalSourceView {
|
||||
readonly _id: Id<"signalSources">;
|
||||
readonly _creationTime: number;
|
||||
readonly signalId: Id<"signals">;
|
||||
readonly organizationId: Id<"organizations">;
|
||||
readonly conversationId: string;
|
||||
readonly messageId: string;
|
||||
readonly ordinal: number;
|
||||
readonly rawTextSnapshot: string;
|
||||
readonly sourceCreatedAt: number;
|
||||
readonly submissionId: string | null;
|
||||
}
|
||||
|
||||
interface SignalView {
|
||||
readonly _id: Id<"signals">;
|
||||
readonly _creationTime: number;
|
||||
readonly organizationId: Id<"organizations">;
|
||||
readonly projectId: Id<"projects"> | null;
|
||||
readonly conversationId: string;
|
||||
readonly problemStatement: {
|
||||
readonly title: string;
|
||||
readonly summary: string;
|
||||
readonly desiredOutcome: string;
|
||||
readonly constraints: readonly string[];
|
||||
};
|
||||
readonly processedByAgentName: string;
|
||||
readonly processedByAgentInstanceId: string;
|
||||
readonly createdAt: number;
|
||||
}
|
||||
|
||||
interface ComposedSignal {
|
||||
readonly signal: SignalView;
|
||||
readonly sources: readonly SignalSourceView[];
|
||||
}
|
||||
|
||||
/**
|
||||
* An admitted user conversation message presented to the agent as candidate
|
||||
* Signal evidence. The agent selects from these by `messageId`; raw text and
|
||||
* timestamps are copied server-side and never accepted from the agent.
|
||||
*/
|
||||
interface ConversationMessageSourceView {
|
||||
readonly messageId: string;
|
||||
readonly clientRequestId: string;
|
||||
readonly rawText: string;
|
||||
readonly submissionId: string | null;
|
||||
readonly createdAt: number;
|
||||
}
|
||||
|
||||
const toSignalView = (doc: Doc<"signals">): SignalView => ({
|
||||
_creationTime: doc._creationTime,
|
||||
_id: doc._id,
|
||||
conversationId: doc.conversationId,
|
||||
createdAt: doc.createdAt,
|
||||
organizationId: doc.organizationId,
|
||||
problemStatement: doc.problemStatement,
|
||||
processedByAgentInstanceId: doc.processedByAgentInstanceId,
|
||||
processedByAgentName: doc.processedByAgentName,
|
||||
projectId: doc.projectId ?? null,
|
||||
});
|
||||
|
||||
const toSourceView = (doc: Doc<"signalSources">): SignalSourceView => ({
|
||||
_creationTime: doc._creationTime,
|
||||
_id: doc._id,
|
||||
conversationId: doc.conversationId,
|
||||
messageId: doc.messageId,
|
||||
ordinal: doc.ordinal,
|
||||
organizationId: doc.organizationId,
|
||||
rawTextSnapshot: doc.rawTextSnapshot,
|
||||
signalId: doc.signalId,
|
||||
sourceCreatedAt: doc.sourceCreatedAt,
|
||||
submissionId: doc.submissionId ?? null,
|
||||
});
|
||||
|
||||
const toConversationMessageSourceView = (
|
||||
doc: Doc<"conversationMessages">
|
||||
): ConversationMessageSourceView => ({
|
||||
clientRequestId: doc.clientRequestId,
|
||||
createdAt: doc.createdAt,
|
||||
messageId: doc.messageId,
|
||||
rawText: doc.rawText,
|
||||
submissionId: doc.submissionId ?? null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a deterministic, collision-safe key from the organization, the
|
||||
* conversation, and the ordered message selection. The key is the JSON
|
||||
* encoding of `[organizationId, conversationId, ...messageIds]`, so no
|
||||
* delimiter inside arbitrary message IDs can ever cause a collision and the
|
||||
* caller's selection order is part of the identity.
|
||||
*/
|
||||
const buildSourceKey = (
|
||||
organizationId: Id<"organizations">,
|
||||
conversationId: string,
|
||||
messageIds: readonly string[]
|
||||
): string => JSON.stringify([organizationId, conversationId, ...messageIds]);
|
||||
|
||||
/**
|
||||
* Resolve all selected conversation messages server-side. Each message must
|
||||
* belong to the same organization and conversation, be a `user` message, be
|
||||
* `admitted`, and appear exactly once in the ordered selection. Raw text,
|
||||
* timestamps, and submission ids are copied from the stored rows; the agent
|
||||
* never supplies them. Returns the messages in the caller's requested order.
|
||||
*/
|
||||
const resolveSources = async (
|
||||
ctx: MutationCtx,
|
||||
organizationId: Id<"organizations">,
|
||||
conversationId: string,
|
||||
messageIds: readonly string[]
|
||||
): Promise<Doc<"conversationMessages">[]> => {
|
||||
if (messageIds.length === 0) {
|
||||
throw new ConvexError("At least one source message is required");
|
||||
}
|
||||
if (new Set(messageIds).size !== messageIds.length) {
|
||||
throw new ConvexError("Source message ids must be unique");
|
||||
}
|
||||
const resolved: Doc<"conversationMessages">[] = [];
|
||||
for (const messageId of messageIds) {
|
||||
const doc = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_organization_and_message", (q) =>
|
||||
q
|
||||
.eq("organizationId", organizationId)
|
||||
.eq("conversationId", conversationId)
|
||||
.eq("messageId", messageId)
|
||||
)
|
||||
.unique();
|
||||
if (!doc) {
|
||||
throw new ConvexError(`Source message not found: ${messageId}`);
|
||||
}
|
||||
if (doc.role !== "user") {
|
||||
throw new ConvexError(
|
||||
`Source message is not a user message: ${messageId}`
|
||||
);
|
||||
}
|
||||
if (doc.status !== "admitted") {
|
||||
throw new ConvexError(`Source message is not admitted: ${messageId}`);
|
||||
}
|
||||
resolved.push(doc);
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a project belongs to the same organization. Projects now carry an
|
||||
* explicit `organizationId`; the caller is already proven to be a member, so
|
||||
* this is a direct invariant check with no cross-tenant leakage.
|
||||
*/
|
||||
const authorizeProject = async (
|
||||
ctx: MutationCtx,
|
||||
organizationId: Id<"organizations">,
|
||||
projectId: Id<"projects">
|
||||
): Promise<void> => {
|
||||
const project = await ctx.db.get(projectId);
|
||||
if (!project) {
|
||||
throw new ConvexError("Project not found");
|
||||
}
|
||||
if (project.organizationId !== organizationId) {
|
||||
throw new ConvexError("Project does not belong to the target organization");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Atomically create one idempotent Signal from an ordered selection of user
|
||||
* messages plus a structured problem statement produced by the agent.
|
||||
*
|
||||
* Authorization: the authenticated identity must be a member of the target
|
||||
* organization. When a project is supplied it must belong to the same
|
||||
* organization. Every message id is resolved server-side from
|
||||
* `conversationMessages` under the same organization and conversation; raw
|
||||
* text, timestamps, and submission ids are copied from those rows and never
|
||||
* accepted from the agent.
|
||||
*
|
||||
* Idempotency: if a Signal with the same `(organizationId, sourceKey)` already
|
||||
* exists, it is returned as-is without creating duplicate sources. The whole
|
||||
* lookup-or-insert runs in one Convex mutation transaction, so retries cannot
|
||||
* produce duplicates.
|
||||
*
|
||||
* Returns the new (or existing) signal id.
|
||||
*/
|
||||
export const createFromMessages = mutation({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.optional(v.id("projects")),
|
||||
conversationId: v.string(),
|
||||
messageIds: v.array(v.string()),
|
||||
problemStatement: PROBLEM_STATEMENT,
|
||||
processedBy: v.object({
|
||||
agentName: v.string(),
|
||||
agentInstanceId: v.string(),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args): Promise<{ signalId: Id<"signals"> }> => {
|
||||
await requireOrganizationMember(ctx, args.organizationId);
|
||||
if (args.projectId) {
|
||||
await authorizeProject(ctx, args.organizationId, args.projectId);
|
||||
}
|
||||
|
||||
// The global conversation id equals the organization id; the caller's
|
||||
// conversation id must match.
|
||||
if (args.conversationId !== args.organizationId) {
|
||||
throw new ConvexError("Conversation does not belong to organization");
|
||||
}
|
||||
|
||||
const sourceKey = buildSourceKey(
|
||||
args.organizationId,
|
||||
args.conversationId,
|
||||
args.messageIds
|
||||
);
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("signals")
|
||||
.withIndex("by_organization_and_sourceKey", (q) =>
|
||||
q.eq("organizationId", args.organizationId).eq("sourceKey", sourceKey)
|
||||
)
|
||||
.unique();
|
||||
if (existing) {
|
||||
return { signalId: existing._id };
|
||||
}
|
||||
|
||||
const sources = await resolveSources(
|
||||
ctx,
|
||||
args.organizationId,
|
||||
args.conversationId,
|
||||
args.messageIds
|
||||
);
|
||||
|
||||
// Compose the pure Effect Signal before writes, using a prospective id.
|
||||
// `composeSignal` decodes an `unknown` input, so pass plain objects.
|
||||
const now = Date.now();
|
||||
const prospectiveId = crypto.randomUUID();
|
||||
const scope =
|
||||
args.projectId === undefined
|
||||
? { _tag: "Organization" as const, organizationId: args.organizationId }
|
||||
: {
|
||||
_tag: "Project" as const,
|
||||
organizationId: args.organizationId,
|
||||
projectId: args.projectId,
|
||||
};
|
||||
|
||||
const signal = await Effect.runPromise(
|
||||
composeSignal({
|
||||
createdAt: now,
|
||||
id: prospectiveId,
|
||||
problemStatement: args.problemStatement,
|
||||
processedBy: args.processedBy,
|
||||
scope,
|
||||
sourceMessages: sources.map((doc) => ({
|
||||
conversationId: doc.conversationId,
|
||||
createdAt: doc.createdAt,
|
||||
messageId: doc.messageId,
|
||||
rawText: doc.rawText,
|
||||
})),
|
||||
})
|
||||
).catch((error: unknown) => {
|
||||
if (error instanceof SignalValidationError) {
|
||||
throw new ConvexError(`Invalid signal: ${error.message}`);
|
||||
}
|
||||
throw new ConvexError(
|
||||
`Invalid signal: ${error instanceof Error ? error.message : "unknown"}`
|
||||
);
|
||||
});
|
||||
|
||||
const signalId = await ctx.db.insert("signals", {
|
||||
conversationId: args.conversationId,
|
||||
createdAt: now,
|
||||
organizationId: args.organizationId,
|
||||
problemStatement: {
|
||||
constraints: [...signal.problemStatement.constraints],
|
||||
desiredOutcome: signal.problemStatement.desiredOutcome,
|
||||
summary: signal.problemStatement.summary,
|
||||
title: signal.problemStatement.title,
|
||||
},
|
||||
processedByAgentInstanceId: args.processedBy.agentInstanceId,
|
||||
processedByAgentName: args.processedBy.agentName,
|
||||
...(args.projectId === undefined ? {} : { projectId: args.projectId }),
|
||||
sourceKey,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
sources.map((doc, ordinal) =>
|
||||
ctx.db.insert("signalSources", {
|
||||
conversationId: doc.conversationId,
|
||||
messageId: doc.messageId,
|
||||
ordinal,
|
||||
organizationId: args.organizationId,
|
||||
rawTextSnapshot: doc.rawText,
|
||||
signalId,
|
||||
...(doc.submissionId === undefined
|
||||
? {}
|
||||
: { submissionId: doc.submissionId }),
|
||||
sourceCreatedAt: doc.createdAt,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return { signalId };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Load the composed source rows for a signal in ordinal order.
|
||||
*/
|
||||
const loadComposedSources = async (
|
||||
ctx: QueryCtx,
|
||||
signal: Doc<"signals">
|
||||
): Promise<SignalSourceView[]> => {
|
||||
const rows = await ctx.db
|
||||
.query("signalSources")
|
||||
.withIndex("by_signal_and_ordinal", (q) => q.eq("signalId", signal._id))
|
||||
.collect();
|
||||
// `collect` does not guarantee index order across all backends; sort by
|
||||
// ordinal to be deterministic.
|
||||
rows.sort((a, b) => a.ordinal - b.ordinal);
|
||||
return rows.map(toSourceView);
|
||||
};
|
||||
|
||||
/**
|
||||
* List the Signals for the authenticated user's organization, newest first.
|
||||
* Membership is required; cross-organization access is denied. Each entry
|
||||
* includes its composed source records in ordinal order.
|
||||
*/
|
||||
export const list = query({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ComposedSignal[]> => {
|
||||
await requireOrganizationMember(ctx, args.organizationId);
|
||||
const signals = await ctx.db
|
||||
.query("signals")
|
||||
.withIndex("by_organization_and_createdAt", (q) =>
|
||||
q.eq("organizationId", args.organizationId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(50);
|
||||
return Promise.all(
|
||||
signals.map(async (signal) => ({
|
||||
signal: toSignalView(signal),
|
||||
sources: await loadComposedSources(ctx, signal),
|
||||
}))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* List the bounded set of admitted user messages in the organization's
|
||||
* conversation that have not yet been consumed as a Signal source, newest
|
||||
* first. These are the candidate messages an agent should select from when
|
||||
* composing a new Signal.
|
||||
*
|
||||
* Authorization: the authenticated identity must be a member of the target
|
||||
* organization. The conversation id equals the organization id; the caller
|
||||
* never supplies tenancy. Cross-organization access is denied.
|
||||
*
|
||||
* Bounded: capped at 100 rows to keep agent evidence selection tractable.
|
||||
*/
|
||||
export const listAdmittedUnused = query({
|
||||
args: {
|
||||
organizationId: v.id("organizations"),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ConversationMessageSourceView[]> => {
|
||||
await requireOrganizationMember(ctx, args.organizationId);
|
||||
const conversationId = args.organizationId;
|
||||
|
||||
const admitted = await ctx.db
|
||||
.query("conversationMessages")
|
||||
.withIndex("by_organization_and_message", (q) =>
|
||||
q
|
||||
.eq("organizationId", args.organizationId)
|
||||
.eq("conversationId", conversationId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(100);
|
||||
|
||||
const candidates = admitted.filter(
|
||||
(message) => message.role === "user" && message.status === "admitted"
|
||||
);
|
||||
|
||||
const unused: ConversationMessageSourceView[] = [];
|
||||
for (const message of candidates) {
|
||||
const consumed = await ctx.db
|
||||
.query("signalSources")
|
||||
.withIndex("by_organization_and_message", (q) =>
|
||||
q
|
||||
.eq("organizationId", args.organizationId)
|
||||
.eq("conversationId", conversationId)
|
||||
.eq("messageId", message.messageId)
|
||||
)
|
||||
.first();
|
||||
if (!consumed) {
|
||||
unused.push(toConversationMessageSourceView(message));
|
||||
}
|
||||
}
|
||||
|
||||
return unused;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get one Signal by id with its composed sources. Membership in the signal's
|
||||
* organization is required; access to another organization's signal is denied.
|
||||
*/
|
||||
export const get = query({
|
||||
args: {
|
||||
signalId: v.id("signals"),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ComposedSignal | null> => {
|
||||
const signal = await ctx.db.get(args.signalId);
|
||||
if (!signal) {
|
||||
return null;
|
||||
}
|
||||
await requireOrganizationMember(ctx, signal.organizationId);
|
||||
return {
|
||||
signal: toSignalView(signal),
|
||||
sources: await loadComposedSources(ctx, signal),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Linked Signal query for the Work OS surface.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LinkedSignalView {
|
||||
readonly signalId: Id<"signals">;
|
||||
readonly title: string;
|
||||
readonly summary: string;
|
||||
readonly sourceCount: number;
|
||||
readonly createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all Signal-issue attachments for a project, grouped by issue id.
|
||||
* User-authenticated: the caller must be a member of the project organization.
|
||||
* Used by the Work OS card list to show per-issue linked Signal counts.
|
||||
*/
|
||||
export const listLinkedSignalsForProject = query({
|
||||
args: {
|
||||
projectId: v.id("projects"),
|
||||
},
|
||||
handler: async (ctx, args): Promise<Record<string, LinkedSignalView[]>> => {
|
||||
const { organizationId } = await requireProjectMember(ctx, args.projectId);
|
||||
const issues = await ctx.db
|
||||
.query("projectIssues")
|
||||
.withIndex("by_project_and_number", (q) =>
|
||||
q.eq("projectId", args.projectId)
|
||||
)
|
||||
.take(100);
|
||||
const result: Record<string, LinkedSignalView[]> = {};
|
||||
for (const issue of issues) {
|
||||
const attachments = await ctx.db
|
||||
.query("signalIssueAttachments")
|
||||
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
|
||||
.collect();
|
||||
if (attachments.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const signals = await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
const signal = await ctx.db.get(attachment.signalId);
|
||||
if (!signal || signal.organizationId !== organizationId) {
|
||||
return null;
|
||||
}
|
||||
const sources = await ctx.db
|
||||
.query("signalSources")
|
||||
.withIndex("by_signal_and_ordinal", (q) =>
|
||||
q.eq("signalId", signal._id)
|
||||
)
|
||||
.collect();
|
||||
return {
|
||||
createdAt: signal.createdAt,
|
||||
signalId: signal._id,
|
||||
sourceCount: sources.length,
|
||||
summary: signal.problemStatement.summary,
|
||||
title: signal.problemStatement.title,
|
||||
} satisfies LinkedSignalView;
|
||||
})
|
||||
);
|
||||
result[String(issue._id)] = signals
|
||||
.filter((entry): entry is LinkedSignalView => entry !== null)
|
||||
// Deterministic order: newest linked Signal first.
|
||||
.sort((a: LinkedSignalView, b: LinkedSignalView) => b.createdAt - a.createdAt);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { query, mutation } from "./_generated/server";
|
||||
|
||||
export const getAll = query({
|
||||
handler: async (ctx) => {
|
||||
return await ctx.db.query("todos").collect();
|
||||
},
|
||||
});
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
text: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const newTodoId = await ctx.db.insert("todos", {
|
||||
text: args.text,
|
||||
completed: false,
|
||||
});
|
||||
return await ctx.db.get("todos", newTodoId);
|
||||
},
|
||||
});
|
||||
|
||||
export const toggle = mutation({
|
||||
args: {
|
||||
id: v.id("todos"),
|
||||
completed: v.boolean(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.patch("todos", args.id, { completed: args.completed });
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteTodo = mutation({
|
||||
args: {
|
||||
id: v.id("todos"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.delete("todos", args.id);
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { env } from "@code/env/convex";
|
||||
import {
|
||||
getIssue,
|
||||
fetchTransport,
|
||||
type GitRemoteConfig,
|
||||
} from "@code/primitives/git-remote-runtime";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import { mutation } from "./_generated/server";
|
||||
|
||||
// Agent token guard (service-level auth for Zopu tools), matching signalRouting.
|
||||
const requireAgent = (token: string) => {
|
||||
if (token !== env.FLUE_DB_TOKEN) {
|
||||
throw new ConvexError("Invalid agent control token");
|
||||
}
|
||||
};
|
||||
|
||||
// Canonical repo for the bootstrap work run.
|
||||
const remoteConfig = (): GitRemoteConfig => {
|
||||
if (!env.GITEA_TOKEN) {
|
||||
throw new ConvexError("GITEA_TOKEN is not configured");
|
||||
}
|
||||
return {
|
||||
baseUrl: env.GITEA_URL,
|
||||
owner: "puter",
|
||||
repo: "zopu-code",
|
||||
token: env.GITEA_TOKEN,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts a work run for an existing Gitea issue on the canonical repo. Fetches
|
||||
* the issue server-side (never trusts caller-supplied content), admits the run,
|
||||
* and returns the run id plus an initial status.
|
||||
*
|
||||
* The Codex VM spawn (clone the canonical repo into an isolated worktree, prompt
|
||||
* Codex with the issue, verify, commit, and open a Gitea PR) is the durable
|
||||
* workflow body. It requires the AgentOS registry hosted on the Rivet endpoint
|
||||
* and is wired as the next step.
|
||||
*/
|
||||
export const startIssueWork = mutation({
|
||||
args: {
|
||||
issueNumber: v.number(),
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (_ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const config = remoteConfig();
|
||||
|
||||
const issue = await Effect.runPromise(
|
||||
getIssue(fetchTransport, config, args.issueNumber)
|
||||
);
|
||||
|
||||
const runId = `run_${Date.now().toString(36)}_${args.issueNumber}`;
|
||||
|
||||
return {
|
||||
issueNumber: issue.number,
|
||||
issueTitle: issue.title,
|
||||
runId,
|
||||
status: "queued",
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import schema from "./schema";
|
||||
|
||||
declare global {
|
||||
@@ -14,211 +13,61 @@ declare global {
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
const identity = { tokenIdentifier: "https://convex.test|slice-one" };
|
||||
|
||||
describe("Slice 1 Work routing", () => {
|
||||
test("creates one proposed Work and preserves exact source text", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const organization = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const project = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
remote: { defaultBranch: "main", documents: [], warnings: [] },
|
||||
source: {
|
||||
host: "github.com",
|
||||
normalizedUrl: "https://github.com/example/slice-one",
|
||||
projectName: "slice-one",
|
||||
repositoryPath: "example/slice-one",
|
||||
url: "https://github.com/example/slice-one",
|
||||
},
|
||||
userId: identity.tokenIdentifier,
|
||||
describe("works", () => {
|
||||
test("creates one proposed Work and one normalized attachment", async () => {
|
||||
const t = convexTest({ modules, schema });
|
||||
const fixture = await t.run(async (ctx) => {
|
||||
const organizationId = await ctx.db.insert("organizations", {
|
||||
createdAt: 1,
|
||||
createdBy: "user",
|
||||
kind: "personal",
|
||||
name: "Personal",
|
||||
});
|
||||
const rawText = " Add a phone-ready Slice 1 experience. ";
|
||||
const message = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "slice-one-request",
|
||||
organizationId: organization._id,
|
||||
rawText,
|
||||
const projectId = await ctx.db.insert("projects", {
|
||||
createdAt: 1,
|
||||
name: "Zopu",
|
||||
normalizedSourceUrl: "https://example.com/zopu",
|
||||
organizationId,
|
||||
repositoryPath: "puter/zopu",
|
||||
sourceHost: "example.com",
|
||||
sourceUrl: "https://example.com/zopu",
|
||||
updatedAt: 1,
|
||||
});
|
||||
await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "slice-one-request",
|
||||
organizationId: organization._id,
|
||||
submissionId: "slice-one-submission",
|
||||
const conversationId = await ctx.db.insert("conversations", {
|
||||
createdAt: 1,
|
||||
organizationId,
|
||||
});
|
||||
const signal = await t.mutation(api.signalRouting.createSignal, {
|
||||
messageIds: [message.messageId],
|
||||
organizationId: organization._id,
|
||||
problemStatement: {
|
||||
constraints: ["Mobile web first"],
|
||||
desiredOutcome: "The Slice 1 loop works on a phone.",
|
||||
summary: "The current product is not ready for phone testing.",
|
||||
title: "Make Slice 1 phone-ready",
|
||||
},
|
||||
processedByAgentInstanceId: organization._id,
|
||||
projectId: project.id,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
const signalId = await ctx.db.insert("signals", {
|
||||
conversationId,
|
||||
createdAt: 1,
|
||||
desiredOutcome: "A working Slice 1",
|
||||
organizationId,
|
||||
processedByAgentInstanceId: String(organizationId),
|
||||
processedByAgentName: "zopu",
|
||||
projectId,
|
||||
sourceKey: "source-1",
|
||||
summary: "Slice 1 needs work",
|
||||
title: "Finish Slice 1",
|
||||
});
|
||||
return { organizationId, signalId };
|
||||
});
|
||||
|
||||
const first = await t.mutation(api.works.createFromSignal, {
|
||||
organizationId: organization._id,
|
||||
signalId: signal.signalId,
|
||||
organizationId: fixture.organizationId,
|
||||
signalId: fixture.signalId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
const repeated = await t.mutation(api.works.createFromSignal, {
|
||||
organizationId: organization._id,
|
||||
signalId: signal.signalId,
|
||||
const second = await t.mutation(api.works.createFromSignal, {
|
||||
organizationId: fixture.organizationId,
|
||||
signalId: fixture.signalId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
expect(repeated).toEqual({ created: false, workId: first.workId });
|
||||
|
||||
const works = await t
|
||||
.withIdentity(identity)
|
||||
.query(api.works.listForProject, { projectId: project.id });
|
||||
expect(works).toHaveLength(1);
|
||||
expect(works[0]?.status).toBe("proposed");
|
||||
expect(works[0]?.signals[0]?.sources[0]?.rawText).toBe(rawText);
|
||||
});
|
||||
|
||||
test("keeps casual conversation out of Work", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const organization = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const project = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
remote: { defaultBranch: "main", documents: [], warnings: [] },
|
||||
source: {
|
||||
host: "git.openputer.com",
|
||||
normalizedUrl: "https://git.openputer.com/puter/zopu-code",
|
||||
projectName: "zopu-code",
|
||||
repositoryPath: "puter/zopu-code",
|
||||
url: "https://git.openputer.com/puter/zopu-code",
|
||||
},
|
||||
userId: identity.tokenIdentifier,
|
||||
});
|
||||
await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "slice-one-casual",
|
||||
organizationId: organization._id,
|
||||
rawText: "Yo, how are you?",
|
||||
});
|
||||
await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "slice-one-casual",
|
||||
organizationId: organization._id,
|
||||
submissionId: "slice-one-casual-submission",
|
||||
});
|
||||
|
||||
const works = await t
|
||||
.withIdentity(identity)
|
||||
.query(api.works.listForProject, { projectId: project.id });
|
||||
expect(works).toEqual([]);
|
||||
});
|
||||
|
||||
test("attaches another Signal to existing Work idempotently", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const organization = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const project = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
remote: { defaultBranch: "main", documents: [], warnings: [] },
|
||||
source: {
|
||||
host: "git.openputer.com",
|
||||
normalizedUrl: "https://git.openputer.com/puter/zopu-code",
|
||||
projectName: "zopu-code",
|
||||
repositoryPath: "puter/zopu-code",
|
||||
url: "https://git.openputer.com/puter/zopu-code",
|
||||
},
|
||||
userId: identity.tokenIdentifier,
|
||||
});
|
||||
|
||||
const seedSignal = async (
|
||||
clientRequestId: string,
|
||||
rawText: string,
|
||||
title: string
|
||||
) => {
|
||||
const message = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId,
|
||||
organizationId: organization._id,
|
||||
rawText,
|
||||
});
|
||||
await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId,
|
||||
organizationId: organization._id,
|
||||
submissionId: `${clientRequestId}-submission`,
|
||||
});
|
||||
return await t.mutation(api.signalRouting.createSignal, {
|
||||
messageIds: [message.messageId],
|
||||
organizationId: organization._id,
|
||||
problemStatement: {
|
||||
constraints: [],
|
||||
desiredOutcome: "The Slice 1 phone flow is polished.",
|
||||
summary: "The messages describe the same desired outcome.",
|
||||
title,
|
||||
},
|
||||
processedByAgentInstanceId: organization._id,
|
||||
projectId: project.id,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
};
|
||||
|
||||
const firstSignal = await seedSignal(
|
||||
"slice-one-first",
|
||||
"Polish the Slice 1 phone experience.",
|
||||
"Polish Slice 1"
|
||||
expect(first.created).toBe(true);
|
||||
expect(second).toEqual({ created: false, workId: first.workId });
|
||||
const attachments = await t.run(async (ctx) =>
|
||||
ctx.db.query("signalWorkAttachments").collect()
|
||||
);
|
||||
const secondSignal = await seedSignal(
|
||||
"slice-one-second",
|
||||
"Make the same Slice 1 experience easier to inspect on mobile.",
|
||||
"Improve Slice 1 inspection"
|
||||
);
|
||||
const created = await t.mutation(api.works.createFromSignal, {
|
||||
organizationId: organization._id,
|
||||
signalId: firstSignal.signalId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
const firstAttach = await t.mutation(api.works.attachSignalToWork, {
|
||||
organizationId: organization._id,
|
||||
signalId: secondSignal.signalId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
workId: created.workId,
|
||||
});
|
||||
const repeatedAttach = await t.mutation(api.works.attachSignalToWork, {
|
||||
organizationId: organization._id,
|
||||
signalId: secondSignal.signalId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
workId: created.workId,
|
||||
});
|
||||
|
||||
expect(firstAttach).toEqual({ attached: true, workId: created.workId });
|
||||
expect(repeatedAttach).toEqual({
|
||||
attached: false,
|
||||
workId: created.workId,
|
||||
});
|
||||
|
||||
const works = await t
|
||||
.withIdentity(identity)
|
||||
.query(api.works.listForProject, { projectId: project.id });
|
||||
expect(works).toHaveLength(1);
|
||||
expect(works[0]?.signals).toHaveLength(2);
|
||||
expect(
|
||||
works[0]?.events.filter(
|
||||
(event: { readonly kind: string }) => event.kind === "signal.attached"
|
||||
)
|
||||
).toHaveLength(1);
|
||||
expect(attachments).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ const attachSignal = async (
|
||||
organizationId: String(signal.organizationId),
|
||||
projectId: String(signal.projectId),
|
||||
signalId: String(signal._id),
|
||||
title: signal.problemStatement.title,
|
||||
title: signal.title,
|
||||
},
|
||||
work: {
|
||||
organizationId: String(work.organizationId),
|
||||
@@ -81,18 +81,14 @@ const attachSignal = async (
|
||||
const createdAt = Date.now();
|
||||
await ctx.db.insert("signalWorkAttachments", {
|
||||
createdAt,
|
||||
organizationId: work.organizationId,
|
||||
projectId: work.projectId,
|
||||
signalId: signal._id,
|
||||
workId: work._id,
|
||||
});
|
||||
await ctx.db.insert("workEvents", {
|
||||
createdAt,
|
||||
data: event.data,
|
||||
idempotencyKey: event.idempotencyKey,
|
||||
kind: event.kind,
|
||||
organizationId: work.organizationId,
|
||||
projectId: work.projectId,
|
||||
signalId: signal._id,
|
||||
workId: work._id,
|
||||
});
|
||||
await ctx.db.patch(work._id, { updatedAt: createdAt });
|
||||
@@ -138,8 +134,19 @@ export const createFromSignal = mutation({
|
||||
return { created: false, workId: existingAttachment.workId };
|
||||
}
|
||||
|
||||
const constraints = await ctx.db
|
||||
.query("signalConstraints")
|
||||
.withIndex("by_signalId_and_ordinal", (q) => q.eq("signalId", signal._id))
|
||||
.collect();
|
||||
const draft = await Effect.runPromise(
|
||||
workDraftFromSignal(signal.problemStatement)
|
||||
workDraftFromSignal({
|
||||
constraints: constraints
|
||||
.sort((left, right) => left.ordinal - right.ordinal)
|
||||
.map((constraint) => constraint.value),
|
||||
desiredOutcome: signal.desiredOutcome,
|
||||
summary: signal.summary,
|
||||
title: signal.title,
|
||||
})
|
||||
).catch((error: unknown) => {
|
||||
throw new ConvexError(
|
||||
error instanceof Error ? error.message : "Invalid Work proposal"
|
||||
@@ -169,18 +176,14 @@ export const createFromSignal = mutation({
|
||||
});
|
||||
await ctx.db.insert("signalWorkAttachments", {
|
||||
createdAt,
|
||||
organizationId: signal.organizationId,
|
||||
projectId: signal.projectId as Id<"projects">,
|
||||
signalId: signal._id,
|
||||
workId,
|
||||
});
|
||||
await ctx.db.insert("workEvents", {
|
||||
createdAt,
|
||||
data: event.data,
|
||||
idempotencyKey: event.idempotencyKey,
|
||||
kind: event.kind,
|
||||
organizationId: signal.organizationId,
|
||||
projectId: signal.projectId as Id<"projects">,
|
||||
signalId: signal._id,
|
||||
workId,
|
||||
});
|
||||
return { created: true, workId };
|
||||
@@ -231,23 +234,23 @@ export const listForProject = query({
|
||||
}
|
||||
const sources = await ctx.db
|
||||
.query("signalSources")
|
||||
.withIndex("by_signal_and_ordinal", (q) =>
|
||||
.withIndex("by_signalId_and_ordinal", (q) =>
|
||||
q.eq("signalId", signal._id)
|
||||
)
|
||||
.collect();
|
||||
return {
|
||||
createdAt: signal.createdAt,
|
||||
signalId: signal._id,
|
||||
summary: signal.problemStatement.summary,
|
||||
summary: signal.summary,
|
||||
sources: sources
|
||||
.sort((a, b) => a.ordinal - b.ordinal)
|
||||
.map((source) => ({
|
||||
createdAt: source.sourceCreatedAt,
|
||||
messageId: source.messageId,
|
||||
messageId: String(source.messageId),
|
||||
rawText: source.rawTextSnapshot,
|
||||
submissionId: source.submissionId ?? null,
|
||||
submissionId: null,
|
||||
})),
|
||||
title: signal.problemStatement.title,
|
||||
title: signal.title,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
1
packages/env/package.json
vendored
1
packages/env/package.json
vendored
@@ -8,7 +8,6 @@
|
||||
"./convex": "./src/convex.ts",
|
||||
"./native": "./src/native.ts",
|
||||
"./server": "./src/server.ts",
|
||||
"./smoke": "./src/smoke.ts",
|
||||
"./web": "./src/web.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
55
packages/env/src/smoke.ts
vendored
55
packages/env/src/smoke.ts
vendored
@@ -1,55 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const smokeEnvSchema = z.object({
|
||||
accessToken: z.string().min(1),
|
||||
agentModelBaseUrl: z.url().optional(),
|
||||
agentModelName: z.string().min(1).optional(),
|
||||
agentModelProvider: z.string().min(1).optional(),
|
||||
convexUrl: z.url(),
|
||||
cpaApiKey: z.string().min(1),
|
||||
cpaBaseUrl: z.url(),
|
||||
daemonId: z.string().min(1),
|
||||
dockerImage: z.string().min(1).optional(),
|
||||
featureRequest: z.string().min(10),
|
||||
flueUrl: z.url(),
|
||||
giteaToken: z.string().min(1).optional(),
|
||||
giteaUrl: z.url().optional(),
|
||||
projectId: z.string().min(1).optional(),
|
||||
repositoryPath: z.string().min(1).optional(),
|
||||
webUrl: z.url(),
|
||||
});
|
||||
|
||||
export type SmokeEnv = z.infer<typeof smokeEnvSchema>;
|
||||
|
||||
export const parseSmokeEnv = (
|
||||
runtimeEnv: Record<string, string | undefined>
|
||||
): SmokeEnv =>
|
||||
smokeEnvSchema.parse({
|
||||
accessToken: runtimeEnv.ZOPU_SMOKE_ACCESS_TOKEN,
|
||||
agentModelBaseUrl: runtimeEnv.AGENT_MODEL_BASE_URL,
|
||||
agentModelName: runtimeEnv.AGENT_MODEL_NAME,
|
||||
agentModelProvider: runtimeEnv.AGENT_MODEL_PROVIDER,
|
||||
convexUrl: runtimeEnv.ZOPU_SMOKE_CONVEX_URL ?? runtimeEnv.CONVEX_URL,
|
||||
cpaApiKey:
|
||||
runtimeEnv.ZOPU_SMOKE_CPA_API_KEY ?? runtimeEnv.AGENT_MODEL_API_KEY,
|
||||
cpaBaseUrl:
|
||||
runtimeEnv.ZOPU_SMOKE_CPA_BASE_URL ?? runtimeEnv.AGENT_MODEL_BASE_URL,
|
||||
daemonId: runtimeEnv.ZOPU_SMOKE_DAEMON_ID ?? runtimeEnv.DAEMON_ID,
|
||||
dockerImage: runtimeEnv.ORB_DOCKER_IMAGE,
|
||||
featureRequest:
|
||||
runtimeEnv.ZOPU_SMOKE_FEATURE_REQUEST ??
|
||||
"Add a tiny accessible status control to the existing web project's primary page, keep existing behavior unchanged, and add a focused test for it.",
|
||||
flueUrl:
|
||||
runtimeEnv.ZOPU_SMOKE_FLUE_URL ??
|
||||
runtimeEnv.VITE_FLUE_URL ??
|
||||
"http://localhost:3583",
|
||||
giteaToken: runtimeEnv.GITEA_TOKEN ?? runtimeEnv.ZOPU_SMOKE_GITEA_TOKEN,
|
||||
giteaUrl: runtimeEnv.GITEA_URL ?? runtimeEnv.ZOPU_SMOKE_GITEA_URL,
|
||||
projectId: runtimeEnv.ZOPU_SMOKE_PROJECT_ID,
|
||||
repositoryPath:
|
||||
runtimeEnv.ZOPU_SMOKE_REPOSITORY_PATH ?? runtimeEnv.GITEA_REPOSITORY_PATH,
|
||||
webUrl:
|
||||
runtimeEnv.ZOPU_SMOKE_WEB_URL ??
|
||||
runtimeEnv.SITE_URL ??
|
||||
"http://localhost:5173",
|
||||
});
|
||||
1
packages/env/src/vite-env.d.ts
vendored
1
packages/env/src/vite-env.d.ts
vendored
@@ -1,7 +1,6 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly [key: string]: string | number | boolean | undefined;
|
||||
readonly VITE_AUTH_URL: string;
|
||||
readonly VITE_FLUE_URL: string;
|
||||
readonly VITE_CONVEX_URL: string;
|
||||
}
|
||||
|
||||
|
||||
1
packages/env/src/web.ts
vendored
1
packages/env/src/web.ts
vendored
@@ -10,7 +10,6 @@ export const env = createEnv({
|
||||
client: {
|
||||
VITE_AUTH_URL: z.url(),
|
||||
VITE_CONVEX_URL: convexUrlSchema("example.convex.cloud"),
|
||||
VITE_FLUE_URL: z.url(),
|
||||
},
|
||||
clientPrefix: "VITE_",
|
||||
emptyStringAsUndefined: true,
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "catalog:",
|
||||
"next-themes": "catalog:",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"shadcn": "^4.12.0",
|
||||
"shiki": "^4.3.1",
|
||||
"sonner": "catalog:",
|
||||
|
||||
Reference in New Issue
Block a user