Merge dogfood/v0 into master
Zopu dogfood v0 loop: project chat -> Signal -> Work routing; Orb runtime (Docker + AgentOS + OpenCode with proven gateway model turn); project-manager orchestration over the Orb; Work OS web UI; single-node runtime deployment. Lanes: Signals orchestrator, Orb runtime, Web Work OS, runtime deployment, project-manager Orb wiring. Three-stage Orb proof passes end to end. # Conflicts: # apps/web/src/components/projects/project-workspace-page.tsx # apps/web/src/hooks/use-personal-organization.ts # apps/web/src/hooks/use-project-workspace.ts # apps/web/src/routes/_app.tsx # apps/web/src/routes/_auth.tsx # packages/backend/convex/projectIssues.ts # packages/backend/convex/projects.ts # packages/primitives/package.json # packages/primitives/src/index.ts
This commit is contained in:
@@ -12,18 +12,25 @@
|
||||
"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:",
|
||||
"hono": "4.12.30",
|
||||
"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:"
|
||||
}
|
||||
}
|
||||
|
||||
132
packages/agents/src/actions/finalize-gitea-lifecycle.ts
Normal file
132
packages/agents/src/actions/finalize-gitea-lifecycle.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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";
|
||||
|
||||
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({ harness, 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"
|
||||
);
|
||||
}
|
||||
|
||||
const runner = {
|
||||
async run(
|
||||
command: string,
|
||||
options?: { cwd?: string; env?: Record<string, string> }
|
||||
) {
|
||||
return await harness.shell(command, options);
|
||||
},
|
||||
};
|
||||
const transport = createGiteaHttpTransport({
|
||||
baseUrl: env.GITEA_URL,
|
||||
token: env.GITEA_TOKEN,
|
||||
});
|
||||
|
||||
try {
|
||||
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: "/workspace/repository",
|
||||
});
|
||||
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: "/workspace/repository",
|
||||
});
|
||||
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;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineAgent } from "@flue/runtime";
|
||||
import type { AgentRouteHandler } from "@flue/runtime";
|
||||
|
||||
import { createFinalizeGiteaLifecycle } from "../actions/finalize-gitea-lifecycle";
|
||||
import { agentOs } from "../sandboxes/agent-os";
|
||||
import { createProjectTools } from "../tools/project";
|
||||
|
||||
@@ -14,15 +15,16 @@ export default defineAgent(({ env, id }) => {
|
||||
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
|
||||
|
||||
return {
|
||||
cwd: "/workspace",
|
||||
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 issue.md, project.md, business.md, design.md, agent.md, work.md, steps.md, artifacts.md, signals.md, agent-manager.md, context.md, and card.md from the AgentOS workspace. Call report_work_status with working before editing.
|
||||
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. Inspect existing files before changing them. If a missing product decision makes safe progress impossible, publish the current work.md and steps.md, report needs-input, then ask one focused question. Otherwise implement the complete issue, run the relevant command or scenario in AgentOS, and preserve command evidence.
|
||||
Work only on the bound issue. The repository checkout is the current working directory; inspect existing source files before changing them. If a missing product decision makes safe progress impossible, publish the current work.md and steps.md, report needs-input, then ask one focused question. Otherwise implement the complete issue, run the relevant install, edit, test, or scenario command in AgentOS, and preserve command evidence.
|
||||
|
||||
Before finishing, update the local work.md, steps.md, artifacts.md, and context.md files and publish each changed canonical artifact with publish_project_artifact. Report completed only after verification. On an unrecoverable error, report failed with the exact blocker. Never claim a repository change or command result you did not observe.`,
|
||||
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),
|
||||
|
||||
@@ -8,24 +8,70 @@ import { local } from "@flue/runtime/node";
|
||||
|
||||
import paseo from "../skills/paseo/SKILL.md" with { type: "skill" };
|
||||
import { paseoCli } from "../tools/paseo";
|
||||
import { createSignalRoutingTools } from "../tools/signals";
|
||||
|
||||
const repositoryRoot = path.resolve(process.cwd(), "../..");
|
||||
|
||||
const INSTRUCTIONS = `You are Zopu, the global planning and work-routing agent for the Zopu Work OS.
|
||||
|
||||
## Your role
|
||||
|
||||
You listen to user messages in the persistent global conversation and decide whether they contain actionable work. The control plane stores every user message as exact evidence before you process it; you never supply or rewrite raw message text.
|
||||
|
||||
## Work routing loop
|
||||
|
||||
When a user sends a message, follow this decision flow:
|
||||
|
||||
1. **Assess actionability.** Does the message contain a concrete problem, request, blocker, opportunity, or decision that warrants a work unit? Greetings, questions about the system, casual conversation, and exploration do NOT create work. If the message is casual conversation, respond naturally and do nothing else.
|
||||
|
||||
2. **Identify project context.** If the message references a project, call list_projects to find it. If the user has one project, use it. If the project is ambiguous, ask one focused clarification.
|
||||
|
||||
3. **Create a Signal (only when actionable).** When the message is actionable:
|
||||
a. Call list_signal_evidence to see the exact admitted user messages.
|
||||
b. Select the message IDs that compose the problem statement.
|
||||
c. Call create_signal with a structured problem statement (title, summary, desiredOutcome, constraints). The problem statement must faithfully represent the user's own intent. Do not invent scope they did not mention.
|
||||
d. Include the projectId when the project is known.
|
||||
|
||||
4. **Route the Signal.** After creating the Signal (or if one already exists):
|
||||
a. Call list_active_issues for the relevant project.
|
||||
b. Compare the Signal's problem to existing issues.
|
||||
c. If the Signal clearly relates to an existing active issue, call attach_signal_to_issue.
|
||||
d. If the Signal is new work that does not match any existing issue, call create_issue_from_signal.
|
||||
e. If genuinely uncertain whether to attach or create, ask one focused question.
|
||||
|
||||
5. **Explain the outcome.** Tell the user clearly what happened:
|
||||
- "Created a Signal: [title] and attached it to issue #[number]: [issue title]."
|
||||
- "Created a Signal: [title] and opened new issue #[number]: [issue title]."
|
||||
- Include the problem statement title so the user can verify accuracy.
|
||||
|
||||
6. **Optionally begin work.** Only when the user explicitly asks to start or work on the issue, call begin_issue. Do not begin work automatically.
|
||||
|
||||
## Rules
|
||||
|
||||
- Never supply or rewrite the raw source message text. The control plane copies it server-side.
|
||||
- Never create work from casual chat.
|
||||
- Ask at most one focused clarification when genuinely ambiguous.
|
||||
- Preserve project and organization scope at all times.
|
||||
- Repeated delivery of the same message must not create duplicate Signals or attachments. The backend is idempotent.
|
||||
- Never claim you created a Signal until the tool call returns successfully.
|
||||
- Do not create Candidate Work, Work Units, or Runs directly. Those are downstream concerns.
|
||||
- When the user asks about existing work, use list_active_issues and list_recent_signals to answer.`;
|
||||
|
||||
export { authenticatedAgentRoute as route } from "../auth";
|
||||
|
||||
export default defineAgent(({ env }) => {
|
||||
export default defineAgent(({ env, id }) => {
|
||||
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
|
||||
|
||||
return {
|
||||
cwd: repositoryRoot,
|
||||
description: "A simple conversational agent with persistent history.",
|
||||
instructions:
|
||||
"You are Zopu, the global planning agent. Help the user clarify and plan work. User messages are durably captured as Signal evidence by the control plane; do not claim that you created a Signal until secure tool execution is wired.",
|
||||
description:
|
||||
"Project-scoped work-routing agent that turns conversation into Signals and routes them to issues.",
|
||||
instructions: INSTRUCTIONS,
|
||||
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
|
||||
// sandbox: agentos(),
|
||||
sandbox: local({ cwd: repositoryRoot }),
|
||||
|
||||
skills: [paseo],
|
||||
tools: [paseoCli],
|
||||
tools: [paseoCli, ...createSignalRoutingTools(id, env)],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ 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, {
|
||||
@@ -18,6 +20,7 @@ registerProvider(agentEnv.AGENT_MODEL_PROVIDER, {
|
||||
});
|
||||
|
||||
const app = new Hono();
|
||||
app.post("/project-requests", projectRequestRoute);
|
||||
app.route("/", flue());
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -98,7 +98,9 @@ const { CONVEX_URL } = parseAgentEnv(process.env);
|
||||
* 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.
|
||||
*/
|
||||
const createAuthenticatedClient = (accessToken: string): ConvexHttpClient => {
|
||||
export const createAuthenticatedClient = (
|
||||
accessToken: string
|
||||
): ConvexHttpClient => {
|
||||
const client = new ConvexHttpClient(CONVEX_URL);
|
||||
client.setAuth(accessToken);
|
||||
return client;
|
||||
@@ -120,7 +122,7 @@ interface HeaderSource {
|
||||
* Extract and validate the Bearer access token from the request. Returns null
|
||||
* when absent or malformed so the caller can produce a clean 401.
|
||||
*/
|
||||
const extractBearerToken = (request: HeaderSource): string | null => {
|
||||
export const extractBearerToken = (request: HeaderSource): string | null => {
|
||||
const header = request.headers.get("authorization");
|
||||
if (!header) {
|
||||
return null;
|
||||
|
||||
155
packages/agents/src/git/gitea.test.ts
Normal file
155
packages/agents/src/git/gitea.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
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 'ssh://git@git.openputer.com:2222/puter/zopu-code.git' 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
307
packages/agents/src/git/gitea.ts
Normal file
307
packages/agents/src/git/gitea.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
const repository = 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 ${shellQuote(repository.sshUrl)} 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",
|
||||
};
|
||||
};
|
||||
105
packages/agents/src/orb/README.md
Normal file
105
packages/agents/src/orb/README.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# 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.
|
||||
109
packages/agents/src/orb/context-pack.ts
Normal file
109
packages/agents/src/orb/context-pack.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
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");
|
||||
};
|
||||
170
packages/agents/src/orb/docker-sandbox.test.ts
Normal file
170
packages/agents/src/orb/docker-sandbox.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/* 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."
|
||||
);
|
||||
}
|
||||
118
packages/agents/src/orb/docker-sandbox.ts
Normal file
118
packages/agents/src/orb/docker-sandbox.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
132
packages/agents/src/orb/domain.test.ts
Normal file
132
packages/agents/src/orb/domain.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
259
packages/agents/src/orb/domain.ts
Normal file
259
packages/agents/src/orb/domain.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/* 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;
|
||||
}
|
||||
);
|
||||
134
packages/agents/src/orb/events.test.ts
Normal file
134
packages/agents/src/orb/events.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/* 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");
|
||||
});
|
||||
});
|
||||
216
packages/agents/src/orb/events.ts
Normal file
216
packages/agents/src/orb/events.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
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;
|
||||
};
|
||||
81
packages/agents/src/orb/git-adapter.ts
Normal file
81
packages/agents/src/orb/git-adapter.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
13
packages/agents/src/orb/index.ts
Normal file
13
packages/agents/src/orb/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// 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";
|
||||
82
packages/agents/src/orb/opencode-config.test.ts
Normal file
82
packages/agents/src/orb/opencode-config.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/* 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");
|
||||
});
|
||||
});
|
||||
76
packages/agents/src/orb/opencode-config.ts
Normal file
76
packages/agents/src/orb/opencode-config.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
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;
|
||||
132
packages/agents/src/orb/orb-adapter.ts
Normal file
132
packages/agents/src/orb/orb-adapter.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
};
|
||||
132
packages/agents/src/orb/orb-project-manager.live.test.ts
Normal file
132
packages/agents/src/orb/orb-project-manager.live.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/* 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);
|
||||
});
|
||||
709
packages/agents/src/orb/orb-project-manager.test.ts
Normal file
709
packages/agents/src/orb/orb-project-manager.test.ts
Normal file
@@ -0,0 +1,709 @@
|
||||
/* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
636
packages/agents/src/orb/orb-project-manager.ts
Normal file
636
packages/agents/src/orb/orb-project-manager.ts
Normal file
@@ -0,0 +1,636 @@
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
107
packages/agents/src/orb/permission-policy.test.ts
Normal file
107
packages/agents/src/orb/permission-policy.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
90
packages/agents/src/orb/permission-policy.ts
Normal file
90
packages/agents/src/orb/permission-policy.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 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 };
|
||||
};
|
||||
136
packages/agents/src/orb/ports.ts
Normal file
136
packages/agents/src/orb/ports.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/* 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;
|
||||
123
packages/agents/src/orb/project-events.test.ts
Normal file
123
packages/agents/src/orb/project-events.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
171
packages/agents/src/orb/project-events.ts
Normal file
171
packages/agents/src/orb/project-events.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
125
packages/agents/src/orb/runtime.test.ts
Normal file
125
packages/agents/src/orb/runtime.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/* 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");
|
||||
});
|
||||
});
|
||||
763
packages/agents/src/orb/runtime.ts
Normal file
763
packages/agents/src/orb/runtime.ts
Normal file
@@ -0,0 +1,763 @@
|
||||
/* 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("'", `'"'"'`)}'`;
|
||||
162
packages/agents/src/project-request.ts
Normal file
162
packages/agents/src/project-request.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
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 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 === "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,9 +1,14 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import {
|
||||
decodeIssueWorkspaceResult,
|
||||
makeIssueWorkspacePlan,
|
||||
} from "@code/primitives/project-workspace";
|
||||
import { createSandboxSessionEnv } from "@flue/runtime";
|
||||
import type { FileStat, SandboxApi, SandboxFactory } from "@flue/runtime";
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import { Effect } from "effect";
|
||||
import * as v from "valibot";
|
||||
|
||||
const execResultSchema = v.object({
|
||||
@@ -218,15 +223,69 @@ export const agentOs = (
|
||||
issueId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
await sandbox.mkdir("/workspace", { recursive: true });
|
||||
await Promise.all(
|
||||
context.artifacts.map((artifact) =>
|
||||
sandbox.writeFile(`/workspace/${artifact.path}`, artifact.content)
|
||||
)
|
||||
|
||||
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,
|
||||
})
|
||||
);
|
||||
await sandbox.writeFile(
|
||||
"/workspace/issue.md",
|
||||
`# Issue ${context.issue.number}: ${context.issue.title}\n\n${context.issue.body}\n`
|
||||
|
||||
const [controlDirectory, checkoutOperation, ...stagingOperations] =
|
||||
plan.operations;
|
||||
if (!controlDirectory || controlDirectory._tag !== "Mkdir") {
|
||||
throw new Error(
|
||||
"Issue workspace plan must start with a control directory"
|
||||
);
|
||||
}
|
||||
await sandbox.mkdir(controlDirectory.path, { recursive: true });
|
||||
if (!checkoutOperation || checkoutOperation._tag !== "Exec") {
|
||||
throw new Error("Issue workspace plan must include a checkout command");
|
||||
}
|
||||
const checkoutCommandResult = await sandbox.exec(
|
||||
checkoutOperation.command,
|
||||
{
|
||||
cwd: checkoutOperation.cwd,
|
||||
timeoutMs: checkoutOperation.timeoutMs,
|
||||
}
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
stagingOperations
|
||||
.filter((operation) => operation._tag === "Mkdir")
|
||||
.map((operation) =>
|
||||
sandbox.mkdir(operation.path, { recursive: true })
|
||||
)
|
||||
);
|
||||
await Promise.all(
|
||||
stagingOperations
|
||||
.filter((operation) => operation._tag === "WriteFile")
|
||||
.map((operation) =>
|
||||
sandbox.writeFile(operation.path, operation.content)
|
||||
)
|
||||
);
|
||||
|
||||
await Effect.runPromise(
|
||||
decodeIssueWorkspaceResult({
|
||||
command: checkoutCommandResult,
|
||||
plan,
|
||||
})
|
||||
);
|
||||
return createSandboxSessionEnv(sandbox, "/workspace");
|
||||
},
|
||||
|
||||
317
packages/agents/src/tools/signals.ts
Normal file
317
packages/agents/src/tools/signals.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineTool } from "@flue/runtime";
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
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;
|
||||
},
|
||||
"queued" | "working"
|
||||
>("signalRouting:beginIssue");
|
||||
|
||||
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 }) {
|
||||
return await client.mutation(beginIssueRef, {
|
||||
issueId: input.issueId,
|
||||
organizationId,
|
||||
token,
|
||||
});
|
||||
},
|
||||
}),
|
||||
];
|
||||
};
|
||||
Reference in New Issue
Block a user