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:
-Puter
2026-07-25 02:03:30 +05:30
156 changed files with 21151 additions and 873 deletions

View File

@@ -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:"
}
}

View 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;
}
},
});
};

View File

@@ -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),

View File

@@ -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)],
};
});

View File

@@ -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;

View File

@@ -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;

View 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"
);
});
});

View 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",
};
};

View 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.

View 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");
};

View 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."
);
}

View 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();
},
});
}
);

View 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");
});
});

View 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;
}
);

View 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");
});
});

View 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;
};

View 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,
};
},
};
};

View 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";

View 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");
});
});

View 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;

View 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;
},
};
};

View 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);
});

View 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);
});
});
});

View 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);
}
}
}

View 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();
});
});

View 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 };
};

View 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;

View 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");
});
});

View 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;
}
}
};

View 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");
});
});

View 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("'", `'"'"'`)}'`;

View 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
);
}
};

View File

@@ -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");
},

View 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,
});
},
}),
];
};

View File

@@ -12,6 +12,7 @@ import type * as agentWorkspace from "../agentWorkspace.js";
import type * as artifactModel from "../artifactModel.js";
import type * as auth from "../auth.js";
import type * as authz from "../authz.js";
import type * as conversationMessages from "../conversationMessages.js";
import type * as daemonCommands from "../daemonCommands.js";
import type * as daemonRuntime from "../daemonRuntime.js";
import type * as daemons from "../daemons.js";
@@ -22,7 +23,10 @@ import type * as organizations from "../organizations.js";
import type * as privateData from "../privateData.js";
import type * as projectArtifacts from "../projectArtifacts.js";
import type * as projectIssues from "../projectIssues.js";
import type * as projectStore from "../projectStore.js";
import type * as projects from "../projects.js";
import type * as publicGit from "../publicGit.js";
import type * as signals from "../signals.js";
import type * as todos from "../todos.js";
import type {
@@ -36,6 +40,7 @@ declare const fullApi: ApiFromModules<{
artifactModel: typeof artifactModel;
auth: typeof auth;
authz: typeof authz;
conversationMessages: typeof conversationMessages;
daemonCommands: typeof daemonCommands;
daemonRuntime: typeof daemonRuntime;
daemons: typeof daemons;
@@ -46,7 +51,10 @@ declare const fullApi: ApiFromModules<{
privateData: typeof privateData;
projectArtifacts: typeof projectArtifacts;
projectIssues: typeof projectIssues;
projectStore: typeof projectStore;
projects: typeof projects;
publicGit: typeof publicGit;
signals: typeof signals;
todos: typeof todos;
}>;

View File

@@ -1,5 +1,12 @@
import { env } from "@code/env/convex";
import {
makeIssueWorkspacePlan,
transitionWorkspaceRun,
WorkspaceStateError,
WorkspaceValidationError,
} from "@code/primitives/project-workspace";
import { ConvexError, v } from "convex/values";
import { Effect } from "effect";
import { mutation, query } from "./_generated/server";
import { artifactPath } from "./artifactModel";
@@ -17,6 +24,37 @@ const agentStatus = v.union(
v.literal("failed")
);
const workspacePlanFor = (input: {
readonly issueBody: string;
readonly issueId: string;
readonly issueNumber: number;
readonly issueTitle: string;
readonly sourceUrl: string | undefined;
readonly defaultBranch: string | undefined;
}) => {
try {
return Effect.runSync(
makeIssueWorkspacePlan({
artifacts: [],
branchName: undefined,
checkoutPath: undefined,
contextFiles: [],
defaultBranch: input.defaultBranch,
issueBody: input.issueBody,
issueId: input.issueId,
issueNumber: input.issueNumber,
issueTitle: input.issueTitle,
sourceUrl: input.sourceUrl,
})
);
} catch (error) {
if (error instanceof WorkspaceValidationError) {
throw new ConvexError(error.message);
}
throw error;
}
};
export const get = query({
args: { issueId: v.id("projectIssues"), token: v.string() },
handler: async (ctx, args) => {
@@ -33,7 +71,26 @@ export const get = query({
.query("projectArtifacts")
.withIndex("by_project", (q) => q.eq("projectId", project._id))
.take(25);
return { artifacts, issue, project };
const [source] = await ctx.db
.query("projectSources")
.withIndex("by_project", (q) => q.eq("projectId", project._id))
.take(1);
const contextDocuments = await ctx.db
.query("projectContextDocuments")
.withIndex("by_project", (q) => q.eq("projectId", project._id))
.take(25);
const run = await ctx.db
.query("projectWorkRuns")
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
.unique();
return {
artifacts,
contextDocuments,
issue,
project,
run: run ?? null,
source: source ?? null,
};
},
});
@@ -49,12 +106,38 @@ export const ensureRun = mutation({
if (!issue) {
throw new ConvexError("Issue not found");
}
const [source] = await ctx.db
.query("projectSources")
.withIndex("by_project", (q) => q.eq("projectId", issue.projectId))
.take(1);
const plan = workspacePlanFor({
defaultBranch: source?.defaultBranch,
issueBody: issue.body,
issueId: String(issue._id),
issueNumber: issue.number,
issueTitle: issue.title,
sourceUrl: source?.url,
});
const existing = await ctx.db
.query("projectWorkRuns")
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
.unique();
if (existing) {
return existing;
if (
existing.baseBranch !== plan.baseBranch ||
existing.branchName !== plan.branchName ||
existing.checkoutPath !== plan.checkoutPath ||
existing.sourceUrl !== plan.sourceUrl
) {
await ctx.db.patch("projectWorkRuns", existing._id, {
baseBranch: plan.baseBranch,
branchName: plan.branchName,
checkoutPath: plan.checkoutPath,
sourceUrl: plan.sourceUrl,
updatedAt: Date.now(),
});
}
return await ctx.db.get("projectWorkRuns", existing._id);
}
const timestamp = Date.now();
const actorKey = [
@@ -66,16 +149,26 @@ export const ensureRun = mutation({
const runId = await ctx.db.insert("projectWorkRuns", {
actorKey,
agentId: String(issue._id),
baseBranch: plan.baseBranch,
branchName: plan.branchName,
checkoutPath: plan.checkoutPath,
createdAt: timestamp,
daemonId: args.daemonId,
issueId: issue._id,
projectId: issue.projectId,
sourceUrl: plan.sourceUrl,
status: "queued",
updatedAt: timestamp,
});
await ctx.db.insert("projectEvents", {
createdAt: timestamp,
data: { actorKey, daemonId: args.daemonId },
data: {
actorKey,
branchName: plan.branchName,
checkoutPath: plan.checkoutPath,
daemonId: args.daemonId,
sourceUrl: plan.sourceUrl,
},
issueId: issue._id,
kind: "agent.workspace.created",
projectId: issue.projectId,
@@ -147,6 +240,19 @@ export const setStatus = mutation({
if (!run) {
throw new ConvexError("Agent work run not found");
}
try {
Effect.runSync(
transitionWorkspaceRun({
from: run.status,
to: args.status,
})
);
} catch (error) {
if (error instanceof WorkspaceStateError) {
throw new ConvexError(error.message);
}
throw error;
}
const timestamp = Date.now();
const terminal = args.status === "completed" || args.status === "failed";
await ctx.db.patch("projectIssues", issue._id, {
@@ -172,3 +278,111 @@ export const setStatus = mutation({
});
},
});
const lifecycleStatus = v.union(
v.literal("no_changes"),
v.literal("committed"),
v.literal("pushed"),
v.literal("pull_request_open"),
v.literal("failed")
);
const pullRequestMetadata = v.object({
baseBranch: v.string(),
branch: v.string(),
number: v.number(),
status: v.union(v.literal("open"), v.literal("closed"), v.literal("merged")),
url: v.string(),
});
export const recordGiteaLifecycle = mutation({
args: {
baseBranch: v.string(),
branch: v.string(),
commitSha: v.optional(v.string()),
error: v.optional(v.string()),
issueId: v.id("projectIssues"),
pullRequest: v.optional(pullRequestMetadata),
status: lifecycleStatus,
token: v.string(),
},
handler: async (ctx, args) => {
requireAgent(args.token);
const issue = await ctx.db.get("projectIssues", args.issueId);
if (!issue) {
throw new ConvexError("Issue not found");
}
const run = await ctx.db
.query("projectWorkRuns")
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
.unique();
if (!run) {
throw new ConvexError("Agent work run not found");
}
const events = await ctx.db
.query("projectEvents")
.withIndex("by_issue_and_createdAt", (q) => q.eq("issueId", issue._id))
.order("desc")
.take(100);
const existing = events.find((event) => {
if (!event.kind.startsWith("gitea.")) {
return false;
}
const data = event.data as {
branch?: unknown;
commitSha?: unknown;
status?: unknown;
};
return (
data.branch === args.branch &&
data.commitSha === args.commitSha &&
data.status === args.status
);
});
if (existing) {
return existing.data;
}
const timestamp = Date.now();
const data = {
baseBranch: args.baseBranch,
branch: args.branch,
...(args.commitSha === undefined ? {} : { commitSha: args.commitSha }),
...(args.error === undefined ? {} : { error: args.error.slice(0, 2000) }),
...(args.pullRequest === undefined
? {}
: { pullRequest: args.pullRequest }),
status: args.status,
};
const artifact = await ctx.db
.query("projectArtifacts")
.withIndex("by_project_and_path", (q) =>
q.eq("projectId", issue.projectId).eq("path", "artifacts.md")
)
.unique();
if (artifact) {
const pullRequestLine = args.pullRequest
? `- Pull request: [#${args.pullRequest.number}](${args.pullRequest.url}) (${args.pullRequest.status})\n`
: "";
const commitLine = args.commitSha ? `- Commit: ${args.commitSha}\n` : "";
await ctx.db.patch("projectArtifacts", artifact._id, {
content: `${artifact.content}\n## Git lifecycle\n\n- Status: ${args.status}\n- Branch: ${args.branch}\n- Base branch: ${args.baseBranch}\n${commitLine}${pullRequestLine}${args.error ? `- Error: ${args.error.slice(0, 1000)}\n` : ""}`,
revision: artifact.revision + 1,
updatedAt: timestamp,
});
}
await ctx.db.insert("projectEvents", {
createdAt: timestamp,
data,
issueId: issue._id,
kind:
args.pullRequest === undefined
? "gitea.lifecycle.updated"
: "gitea.pull_request.created",
projectId: issue.projectId,
runId: run._id,
});
return data;
},
});

View File

@@ -0,0 +1,387 @@
import { type TestConvex, convexTest } from "convex-test";
import { anyApi } from "convex/server";
import { describe, expect, test } from "vitest";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import schema from "./schema";
declare global {
interface ImportMeta {
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
}
}
const modules = import.meta.glob("./**/*.ts");
const api = anyApi;
const ID_A = "https://convex.test|user-a";
const ID_B = "https://convex.test|user-b";
const identityA = { tokenIdentifier: ID_A };
const identityB = { tokenIdentifier: ID_B };
const newTest = () => convexTest({ schema, modules });
const ensureOrg = async (
t: TestConvex<typeof schema>,
identity: { readonly tokenIdentifier: string }
) => {
const org = await t
.withIdentity(identity)
.mutation(api.organizations.ensurePersonalOrganization, {});
return org._id as Id<"organizations">;
};
const problemStatement = {
title: "Deploy fails on staging",
summary: "The staging deploy command exits with a DNS resolution error.",
desiredOutcome: "Staging deploys successfully without DNS errors.",
constraints: ["Must not change the production pipeline"],
};
const processedBy = { agentInstanceId: "zopu-instance-1", agentName: "zopu" };
const seedMessage = async (
t: TestConvex<typeof schema>,
identity: { readonly tokenIdentifier: string },
orgId: Id<"organizations">,
clientRequestId: string,
rawText: string
): Promise<string> => {
const begun = await t
.withIdentity(identity)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId,
organizationId: orgId,
rawText,
});
await t
.withIdentity(identity)
.mutation(api.conversationMessages.markAdmitted, {
clientRequestId,
organizationId: orgId,
submissionId: `sub-${clientRequestId}`,
});
return begun.messageId;
};
const createProject = async (
t: TestConvex<typeof schema>,
ownerId: string,
suffix: string
): Promise<Id<"projects">> => {
const ownerIdentity = { tokenIdentifier: ownerId };
await t
.withIdentity(ownerIdentity)
.mutation(api.organizations.ensurePersonalOrganization);
const outcome = await t
.withIdentity(ownerIdentity)
.mutation(internal.projects.persistPublicGitImport, {
userId: ownerId,
source: {
host: "github.com",
projectName: `test-repo-${suffix}`,
repositoryPath: `owner-${suffix}/test-repo`,
normalizedUrl: `https://github.com/owner-${suffix}/test-repo`,
url: `https://github.com/owner-${suffix}/test-repo`,
},
remote: {
defaultBranch: "main",
documents: [
{
kind: "readme" as const,
path: "README.md",
content: "# test\n",
},
],
warnings: [],
},
});
return outcome.id as unknown as Id<"projects">;
};
// ---------------------------------------------------------------------------
// Helpers for creating signals and attachments via the DB directly (the
// query under test is read-only; we seed through mutations/DB).
// ---------------------------------------------------------------------------
const createSignalInProject = async (
t: TestConvex<typeof schema>,
identity: { readonly tokenIdentifier: string },
orgId: Id<"organizations">,
projectId: Id<"projects">,
msgSuffix: string,
ps: typeof problemStatement
): Promise<Id<"signals">> => {
const messageId = await seedMessage(
t,
identity,
orgId,
`req-${msgSuffix}`,
`evidence ${msgSuffix}`
);
const result = await t
.withIdentity(identity)
.mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: [messageId],
organizationId: orgId,
problemStatement: ps,
projectId,
processedBy,
});
return result.signalId;
};
/** Insert a signalIssueAttachment directly via the test DB. */
const linkSignalToIssue = async (
t: TestConvex<typeof schema>,
signalId: Id<"signals">,
issueId: Id<"projectIssues">,
organizationId: Id<"organizations">,
projectId: Id<"projects">
): Promise<void> => {
await t.mutation((ctx) =>
ctx.db.insert("signalIssueAttachments", {
createdAt: Date.now(),
issueId,
organizationId,
projectId,
signalId,
})
);
};
const createIssue = async (
t: TestConvex<typeof schema>,
identity: { readonly tokenIdentifier: string },
projectId: Id<"projects">,
title: string
): Promise<Id<"projectIssues">> =>
t.withIdentity(identity).mutation(api.projectIssues.create, {
body: "A representative issue body long enough to pass validation",
projectId,
title,
});
describe("listLinkedSignalsForProject", () => {
test("groups linked signals by issue with correct source counts", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const projectId = await createProject(t, ID_A, "group");
const issue1 = await createIssue(t, identityA, projectId, "Issue one");
const issue2 = await createIssue(t, identityA, projectId, "Issue two");
// Signal 1: one source, linked to issue1
const sig1 = await createSignalInProject(
t,
identityA,
orgId,
projectId,
"s1",
problemStatement
);
await linkSignalToIssue(t, sig1, issue1, orgId, projectId);
// Signal 2: one source, linked to issue2
const sig2 = await createSignalInProject(
t,
identityA,
orgId,
projectId,
"s2",
{
...problemStatement,
title: "Different signal",
}
);
await linkSignalToIssue(t, sig2, issue2, orgId, projectId);
const result = await t
.withIdentity(identityA)
.query(api.signals.listLinkedSignalsForProject, { projectId });
expect(Object.keys(result)).toHaveLength(2);
expect(result[String(issue1)]).toHaveLength(1);
expect(result[String(issue2)]).toHaveLength(1);
expect(result[String(issue1)]![0]!.title).toBe("Deploy fails on staging");
expect(result[String(issue2)]![0]!.title).toBe("Different signal");
expect(result[String(issue1)]![0]!.sourceCount).toBe(1);
});
test("multiple signals linked to one issue are returned newest first", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const projectId = await createProject(t, ID_A, "order");
const issue = await createIssue(t, identityA, projectId, "Single issue");
const ps1 = { ...problemStatement, title: "First signal" };
const ps2 = { ...problemStatement, title: "Second signal" };
const sig1 = await createSignalInProject(
t,
identityA,
orgId,
projectId,
"order1",
ps1
);
// Small delay so createdAt differs.
await t.mutation(async (ctx) => {
// Patch createdAt to be earlier.
const s = await ctx.db.get(sig1);
if (s) {
await ctx.db.patch(sig1, { createdAt: s.createdAt - 1000 });
}
});
const sig2 = await createSignalInProject(
t,
identityA,
orgId,
projectId,
"order2",
ps2
);
await linkSignalToIssue(t, sig1, issue, orgId, projectId);
await linkSignalToIssue(t, sig2, issue, orgId, projectId);
const result = await t
.withIdentity(identityA)
.query(api.signals.listLinkedSignalsForProject, { projectId });
const linked = result[String(issue)]!;
expect(linked).toHaveLength(2);
// Newest first: sig2 was created later.
expect(linked[0]!.title).toBe("Second signal");
expect(linked[1]!.title).toBe("First signal");
});
test("requires project membership — denies non-member access", async () => {
const t = newTest();
const _orgA = await ensureOrg(t, identityA);
const _orgB = await ensureOrg(t, identityB);
const projectId = await createProject(t, ID_A, "auth");
// User B is NOT a member of org A / project.
await expect(
t
.withIdentity(identityB)
.query(api.signals.listLinkedSignalsForProject, { projectId })
).rejects.toThrow(/Project not found|Organization membership required/u);
});
test("does not return signals from a different project", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
// Two projects in the same org.
const projectId1 = await createProject(t, ID_A, "p1");
const projectId2 = await createProject(t, ID_A, "p2");
const issue1 = await createIssue(t, identityA, projectId1, "Issue P1");
const issue2 = await createIssue(t, identityA, projectId2, "Issue P2");
// Signal linked to issue in project2.
const sig = await createSignalInProject(
t,
identityA,
orgId,
projectId2,
"xproj",
problemStatement
);
await linkSignalToIssue(t, sig, issue2, orgId, projectId2);
// Querying project1 should NOT see project2's signal.
const result1 = await t
.withIdentity(identityA)
.query(api.signals.listLinkedSignalsForProject, {
projectId: projectId1,
});
expect(result1[String(issue1)] ?? []).toHaveLength(0);
// Querying project2 SHOULD see it.
const result2 = await t
.withIdentity(identityA)
.query(api.signals.listLinkedSignalsForProject, {
projectId: projectId2,
});
expect(result2[String(issue2)] ?? []).toHaveLength(1);
});
test("returns correct source count for multi-source signals", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const projectId = await createProject(t, ID_A, "sources");
const issue = await createIssue(t, identityA, projectId, "Multi-source");
// Create a signal with two sources.
const m1 = await seedMessage(t, identityA, orgId, "ms-1", "evidence a");
const m2 = await seedMessage(t, identityA, orgId, "ms-2", "evidence b");
const result = await t
.withIdentity(identityA)
.mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: [m1, m2],
organizationId: orgId,
problemStatement,
projectId,
processedBy,
});
await linkSignalToIssue(t, result.signalId, issue, orgId, projectId);
const linked = await t
.withIdentity(identityA)
.query(api.signals.listLinkedSignalsForProject, { projectId });
const signals = linked[String(issue)]!;
expect(signals).toHaveLength(1);
expect(signals[0]!.sourceCount).toBe(2);
});
test("returns empty record for a project with no attachments", async () => {
const t = newTest();
const _orgId = await ensureOrg(t, identityA);
const projectId = await createProject(t, ID_A, "empty");
await createIssue(t, identityA, projectId, "Orphan issue");
const result = await t
.withIdentity(identityA)
.query(api.signals.listLinkedSignalsForProject, { projectId });
expect(Object.keys(result)).toHaveLength(0);
});
test("issue with no linked signals is omitted from the result", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const projectId = await createProject(t, ID_A, "mixed");
const issueWith = await createIssue(t, identityA, projectId, "Has signal");
const issueWithout = await createIssue(
t,
identityA,
projectId,
"No signal"
);
const sig = await createSignalInProject(
t,
identityA,
orgId,
projectId,
"mixed1",
problemStatement
);
await linkSignalToIssue(t, sig, issueWith, orgId, projectId);
const result = await t
.withIdentity(identityA)
.query(api.signals.listLinkedSignalsForProject, { projectId });
expect(Object.keys(result)).toHaveLength(1);
expect(result[String(issueWith)]).toHaveLength(1);
expect(result[String(issueWithout)]).toBeUndefined();
});
});

View File

@@ -168,11 +168,17 @@ describe("projectEvents", () => {
.withIdentity(identityA)
.mutation(api.projectIssues.begin, { issueId });
await t.mutation(api.agentWorkspace.ensureRun, {
const run = await t.mutation(api.agentWorkspace.ensureRun, {
daemonId: "daemon-1",
issueId,
token: FLUE_DB_TOKEN,
});
expect(run).toMatchObject({
baseBranch: "main",
checkoutPath: "/workspace/repository",
sourceUrl: SOURCE.url,
});
expect(run?.branchName).toMatch(/^work\/issue-1-/u);
await t.mutation(api.agentWorkspace.setStatus, {
issueId,
status: "completed",
@@ -217,4 +223,63 @@ describe("projectEvents", () => {
const events = await eventsForProject(t, projectId);
expect(events.some((e) => e.kind === "agent.completed")).toBe(false);
});
test("Gitea lifecycle persists PR metadata in an event and artifact", async () => {
const t = convexTest({ schema, modules });
const projectId = await createTestProject(t);
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {
body: "A representative issue body long enough to pass validation",
projectId,
title: "Representative issue",
});
await t.mutation(api.agentWorkspace.ensureRun, {
daemonId: "daemon-1",
issueId,
token: FLUE_DB_TOKEN,
});
await t.mutation(api.agentWorkspace.recordGiteaLifecycle, {
baseBranch: "main",
branch: "work/issue-1",
commitSha: "abc123",
issueId,
pullRequest: {
baseBranch: "main",
branch: "work/issue-1",
number: 5,
status: "open",
url: "https://git.openputer.com/puter/zopu-code/pulls/5",
},
status: "pull_request_open",
token: FLUE_DB_TOKEN,
});
const events = await eventsForProject(t, projectId);
const event = events.find(
(item) => item.kind === "gitea.pull_request.created"
);
expect(event?.data).toMatchObject({
branch: "work/issue-1",
commitSha: "abc123",
pullRequest: {
number: 5,
status: "open",
},
status: "pull_request_open",
});
const artifacts = await t.query((ctx) =>
ctx.db
.query("projectArtifacts")
.withIndex("by_project_and_path", (q) =>
q.eq("projectId", projectId).eq("path", "artifacts.md")
)
.unique()
);
expect(artifacts?.content).toContain(
"https://git.openputer.com/puter/zopu-code/pulls/5"
);
});
});

View File

@@ -0,0 +1,177 @@
import { type TestConvex, convexTest } from "convex-test";
import { anyApi } from "convex/server";
import { describe, expect, test } from "vitest";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import schema from "./schema";
declare global {
interface ImportMeta {
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
}
}
const modules = import.meta.glob("./**/*.ts");
const api = anyApi;
const ID_A = "https://convex.test|project-request-a";
const ID_B = "https://convex.test|project-request-b";
const identityA = { tokenIdentifier: ID_A };
const identityB = { tokenIdentifier: ID_B };
const createProject = async (
t: TestConvex<typeof schema>
): Promise<Id<"projects">> => {
await t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization, {});
const outcome = await t
.withIdentity(identityA)
.mutation(internal.projects.persistPublicGitImport, {
userId: ID_A,
source: {
host: "github.com",
normalizedUrl: "https://github.com/test/project-request",
projectName: "project-request",
repositoryPath: "test/project-request",
url: "https://github.com/test/project-request",
},
remote: {
defaultBranch: "main",
documents: [
{
content: "# Project Request\n",
kind: "readme" as const,
path: "README.md",
},
],
warnings: [],
},
});
return outcome.id as unknown as Id<"projects">;
};
describe("project issue request smoke", () => {
test("project Signal becomes a queued project issue with durable evidence", async () => {
const t = convexTest({ schema, modules });
const projectId = await createProject(t);
const organization = await t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization, {});
const begun = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "project-request-1",
organizationId: organization._id,
rawText: "Staging deploys fail during DNS resolution.",
});
await t
.withIdentity(identityA)
.mutation(api.conversationMessages.markAdmitted, {
clientRequestId: "project-request-1",
organizationId: organization._id,
submissionId: "submission-project-request-1",
});
const signal = await t
.withIdentity(identityA)
.mutation(api.signals.createFromMessages, {
conversationId: organization._id,
messageIds: [begun.messageId],
organizationId: organization._id,
problemStatement: {
constraints: ["Do not change production"],
desiredOutcome: "Staging deploys successfully",
summary: "The staging deploy fails during DNS resolution.",
title: "Fix the staging deploy DNS failure",
},
processedBy: {
agentInstanceId: String(organization._id),
agentName: "zopu",
},
projectId,
});
const created = await t
.withIdentity(identityA)
.mutation(api.projectIssues.createFromSignal, {
signalId: signal.signalId,
});
const issue = await t.run(async (ctx) => ctx.db.get(created.issueId));
expect(issue).toMatchObject({
body: expect.stringContaining("Source Signal"),
projectId,
status: "open",
title: "Fix the staging deploy DNS failure",
});
const status = await t
.withIdentity(identityA)
.mutation(api.projectIssues.begin, { issueId: created.issueId });
expect(status).toBe("queued");
const events = await t.run(async (ctx) =>
ctx.db
.query("projectEvents")
.withIndex("by_project_and_createdAt", (q) =>
q.eq("projectId", projectId)
)
.collect()
);
expect(events.map((event) => event.kind)).toEqual([
"project.connected",
"issue.created",
"issue.queued",
]);
});
test("a different organization cannot consume the project Signal", async () => {
const t = convexTest({ schema, modules });
const projectId = await createProject(t);
await t
.withIdentity(identityB)
.mutation(api.organizations.ensurePersonalOrganization, {});
const organization = await t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization, {});
const begun = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId: "project-request-cross-org",
organizationId: organization._id,
rawText: "Private project request",
});
await t
.withIdentity(identityA)
.mutation(api.conversationMessages.markAdmitted, {
clientRequestId: "project-request-cross-org",
organizationId: organization._id,
submissionId: "submission-project-request-cross-org",
});
const signal = await t
.withIdentity(identityA)
.mutation(api.signals.createFromMessages, {
conversationId: organization._id,
messageIds: [begun.messageId],
organizationId: organization._id,
problemStatement: {
constraints: [],
desiredOutcome: "Keep the request private",
summary: "This request belongs to the first organization.",
title: "Private project request",
},
processedBy: {
agentInstanceId: String(organization._id),
agentName: "zopu",
},
projectId,
});
await expect(
t.withIdentity(identityB).mutation(api.projectIssues.createFromSignal, {
signalId: signal.signalId,
})
).rejects.toThrow(/membership required/u);
});
});

View File

@@ -1,8 +1,82 @@
import {
projectIssueDraftFromSignal,
queueProjectIssue,
validateProjectIssueDraft,
type ProjectIssueDraft,
} from "@code/primitives/project-issue";
import { ConvexError, v } from "convex/values";
import { Effect } from "effect";
import { mutation, query } from "./_generated/server";
import type { Id } from "./_generated/dataModel";
import { mutation, type MutationCtx, query } from "./_generated/server";
import { requireProjectMember } from "./authz";
const toDomainError = (error: unknown) => {
return new ConvexError(
error instanceof Error ? error.message : "Invalid project issue"
);
};
const decodeDraft = async (input: unknown): Promise<ProjectIssueDraft> => {
try {
return await Effect.runPromise(validateProjectIssueDraft(input));
} catch (error) {
throw toDomainError(error);
}
};
const draftFromSignal = async (input: unknown): Promise<ProjectIssueDraft> => {
try {
return await Effect.runPromise(projectIssueDraftFromSignal(input));
} catch (error) {
throw toDomainError(error);
}
};
const insertIssue = async (
ctx: MutationCtx,
projectId: Id<"projects">,
draft: ProjectIssueDraft
): Promise<Id<"projectIssues">> => {
const latest = await ctx.db
.query("projectIssues")
.withIndex("by_project_and_number", (q) => q.eq("projectId", projectId))
.order("desc")
.first();
const timestamp = Date.now();
const number = (latest?.number ?? 0) + 1;
const issueId = await ctx.db.insert("projectIssues", {
body: draft.body,
createdAt: timestamp,
number,
projectId,
status: "open",
title: draft.title,
updatedAt: timestamp,
});
const workArtifact = await ctx.db
.query("projectArtifacts")
.withIndex("by_project_and_path", (q) =>
q.eq("projectId", projectId).eq("path", "work.md")
)
.unique();
if (workArtifact) {
await ctx.db.patch("projectArtifacts", workArtifact._id, {
content: `${workArtifact.content}\n## Issue ${number}: ${draft.title}\n\nStatus: open\n\n${draft.body}\n`,
revision: workArtifact.revision + 1,
updatedAt: timestamp,
});
}
await ctx.db.insert("projectEvents", {
createdAt: timestamp,
data: { number, title: draft.title },
issueId,
kind: "issue.created",
projectId,
});
return issueId;
};
export const create = mutation({
args: {
body: v.string(),
@@ -11,55 +85,36 @@ export const create = mutation({
},
handler: async (ctx, args) => {
await requireProjectMember(ctx, args.projectId);
const title = args.title.trim();
const body = args.body.trim();
if (title.length < 3 || title.length > 160) {
throw new ConvexError("Issue title must be between 3 and 160 characters");
const draft = await decodeDraft({ body: args.body, title: args.title });
return insertIssue(ctx, args.projectId, draft);
},
});
export const createFromSignal = mutation({
args: { signalId: v.id("signals") },
handler: async (ctx, args) => {
const signal = await ctx.db.get("signals", args.signalId);
if (!signal) {
throw new ConvexError("Signal not found");
}
if (body.length < 10 || body.length > 10_000) {
if (!signal.projectId) {
throw new ConvexError("Signal is not project-scoped");
}
const { organizationId } = await requireProjectMember(
ctx,
signal.projectId
);
if (signal.organizationId !== organizationId) {
throw new ConvexError(
"Issue description must be between 10 and 10000 characters"
"Signal does not belong to the project organization"
);
}
const latest = await ctx.db
.query("projectIssues")
.withIndex("by_project_and_number", (q) =>
q.eq("projectId", args.projectId)
)
.order("desc")
.first();
const timestamp = Date.now();
const number = (latest?.number ?? 0) + 1;
const issueId = await ctx.db.insert("projectIssues", {
body,
createdAt: timestamp,
number,
projectId: args.projectId,
status: "open",
title,
updatedAt: timestamp,
const draft = await draftFromSignal({
problemStatement: signal.problemStatement,
signalId: String(signal._id),
});
const workArtifact = await ctx.db
.query("projectArtifacts")
.withIndex("by_project_and_path", (q) =>
q.eq("projectId", args.projectId).eq("path", "work.md")
)
.unique();
if (workArtifact) {
await ctx.db.patch("projectArtifacts", workArtifact._id, {
content: `${workArtifact.content}\n## Issue ${number}: ${title}\n\nStatus: open\n\n${body}\n`,
revision: workArtifact.revision + 1,
updatedAt: timestamp,
});
}
await ctx.db.insert("projectEvents", {
createdAt: timestamp,
data: { number, title },
issueId,
kind: "issue.created",
projectId: args.projectId,
});
return issueId;
const issueId = await insertIssue(ctx, signal.projectId, draft);
return { issueId, projectId: signal.projectId };
},
});
@@ -71,12 +126,18 @@ export const begin = mutation({
throw new ConvexError("Issue not found");
}
await requireProjectMember(ctx, issue.projectId);
if (issue.status === "queued" || issue.status === "working") {
return issue.status;
let status: "queued" | "working";
try {
status = await Effect.runPromise(queueProjectIssue(issue.status));
} catch (error) {
throw toDomainError(error);
}
if (status === issue.status) {
return status;
}
const timestamp = Date.now();
await ctx.db.patch("projectIssues", issue._id, {
status: "queued",
status,
updatedAt: timestamp,
});
await ctx.db.insert("projectEvents", {
@@ -130,3 +191,21 @@ export const list = query({
.take(100);
},
});
export const events = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
try {
await requireProjectMember(ctx, args.projectId);
} catch {
return [];
}
return await ctx.db
.query("projectEvents")
.withIndex("by_project_and_createdAt", (q) =>
q.eq("projectId", args.projectId)
)
.order("desc")
.take(100);
},
});

View File

@@ -12,7 +12,11 @@ import type { Id } from "./_generated/dataModel";
import { action, internalMutation, query } from "./_generated/server";
import { createInitialArtifacts } from "./artifactModel";
import { requireCurrentOrganization } from "./authz";
import { persistPublicGitImportTransaction } from "./projectStore";
import {
makeProjectMutationStore,
makeProjectQueryStore,
persistPublicGitImportTransaction,
} from "./projectStore";
// ---------------------------------------------------------------------------
// Internal: persist a public Git import in one transaction
@@ -146,7 +150,6 @@ export const putContextDocument = internalMutation({
),
},
handler: async (ctx, args) => {
const { makeProjectMutationStore } = await import("./projectStore");
const store = makeProjectMutationStore(ctx);
return store.putContext(args.userId, args.projectId, args.write as never);
},
@@ -160,7 +163,6 @@ export const list = query({
args: {},
handler: async (ctx): Promise<ProjectView[]> => {
const { userId } = await requireCurrentOrganization(ctx);
const { makeProjectQueryStore } = await import("./projectStore");
const store = makeProjectQueryStore(ctx);
return store.listProjects(userId);
},
@@ -174,7 +176,6 @@ export const get = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args): Promise<ProjectView | null> => {
const { userId } = await requireCurrentOrganization(ctx);
const { makeProjectQueryStore } = await import("./projectStore");
const store = makeProjectQueryStore(ctx);
return store.getProject(userId, args.projectId);
},
@@ -216,7 +217,6 @@ export const putText = internalMutation({
},
handler: async (ctx, args) => {
const { userId } = await requireCurrentOrganization(ctx);
const { makeProjectMutationStore } = await import("./projectStore");
const store = makeProjectMutationStore(ctx);
return store.putContext(userId, args.projectId, {
_tag: "UserText" as const,

View File

@@ -0,0 +1,125 @@
import {
CONTEXT_KINDS,
contextPathForKind,
MAX_CONTEXT_DOCUMENT_CHARACTERS,
type PreparedPublicGitSource,
type PublicGitImportResult,
type RepositoryContextDocument,
} from "@code/primitives/project";
const GITHUB_HOST = "github.com";
const REQUEST_HEADERS = {
Accept: "application/vnd.github+json",
"User-Agent": "zopu-project-importer",
} as const;
const candidatePaths = {
agents: ["AGENTS.md"],
business: ["business.md", "docs/business.md"],
design: ["design.md", "docs/design.md"],
product: ["product.md", "docs/product.md"],
readme: ["README.md", "README", "readme.md"],
tech: ["tech.md", "docs/tech.md"],
} as const;
const encodePath = (path: string) =>
path
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
const fetchTextCandidate = async (
repositoryPath: string,
branch: string,
path: string
): Promise<string | null> => {
const url = `https://raw.githubusercontent.com/${encodePath(repositoryPath)}/${encodePath(branch)}/${encodePath(path)}`;
const response = await fetch(url, { headers: REQUEST_HEADERS });
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`GitHub returned ${response.status} for ${path}`);
}
const content = await response.text();
if (content.trim().length === 0) {
return null;
}
if (content.length > MAX_CONTEXT_DOCUMENT_CHARACTERS) {
throw new Error(`${path} exceeds the 200000 character import limit`);
}
return content;
};
const fetchContextDocument = async (
source: PreparedPublicGitSource,
branch: string,
kind: (typeof CONTEXT_KINDS)[number]
): Promise<RepositoryContextDocument | null> => {
for (const candidate of candidatePaths[kind]) {
const content = await fetchTextCandidate(
source.repositoryPath,
branch,
candidate
);
if (content) {
return {
content,
kind,
path: contextPathForKind(kind),
};
}
}
return null;
};
const readDefaultBranch = async (
source: PreparedPublicGitSource
): Promise<string> => {
if (source.host !== GITHUB_HOST) {
throw new Error("Repository import currently supports public GitHub URLs");
}
const response = await fetch(
`https://api.github.com/repos/${encodePath(source.repositoryPath)}`,
{ headers: REQUEST_HEADERS }
);
if (!response.ok) {
throw new Error(
response.status === 404
? "Public GitHub repository not found"
: `GitHub returned ${response.status} while reading the repository`
);
}
const metadata = (await response.json()) as { default_branch?: unknown };
if (
typeof metadata.default_branch !== "string" ||
metadata.default_branch.length === 0
) {
throw new Error("GitHub did not return a default branch");
}
return metadata.default_branch;
};
export const inspectPublicGit = async (
source: PreparedPublicGitSource
): Promise<PublicGitImportResult> => {
const defaultBranch = await readDefaultBranch(source);
const candidates = await Promise.all(
CONTEXT_KINDS.map((kind) =>
fetchContextDocument(source, defaultBranch, kind)
)
);
const documents = candidates.filter(
(document): document is RepositoryContextDocument => document !== null
);
return {
defaultBranch,
documents,
warnings: CONTEXT_KINDS.filter(
(kind) => !documents.some((document) => document.kind === kind)
).map((kind) => ({
message: "Canonical context file was not found in the repository",
path: contextPathForKind(kind),
})),
};
};

View File

@@ -143,11 +143,15 @@ export default defineSchema({
.index("by_project_and_number", ["projectId", "number"])
.index("by_project_and_status", ["projectId", "status"]),
projectWorkRuns: defineTable({
baseBranch: v.optional(v.string()),
branchName: v.optional(v.string()),
checkoutPath: v.optional(v.string()),
projectId: v.id("projects"),
issueId: v.id("projectIssues"),
agentId: v.string(),
daemonId: v.string(),
actorKey: v.array(v.string()),
sourceUrl: v.optional(v.string()),
status: v.union(
v.literal("ready"),
v.literal("queued"),
@@ -278,6 +282,23 @@ export default defineSchema({
"messageId",
]),
// -----------------------------------------------------------------
// Signal-to-issue attachments. A proper relation that links one Signal
// to one ProjectIssue, idempotent by (signalId, issueId). Multiple
// Signals may attach to the same ProjectIssue. The relation is scoped
// by organization and project to prevent cross-tenant leakage.
// -----------------------------------------------------------------
signalIssueAttachments: defineTable({
signalId: v.id("signals"),
issueId: v.id("projectIssues"),
organizationId: v.id("organizations"),
projectId: v.id("projects"),
createdAt: v.number(),
})
.index("by_signal", ["signalId"])
.index("by_issue", ["issueId"])
.index("by_signal_and_issue", ["signalId", "issueId"]),
// -----------------------------------------------------------------
// Flue persistence stores (schema/format version 4).
// -----------------------------------------------------------------

View File

@@ -0,0 +1,552 @@
import { env } from "@code/env/convex";
import { type TestConvex, convexTest } from "convex-test";
import { anyApi } from "convex/server";
import { describe, expect, test } from "vitest";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import schema from "./schema";
declare global {
interface ImportMeta {
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
}
}
const modules = import.meta.glob("./**/*.ts");
const api = anyApi;
const TOKEN = env.FLUE_DB_TOKEN;
const BAD_TOKEN = "not-the-right-token";
const ID_A = "https://convex.test|user-a";
const identityA = { tokenIdentifier: ID_A };
const newTest = () => convexTest({ schema, modules });
const ensureOrg = async (t: TestConvex<typeof schema>) => {
const org = await t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization, {});
return org._id as Id<"organizations">;
};
const problemStatement = {
title: "Deploy fails on staging",
summary: "The staging deploy command exits with a DNS resolution error.",
desiredOutcome: "Staging deploys successfully without DNS errors.",
constraints: ["Must not change the production pipeline"],
};
const seedMessage = async (
t: TestConvex<typeof schema>,
orgId: Id<"organizations">,
clientRequestId: string,
rawText: string
): Promise<string> => {
const begun = await t
.withIdentity(identityA)
.mutation(api.conversationMessages.beginUserMessage, {
clientRequestId,
organizationId: orgId,
rawText,
});
await t
.withIdentity(identityA)
.mutation(api.conversationMessages.markAdmitted, {
clientRequestId,
organizationId: orgId,
submissionId: `sub-${clientRequestId}`,
});
return begun.messageId;
};
const createProject = async (
t: TestConvex<typeof schema>,
ownerId: string,
repoName: string
): Promise<Id<"projects">> => {
const ownerIdentity = { tokenIdentifier: ownerId };
await t
.withIdentity(ownerIdentity)
.mutation(api.organizations.ensurePersonalOrganization);
const outcome = await t
.withIdentity(ownerIdentity)
.mutation(internal.projects.persistPublicGitImport, {
userId: ownerId,
source: {
host: "github.com",
projectName: repoName,
repositoryPath: `owner/${repoName}`,
normalizedUrl: `https://github.com/owner/${repoName}`,
url: `https://github.com/owner/${repoName}`,
},
remote: {
defaultBranch: "main",
documents: [
{
kind: "readme" as const,
path: "README.md",
content: `# ${repoName}\n\nTest.\n`,
},
],
warnings: [],
},
});
return outcome.id as unknown as Id<"projects">;
};
// Create a project-scoped signal via the routing backend.
const createRoutingSignal = async (
t: TestConvex<typeof schema>,
orgId: Id<"organizations">,
projectId: Id<"projects">,
messageIds: string[]
): Promise<Id<"signals">> => {
const result = await t.mutation(api.signalRouting.createSignal, {
messageIds,
organizationId: orgId,
problemStatement,
processedByAgentInstanceId: orgId,
projectId,
token: TOKEN,
});
return result.signalId as Id<"signals">;
};
describe("signalRouting authentication", () => {
test("invalid token is rejected on every function", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "auth-test");
const messageId = await seedMessage(t, orgId, "req-auth", "test message");
await expect(
t.mutation(api.signalRouting.createSignal, {
messageIds: [messageId],
organizationId: orgId,
problemStatement,
processedByAgentInstanceId: orgId,
projectId,
token: BAD_TOKEN,
})
).rejects.toThrow(/Invalid agent control token/u);
await expect(
t.query(api.signalRouting.listEvidence, {
organizationId: orgId,
token: BAD_TOKEN,
})
).rejects.toThrow(/Invalid agent control token/u);
await expect(
t.query(api.signalRouting.listActiveIssues, {
organizationId: orgId,
projectId,
token: BAD_TOKEN,
})
).rejects.toThrow(/Invalid agent control token/u);
await expect(
t.query(api.signalRouting.listProjects, {
organizationId: orgId,
token: BAD_TOKEN,
})
).rejects.toThrow(/Invalid agent control token/u);
});
});
describe("signalRouting create and route", () => {
test("create signal, list evidence, and list signals", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "route-test");
const messageId = await seedMessage(
t,
orgId,
"req-route",
"The work-unit card should expose the generated PR."
);
// Evidence appears before signal creation.
const evidenceBefore = await t.query(api.signalRouting.listEvidence, {
organizationId: orgId,
token: TOKEN,
});
expect(evidenceBefore).toHaveLength(1);
expect(evidenceBefore[0]?.rawText).toBe(
"The work-unit card should expose the generated PR."
);
// Create the signal.
const signalId = await createRoutingSignal(t, orgId, projectId, [
messageId,
]);
// Evidence is consumed after signal creation.
const evidenceAfter = await t.query(api.signalRouting.listEvidence, {
organizationId: orgId,
token: TOKEN,
});
expect(evidenceAfter).toHaveLength(0);
// Signal appears in the project list.
const signals = await t.query(api.signalRouting.listSignals, {
organizationId: orgId,
projectId,
token: TOKEN,
});
expect(signals).toHaveLength(1);
expect(signals[0]?._id).toBe(signalId);
expect(signals[0]?.problemStatement.title).toBe("Deploy fails on staging");
});
test("attach signal to existing issue", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "attach-test");
const messageId = await seedMessage(t, orgId, "req-attach", "problem");
const signalId = await createRoutingSignal(t, orgId, projectId, [
messageId,
]);
// Create an issue via the existing projectIssues backend.
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {
body: "Existing issue body that is long enough.",
projectId,
title: "Existing UI issue",
});
const result = await t.mutation(api.signalRouting.attachSignalToIssue, {
issueId: issueId as string,
organizationId: orgId,
signalId: signalId as string,
token: TOKEN,
});
expect(result.alreadyAttached).toBe(false);
// List active issues includes the attached issue.
const issues = await t.query(api.signalRouting.listActiveIssues, {
organizationId: orgId,
projectId,
token: TOKEN,
});
expect(issues).toHaveLength(1);
expect(issues[0]?._id).toBe(issueId);
});
test("create issue from signal (attach-vs-create fallback)", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "create-test");
const messageId = await seedMessage(t, orgId, "req-create", "new problem");
const signalId = await createRoutingSignal(t, orgId, projectId, [
messageId,
]);
const result = await t.mutation(api.signalRouting.createIssueFromSignal, {
organizationId: orgId,
signalId: signalId as string,
token: TOKEN,
});
expect(result.created).toBe(true);
expect(result.projectId).toBe(projectId);
// The issue exists and is open.
const issues = await t.query(api.signalRouting.listActiveIssues, {
organizationId: orgId,
projectId,
token: TOKEN,
});
expect(issues).toHaveLength(1);
expect(issues[0]?.title).toBe("Deploy fails on staging");
});
});
describe("signalRouting idempotency", () => {
test("duplicate attach retry returns existing without duplicate event", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "idem-attach");
const messageId = await seedMessage(t, orgId, "req-idem-att", "problem");
const signalId = await createRoutingSignal(t, orgId, projectId, [
messageId,
]);
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {
body: "Existing issue body that is long enough for validation.",
projectId,
title: "Pre-existing issue",
});
// First attach.
const first = await t.mutation(api.signalRouting.attachSignalToIssue, {
issueId: issueId as string,
organizationId: orgId,
signalId: signalId as string,
token: TOKEN,
});
expect(first.alreadyAttached).toBe(false);
// Retry the same attach.
const second = await t.mutation(api.signalRouting.attachSignalToIssue, {
issueId: issueId as string,
organizationId: orgId,
signalId: signalId as string,
token: TOKEN,
});
expect(second.alreadyAttached).toBe(true);
expect(second.attachmentId).toBe(first.attachmentId);
// Verify only one signal.attached event was emitted (not duplicated).
const events = await t
.withIdentity(identityA)
.query(api.projectIssues.events, { projectId });
const attached = events.filter((e: { kind: string }) =>
e.kind.includes("signal.attached")
);
expect(attached).toHaveLength(1);
});
test("duplicate create-from-signal retry returns existing issue", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "idem-create");
const messageId = await seedMessage(t, orgId, "req-idem-crt", "problem");
const signalId = await createRoutingSignal(t, orgId, projectId, [
messageId,
]);
// First create.
const first = await t.mutation(api.signalRouting.createIssueFromSignal, {
organizationId: orgId,
signalId: signalId as string,
token: TOKEN,
});
expect(first.created).toBe(true);
// Retry — should return existing, not create a new issue.
const second = await t.mutation(api.signalRouting.createIssueFromSignal, {
organizationId: orgId,
signalId: signalId as string,
token: TOKEN,
});
expect(second.created).toBe(false);
expect(second.issueId).toBe(first.issueId);
// Only one issue exists.
const issues = await t.query(api.signalRouting.listActiveIssues, {
organizationId: orgId,
projectId,
token: TOKEN,
});
expect(issues).toHaveLength(1);
});
test("duplicate signal creation is idempotent (sourceKey)", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "idem-signal");
const messageId = await seedMessage(t, orgId, "req-idem-sig", "test");
const first = await t.mutation(api.signalRouting.createSignal, {
messageIds: [messageId],
organizationId: orgId,
problemStatement,
processedByAgentInstanceId: orgId,
projectId,
token: TOKEN,
});
const second = await t.mutation(api.signalRouting.createSignal, {
messageIds: [messageId],
organizationId: orgId,
problemStatement,
processedByAgentInstanceId: orgId,
projectId,
token: TOKEN,
});
expect(second.signalId).toBe(first.signalId);
});
});
describe("signalRouting cross-project rejection", () => {
test("attach rejects when signal and issue are in different projects", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectA = await createProject(t, ID_A, "cross-a");
const projectB = await createProject(t, ID_A, "cross-b");
const messageId = await seedMessage(t, orgId, "req-cross", "problem");
// Signal is scoped to project A.
const signalId = await createRoutingSignal(t, orgId, projectA, [messageId]);
// Issue is in project B.
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {
body: "Issue in project B with enough body text.",
projectId: projectB,
title: "Project B issue",
});
await expect(
t.mutation(api.signalRouting.attachSignalToIssue, {
issueId: issueId as string,
organizationId: orgId,
signalId: signalId as string,
token: TOKEN,
})
).rejects.toThrow(/must belong to the same project/u);
});
test("attach rejects org-scoped signal (no projectId)", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "org-scope-test");
const messageId = await seedMessage(t, orgId, "req-org", "problem");
// Create an org-scoped signal (no projectId).
const signalId = await t.mutation(api.signalRouting.createSignal, {
messageIds: [messageId],
organizationId: orgId,
problemStatement,
processedByAgentInstanceId: orgId,
token: TOKEN,
});
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {
body: "Issue body that is long enough for the validator.",
projectId,
title: "Some issue",
});
await expect(
t.mutation(api.signalRouting.attachSignalToIssue, {
issueId: issueId as string,
organizationId: orgId,
signalId: signalId.signalId as string,
token: TOKEN,
})
).rejects.toThrow(/not project-scoped/u);
});
});
describe("signalRouting provenance", () => {
test("exact message raw text is preserved through the routing loop", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "prov-test");
const rawText = " The work-unit card should expose the generated PR. ";
const messageId = await seedMessage(t, orgId, "req-prov", rawText);
// Evidence shows exact raw text.
const evidence = await t.query(api.signalRouting.listEvidence, {
organizationId: orgId,
token: TOKEN,
});
expect(evidence[0]?.rawText).toBe(rawText);
// Signal creation preserves exact snapshot.
const signalId = await createRoutingSignal(t, orgId, projectId, [
messageId,
]);
// Verify via the existing signals.get that the snapshot is exact.
const composed = await t
.withIdentity(identityA)
.query(api.signals.get, { signalId });
expect(composed?.sources[0]?.rawTextSnapshot).toBe(rawText);
});
});
describe("signalRouting begin issue", () => {
test("begin transitions an open issue to queued", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "begin-test");
const messageId = await seedMessage(t, orgId, "req-begin", "start this");
const signalId = await createRoutingSignal(t, orgId, projectId, [
messageId,
]);
const result = await t.mutation(api.signalRouting.createIssueFromSignal, {
organizationId: orgId,
signalId: signalId as string,
token: TOKEN,
});
const status = await t.mutation(api.signalRouting.beginIssue, {
issueId: result.issueId as string,
organizationId: orgId,
token: TOKEN,
});
expect(status).toBe("queued");
});
test("begin rejects invalid token", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "begin-auth");
const messageId = await seedMessage(t, orgId, "req-begin-auth", "msg");
const signalId = await createRoutingSignal(t, orgId, projectId, [
messageId,
]);
const result = await t.mutation(api.signalRouting.createIssueFromSignal, {
organizationId: orgId,
signalId: signalId as string,
token: TOKEN,
});
await expect(
t.mutation(api.signalRouting.beginIssue, {
issueId: result.issueId as string,
organizationId: orgId,
token: BAD_TOKEN,
})
).rejects.toThrow(/Invalid agent control token/u);
});
});
describe("signalRouting project context", () => {
test("get project context returns project and documents", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "ctx-test");
const context = await t.query(api.signalRouting.getProjectContext, {
organizationId: orgId,
projectId,
token: TOKEN,
});
expect(context.project._id).toBe(projectId);
expect(context.project.name).toBe("ctx-test");
expect(context.contextDocuments.length).toBeGreaterThan(0);
});
test("list projects returns organization projects", async () => {
const t = newTest();
const orgId = await ensureOrg(t);
const projectId = await createProject(t, ID_A, "list-proj-test");
const projects = await t.query(api.signalRouting.listProjects, {
organizationId: orgId,
token: TOKEN,
});
expect(projects).toHaveLength(1);
expect(projects[0]?._id).toBe(projectId);
});
});

View File

@@ -0,0 +1,721 @@
import { env } from "@code/env/convex";
import {
projectIssueDraftFromSignal,
queueProjectIssue,
type ProjectIssueDraft,
} from "@code/primitives/project-issue";
import { composeSignal, SignalValidationError } from "@code/primitives/signal";
import { ConvexError, v } from "convex/values";
import { Effect } from "effect";
import type { Doc, Id } from "./_generated/dataModel";
import {
mutation,
type MutationCtx,
query,
type QueryCtx,
} from "./_generated/server";
// ---------------------------------------------------------------------------
// Agent token guard (service-level auth for Zopu tools).
// ---------------------------------------------------------------------------
const requireAgent = (token: string) => {
if (token !== env.FLUE_DB_TOKEN) {
throw new ConvexError("Invalid agent control token");
}
};
const PROBLEM_STATEMENT = v.object({
title: v.string(),
summary: v.string(),
desiredOutcome: v.string(),
constraints: v.array(v.string()),
});
// ---------------------------------------------------------------------------
// View helpers
// ---------------------------------------------------------------------------
interface EvidenceMessage {
readonly messageId: string;
readonly rawText: string;
readonly createdAt: number;
readonly submissionId: string | null;
}
interface ActiveIssueView {
readonly _id: Id<"projectIssues">;
readonly number: number;
readonly title: string;
readonly body: string;
readonly status: string;
readonly projectId: Id<"projects">;
readonly updatedAt: number;
}
interface ProjectSummaryView {
readonly _id: Id<"projects">;
readonly name: string;
readonly description: string | null;
readonly organizationId: Id<"organizations">;
}
interface ContextDocumentView {
readonly kind: string;
readonly path: string;
readonly content: string;
readonly revision: number;
}
interface SignalSummaryView {
readonly _id: Id<"signals">;
readonly problemStatement: {
readonly title: string;
readonly summary: string;
readonly desiredOutcome: string;
readonly constraints: readonly string[];
};
readonly projectId: Id<"projects"> | null;
readonly createdAt: number;
}
// ---------------------------------------------------------------------------
// Organization + project authorization (agent-gated, no user identity).
// The agent instance id IS the organization id; the tool never supplies
// tenancy. We verify the organization exists and the project belongs to it.
// ---------------------------------------------------------------------------
const authorizeProjectInOrg = async (
ctx: MutationCtx | QueryCtx,
organizationId: Id<"organizations">,
projectId: Id<"projects">
): Promise<Doc<"projects">> => {
const project = await ctx.db.get(projectId);
if (!project) {
throw new ConvexError("Project not found");
}
if (project.organizationId !== organizationId) {
throw new ConvexError("Project does not belong to the target organization");
}
return project;
};
const authorizeSignalInOrg = async (
ctx: MutationCtx | QueryCtx,
organizationId: Id<"organizations">,
signalId: Id<"signals">
): Promise<Doc<"signals">> => {
const signal = await ctx.db.get(signalId);
if (!signal) {
throw new ConvexError("Signal not found");
}
if (signal.organizationId !== organizationId) {
throw new ConvexError("Signal does not belong to the target organization");
}
return signal;
};
// ---------------------------------------------------------------------------
// 1. List admitted user messages not yet consumed by a Signal.
// ---------------------------------------------------------------------------
export const listEvidence = query({
args: {
organizationId: v.id("organizations"),
token: v.string(),
},
handler: async (ctx, args): Promise<EvidenceMessage[]> => {
requireAgent(args.token);
const conversationId = args.organizationId;
const admitted = await ctx.db
.query("conversationMessages")
.withIndex("by_organization_and_message", (q) =>
q
.eq("organizationId", args.organizationId)
.eq("conversationId", conversationId)
)
.order("desc")
.take(100);
const candidates = admitted.filter(
(message) => message.role === "user" && message.status === "admitted"
);
const unused: EvidenceMessage[] = [];
for (const message of candidates) {
const consumed = await ctx.db
.query("signalSources")
.withIndex("by_organization_and_message", (q) =>
q
.eq("organizationId", args.organizationId)
.eq("conversationId", conversationId)
.eq("messageId", message.messageId)
)
.first();
if (!consumed) {
unused.push({
createdAt: message.createdAt,
messageId: message.messageId,
rawText: message.rawText,
submissionId: message.submissionId ?? null,
});
}
}
return unused;
},
});
// ---------------------------------------------------------------------------
// 2. Create a Signal from selected messages + structured problem statement.
// ---------------------------------------------------------------------------
const buildSourceKey = (
organizationId: Id<"organizations">,
conversationId: string,
messageIds: readonly string[]
): string => JSON.stringify([organizationId, conversationId, ...messageIds]);
const resolveSources = async (
ctx: MutationCtx,
organizationId: Id<"organizations">,
conversationId: string,
messageIds: readonly string[]
): Promise<Doc<"conversationMessages">[]> => {
if (messageIds.length === 0) {
throw new ConvexError("At least one source message is required");
}
if (new Set(messageIds).size !== messageIds.length) {
throw new ConvexError("Source message ids must be unique");
}
const resolved: Doc<"conversationMessages">[] = [];
for (const messageId of messageIds) {
const doc = await ctx.db
.query("conversationMessages")
.withIndex("by_organization_and_message", (q) =>
q
.eq("organizationId", organizationId)
.eq("conversationId", conversationId)
.eq("messageId", messageId)
)
.unique();
if (!doc) {
throw new ConvexError(`Source message not found: ${messageId}`);
}
if (doc.role !== "user") {
throw new ConvexError(
`Source message is not a user message: ${messageId}`
);
}
if (doc.status !== "admitted") {
throw new ConvexError(`Source message is not admitted: ${messageId}`);
}
resolved.push(doc);
}
return resolved;
};
export const createSignal = mutation({
args: {
organizationId: v.id("organizations"),
projectId: v.optional(v.id("projects")),
messageIds: v.array(v.string()),
problemStatement: PROBLEM_STATEMENT,
processedByAgentInstanceId: v.string(),
token: v.string(),
},
handler: async (ctx, args): Promise<{ signalId: Id<"signals"> }> => {
requireAgent(args.token);
if (args.projectId) {
await authorizeProjectInOrg(ctx, args.organizationId, args.projectId);
}
const conversationId = args.organizationId;
if (conversationId !== args.organizationId) {
throw new ConvexError("Conversation does not belong to organization");
}
const sourceKey = buildSourceKey(
args.organizationId,
conversationId,
args.messageIds
);
const existing = await ctx.db
.query("signals")
.withIndex("by_organization_and_sourceKey", (q) =>
q.eq("organizationId", args.organizationId).eq("sourceKey", sourceKey)
)
.unique();
if (existing) {
return { signalId: existing._id };
}
const sources = await resolveSources(
ctx,
args.organizationId,
conversationId,
args.messageIds
);
const now = Date.now();
const prospectiveId = crypto.randomUUID();
const scope =
args.projectId === undefined
? { _tag: "Organization" as const, organizationId: args.organizationId }
: {
_tag: "Project" as const,
organizationId: args.organizationId,
projectId: args.projectId,
};
const signal = await Effect.runPromise(
composeSignal({
createdAt: now,
id: prospectiveId,
problemStatement: args.problemStatement,
processedBy: {
agentInstanceId: args.processedByAgentInstanceId,
agentName: "zopu",
},
scope,
sourceMessages: sources.map((doc) => ({
conversationId: doc.conversationId,
createdAt: doc.createdAt,
messageId: doc.messageId,
rawText: doc.rawText,
})),
})
).catch((error: unknown) => {
if (error instanceof SignalValidationError) {
throw new ConvexError(`Invalid signal: ${error.message}`);
}
throw new ConvexError(
`Invalid signal: ${error instanceof Error ? error.message : "unknown"}`
);
});
const signalId = await ctx.db.insert("signals", {
conversationId,
createdAt: now,
organizationId: args.organizationId,
problemStatement: {
constraints: [...signal.problemStatement.constraints],
desiredOutcome: signal.problemStatement.desiredOutcome,
summary: signal.problemStatement.summary,
title: signal.problemStatement.title,
},
processedByAgentInstanceId: args.processedByAgentInstanceId,
processedByAgentName: "zopu",
...(args.projectId === undefined ? {} : { projectId: args.projectId }),
sourceKey,
});
await Promise.all(
sources.map((doc, ordinal) =>
ctx.db.insert("signalSources", {
conversationId: doc.conversationId,
messageId: doc.messageId,
ordinal,
organizationId: args.organizationId,
rawTextSnapshot: doc.rawText,
signalId,
...(doc.submissionId === undefined
? {}
: { submissionId: doc.submissionId }),
sourceCreatedAt: doc.createdAt,
})
)
);
return { signalId };
},
});
// ---------------------------------------------------------------------------
// 3. List recent Signals for a project (or organization-wide when no project).
// ---------------------------------------------------------------------------
export const listSignals = query({
args: {
organizationId: v.id("organizations"),
projectId: v.optional(v.id("projects")),
token: v.string(),
},
handler: async (ctx, args): Promise<SignalSummaryView[]> => {
requireAgent(args.token);
if (args.projectId) {
await authorizeProjectInOrg(ctx, args.organizationId, args.projectId);
const signals = await ctx.db
.query("signals")
.withIndex("by_project_and_createdAt", (q) =>
q.eq("projectId", args.projectId as Id<"projects">)
)
.order("desc")
.take(20);
return signals.map((s) => ({
_id: s._id,
createdAt: s.createdAt,
problemStatement: s.problemStatement,
projectId: s.projectId ?? null,
}));
}
const signals = await ctx.db
.query("signals")
.withIndex("by_organization_and_createdAt", (q) =>
q.eq("organizationId", args.organizationId)
)
.order("desc")
.take(20);
return signals.map((s) => ({
_id: s._id,
createdAt: s.createdAt,
problemStatement: s.problemStatement,
projectId: s.projectId ?? null,
}));
},
});
// ---------------------------------------------------------------------------
// 4. List active (open/queued/working/needs-input) ProjectIssues for a project.
// ---------------------------------------------------------------------------
export const listActiveIssues = query({
args: {
organizationId: v.id("organizations"),
projectId: v.id("projects"),
token: v.string(),
},
handler: async (ctx, args): Promise<ActiveIssueView[]> => {
requireAgent(args.token);
await authorizeProjectInOrg(ctx, args.organizationId, args.projectId);
const issues = await ctx.db
.query("projectIssues")
.withIndex("by_project_and_status", (q) =>
q.eq("projectId", args.projectId)
)
.take(100);
return issues
.filter(
(issue) =>
issue.status === "open" ||
issue.status === "queued" ||
issue.status === "working" ||
issue.status === "needs-input"
)
.map((issue) => ({
_id: issue._id,
body: issue.body,
number: issue.number,
projectId: issue.projectId,
status: issue.status,
title: issue.title,
updatedAt: issue.updatedAt,
}))
.sort((a, b) => b.updatedAt - a.updatedAt);
},
});
// ---------------------------------------------------------------------------
// 5. Attach a Signal to an existing ProjectIssue (idempotent).
// ---------------------------------------------------------------------------
export const attachSignalToIssue = mutation({
args: {
organizationId: v.id("organizations"),
signalId: v.id("signals"),
issueId: v.id("projectIssues"),
token: v.string(),
},
handler: async (
ctx,
args
): Promise<{
attachmentId: Id<"signalIssueAttachments">;
alreadyAttached: boolean;
}> => {
requireAgent(args.token);
const signal = await authorizeSignalInOrg(
ctx,
args.organizationId,
args.signalId
);
const issue = await ctx.db.get(args.issueId);
if (!issue) {
throw new ConvexError("Issue not found");
}
await authorizeProjectInOrg(ctx, args.organizationId, issue.projectId);
// Cross-project rejection: the signal must be project-scoped and its
// project must match the issue's project. Organization equality alone is
// insufficient — it would permit attaching across different projects in
// the same org.
if (!signal.projectId) {
throw new ConvexError("Signal is not project-scoped");
}
if (signal.projectId !== issue.projectId) {
throw new ConvexError("Signal and issue must belong to the same project");
}
// Idempotent: if the attachment already exists, return it without
// emitting a duplicate event.
const existing = await ctx.db
.query("signalIssueAttachments")
.withIndex("by_signal_and_issue", (q) =>
q.eq("signalId", args.signalId).eq("issueId", args.issueId)
)
.unique();
if (existing) {
return {
alreadyAttached: true,
attachmentId: existing._id,
};
}
const now = Date.now();
const attachmentId = await ctx.db.insert("signalIssueAttachments", {
createdAt: now,
issueId: args.issueId,
organizationId: args.organizationId,
projectId: issue.projectId,
signalId: args.signalId,
});
await ctx.db.insert("projectEvents", {
createdAt: now,
data: {
signalProblemTitle: signal.problemStatement.title,
signalId: String(signal._id),
},
issueId: args.issueId,
kind: "signal.attached",
projectId: issue.projectId,
});
return { alreadyAttached: false, attachmentId };
},
});
// ---------------------------------------------------------------------------
// 6. Create a new ProjectIssue from a Signal.
// ---------------------------------------------------------------------------
const insertIssue = async (
ctx: MutationCtx,
projectId: Id<"projects">,
draft: ProjectIssueDraft
): Promise<Id<"projectIssues">> => {
const latest = await ctx.db
.query("projectIssues")
.withIndex("by_project_and_number", (q) => q.eq("projectId", projectId))
.order("desc")
.first();
const timestamp = Date.now();
const number = (latest?.number ?? 0) + 1;
const issueId = await ctx.db.insert("projectIssues", {
body: draft.body,
createdAt: timestamp,
number,
projectId,
status: "open",
title: draft.title,
updatedAt: timestamp,
});
await ctx.db.insert("projectEvents", {
createdAt: timestamp,
data: { number, source: "signal", title: draft.title },
issueId,
kind: "issue.created",
projectId,
});
return issueId;
};
export const createIssueFromSignal = mutation({
args: {
organizationId: v.id("organizations"),
signalId: v.id("signals"),
token: v.string(),
},
handler: async (
ctx,
args
): Promise<{
created: boolean;
issueId: Id<"projectIssues">;
projectId: Id<"projects">;
}> => {
requireAgent(args.token);
const signal = await authorizeSignalInOrg(
ctx,
args.organizationId,
args.signalId
);
if (!signal.projectId) {
throw new ConvexError("Signal is not project-scoped");
}
await authorizeProjectInOrg(ctx, args.organizationId, signal.projectId);
// Idempotent: if any attachment already exists for this signal, return
// the existing issue without creating a duplicate. This prevents Flue
// retries from producing duplicate issues/attachments.
const existingAttachment = await ctx.db
.query("signalIssueAttachments")
.withIndex("by_signal", (q) => q.eq("signalId", args.signalId))
.first();
if (existingAttachment) {
return {
created: false,
issueId: existingAttachment.issueId,
projectId: existingAttachment.projectId,
};
}
const draft = await Effect.runPromise(
projectIssueDraftFromSignal({
problemStatement: signal.problemStatement,
signalId: String(signal._id),
})
).catch((error: unknown) => {
throw new ConvexError(
error instanceof Error ? error.message : "Invalid project issue"
);
});
const issueId = await insertIssue(ctx, signal.projectId, draft);
// Auto-attach the signal to the new issue.
const now = Date.now();
await ctx.db.insert("signalIssueAttachments", {
createdAt: now,
issueId,
organizationId: args.organizationId,
projectId: signal.projectId,
signalId: signal._id,
});
return { created: true, issueId, projectId: signal.projectId };
},
});
// ---------------------------------------------------------------------------
// 7. Begin a ProjectIssue (transition to queued/working).
// ---------------------------------------------------------------------------
export const beginIssue = mutation({
args: {
organizationId: v.id("organizations"),
issueId: v.id("projectIssues"),
token: v.string(),
},
handler: async (ctx, args): Promise<"queued" | "working"> => {
requireAgent(args.token);
const issue = await ctx.db.get(args.issueId);
if (!issue) {
throw new ConvexError("Issue not found");
}
await authorizeProjectInOrg(ctx, args.organizationId, issue.projectId);
const status = await Effect.runPromise(
queueProjectIssue(issue.status)
).catch((error: unknown) => {
throw new ConvexError(
error instanceof Error ? error.message : "Invalid issue status"
);
});
if (status === issue.status) {
return status;
}
const timestamp = Date.now();
await ctx.db.patch(issue._id, {
status,
updatedAt: timestamp,
});
await ctx.db.insert("projectEvents", {
createdAt: timestamp,
data: { number: issue.number },
issueId: issue._id,
kind: "issue.queued",
projectId: issue.projectId,
});
return status;
},
});
// ---------------------------------------------------------------------------
// 8. Get project summary + context documents for routing decisions.
// ---------------------------------------------------------------------------
export const getProjectContext = query({
args: {
organizationId: v.id("organizations"),
projectId: v.id("projects"),
token: v.string(),
},
handler: async (
ctx,
args
): Promise<{
project: ProjectSummaryView;
contextDocuments: ContextDocumentView[];
}> => {
requireAgent(args.token);
const project = await authorizeProjectInOrg(
ctx,
args.organizationId,
args.projectId
);
const docs = await ctx.db
.query("projectContextDocuments")
.withIndex("by_project", (q) => q.eq("projectId", args.projectId))
.take(25);
return {
contextDocuments: docs.map((doc) => ({
content: doc.content,
kind: doc.kind,
path: doc.path,
revision: doc.revision,
})),
project: {
_id: project._id,
description: project.description ?? null,
name: project.name,
organizationId: project.organizationId,
},
};
},
});
// ---------------------------------------------------------------------------
// 9. List projects in the organization (for selecting a project context).
// ---------------------------------------------------------------------------
export const listProjects = query({
args: {
organizationId: v.id("organizations"),
token: v.string(),
},
handler: async (ctx, args): Promise<ProjectSummaryView[]> => {
requireAgent(args.token);
const projects = await ctx.db
.query("projects")
.withIndex("by_organization_and_createdAt", (q) =>
q.eq("organizationId", args.organizationId)
)
.take(50);
return projects.map((p) => ({
_id: p._id,
description: p.description ?? null,
name: p.name,
organizationId: p.organizationId,
}));
},
});

View File

@@ -412,6 +412,69 @@ describe("signals", () => {
expect(list).toHaveLength(2);
});
test("reversed selection preserves agent ordinals and original timestamps", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);
const m1 = await seedMessage(t, identityA, orgId, "req-rev-1", "first");
const m2 = await seedMessage(t, identityA, orgId, "req-rev-2", "second");
// Chronological selection: m1 then m2.
const chrono = await t
.withIdentity(identityA)
.mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: [m1, m2],
organizationId: orgId,
problemStatement,
processedBy,
});
// Reversed selection: m2 then m1 (non-chronological agent order).
const reversed = await t
.withIdentity(identityA)
.mutation(api.signals.createFromMessages, {
conversationId: orgId,
messageIds: [m2, m1],
organizationId: orgId,
problemStatement,
processedBy,
});
expect(reversed.signalId).not.toBe(chrono.signalId);
const chronoSignal = await t
.withIdentity(identityA)
.query(api.signals.get, { signalId: chrono.signalId });
const reversedSignal = await t
.withIdentity(identityA)
.query(api.signals.get, { signalId: reversed.signalId });
// Chronological: ordinals follow selection.
expect(chronoSignal?.sources[0]?.messageId).toBe(m1);
expect(chronoSignal?.sources[0]?.ordinal).toBe(0);
expect(chronoSignal?.sources[1]?.messageId).toBe(m2);
expect(chronoSignal?.sources[1]?.ordinal).toBe(1);
// Reversed: ordinals follow the agent's reversed selection, and each
// source retains its original creation timestamp (unchanged by reordering).
expect(reversedSignal?.sources[0]?.messageId).toBe(m2);
expect(reversedSignal?.sources[0]?.ordinal).toBe(0);
expect(reversedSignal?.sources[1]?.messageId).toBe(m1);
expect(reversedSignal?.sources[1]?.ordinal).toBe(1);
// Exact raw text is preserved in both orderings.
expect(
chronoSignal?.sources.map(
(s: { rawTextSnapshot: string }) => s.rawTextSnapshot
)
).toEqual(["first", "second"]);
expect(
reversedSignal?.sources.map(
(s: { rawTextSnapshot: string }) => s.rawTextSnapshot
)
).toEqual(["second", "first"]);
});
test("creates a project-scoped signal when the project owner is an org member", async () => {
const t = newTest();
const orgId = await ensureOrg(t, identityA);

View File

@@ -9,7 +9,7 @@ import {
type QueryCtx,
query,
} from "./_generated/server";
import { requireOrganizationMember } from "./authz";
import { requireOrganizationMember, requireProjectMember } from "./authz";
const PROBLEM_STATEMENT = v.object({
title: v.string(),
@@ -435,3 +435,71 @@ export const get = query({
};
},
});
// ---------------------------------------------------------------------------
// Linked Signal query for the Work OS surface.
// ---------------------------------------------------------------------------
export interface LinkedSignalView {
readonly signalId: Id<"signals">;
readonly title: string;
readonly summary: string;
readonly sourceCount: number;
readonly createdAt: number;
}
/**
* List all Signal-issue attachments for a project, grouped by issue id.
* User-authenticated: the caller must be a member of the project organization.
* Used by the Work OS card list to show per-issue linked Signal counts.
*/
export const listLinkedSignalsForProject = query({
args: {
projectId: v.id("projects"),
},
handler: async (ctx, args): Promise<Record<string, LinkedSignalView[]>> => {
const { organizationId } = await requireProjectMember(ctx, args.projectId);
const issues = await ctx.db
.query("projectIssues")
.withIndex("by_project_and_number", (q) =>
q.eq("projectId", args.projectId)
)
.take(100);
const result: Record<string, LinkedSignalView[]> = {};
for (const issue of issues) {
const attachments = await ctx.db
.query("signalIssueAttachments")
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
.collect();
if (attachments.length === 0) {
continue;
}
const signals = await Promise.all(
attachments.map(async (attachment) => {
const signal = await ctx.db.get(attachment.signalId);
if (!signal || signal.organizationId !== organizationId) {
return null;
}
const sources = await ctx.db
.query("signalSources")
.withIndex("by_signal_and_ordinal", (q) =>
q.eq("signalId", signal._id)
)
.collect();
return {
createdAt: signal.createdAt,
signalId: signal._id,
sourceCount: sources.length,
summary: signal.problemStatement.summary,
title: signal.problemStatement.title,
} satisfies LinkedSignalView;
})
);
result[String(issue._id)] = signals
.filter((entry): entry is LinkedSignalView => entry !== null)
// Deterministic order: newest linked Signal first.
.toSorted((a, b) => b.createdAt - a.createdAt);
}
return result;
},
});

View File

@@ -8,8 +8,12 @@
"./convex": "./src/convex.ts",
"./native": "./src/native.ts",
"./server": "./src/server.ts",
"./smoke": "./src/smoke.ts",
"./web": "./src/web.ts"
},
"scripts": {
"check-types": "tsc --noEmit"
},
"dependencies": {
"@t3-oss/env-core": "^0.13.11",
"zod": "catalog:"

View File

@@ -11,6 +11,8 @@ const agentEnvSchema = z.object({
CONVEX_URL: z.url(),
DAEMON_ID: z.string().min(1),
FLUE_DB_TOKEN: z.string().min(1),
GITEA_TOKEN: z.string().min(1).optional(),
GITEA_URL: z.url().default("https://git.openputer.com"),
});
export type AgentEnv = z.infer<typeof agentEnvSchema>;

46
packages/env/src/smoke.ts vendored Normal file
View File

@@ -0,0 +1,46 @@
import { z } from "zod";
const smokeEnvSchema = z.object({
accessToken: z.string().min(1),
agentModelBaseUrl: z.url().optional(),
agentModelName: z.string().min(1).optional(),
agentModelProvider: z.string().min(1).optional(),
convexUrl: z.url(),
cpaApiKey: z.string().min(1),
cpaBaseUrl: z.url(),
daemonId: z.string().min(1),
featureRequest: z.string().min(10),
flueUrl: z.url(),
projectId: z.string().min(1).optional(),
webUrl: z.url(),
});
export type SmokeEnv = z.infer<typeof smokeEnvSchema>;
export const parseSmokeEnv = (
runtimeEnv: Record<string, string | undefined>
): SmokeEnv =>
smokeEnvSchema.parse({
accessToken: runtimeEnv.ZOPU_SMOKE_ACCESS_TOKEN,
agentModelBaseUrl: runtimeEnv.AGENT_MODEL_BASE_URL,
agentModelName: runtimeEnv.AGENT_MODEL_NAME,
agentModelProvider: runtimeEnv.AGENT_MODEL_PROVIDER,
convexUrl: runtimeEnv.ZOPU_SMOKE_CONVEX_URL ?? runtimeEnv.CONVEX_URL,
cpaApiKey:
runtimeEnv.ZOPU_SMOKE_CPA_API_KEY ?? runtimeEnv.AGENT_MODEL_API_KEY,
cpaBaseUrl:
runtimeEnv.ZOPU_SMOKE_CPA_BASE_URL ?? runtimeEnv.AGENT_MODEL_BASE_URL,
daemonId: runtimeEnv.ZOPU_SMOKE_DAEMON_ID ?? runtimeEnv.DAEMON_ID,
featureRequest:
runtimeEnv.ZOPU_SMOKE_FEATURE_REQUEST ??
"Add a tiny accessible status control to the existing web project's primary page, keep existing behavior unchanged, and add a focused test for it.",
flueUrl:
runtimeEnv.ZOPU_SMOKE_FLUE_URL ??
runtimeEnv.VITE_FLUE_URL ??
"http://localhost:3583",
projectId: runtimeEnv.ZOPU_SMOKE_PROJECT_ID,
webUrl:
runtimeEnv.ZOPU_SMOKE_WEB_URL ??
runtimeEnv.SITE_URL ??
"http://localhost:5173",
});

View File

@@ -6,8 +6,13 @@
"exports": {
".": "./src/index.ts",
"./agent-os": "./src/agent-os.ts",
"./git": "./src/git.ts",
"./project": "./src/project.ts",
"./signal": "./src/signal.ts"
"./project-issue": "./src/project-issue.ts",
"./project-workspace": "./src/project-workspace.ts",
"./project-work": "./src/project-work.ts",
"./signal": "./src/signal.ts",
"./smoke": "./src/smoke.ts"
},
"scripts": {
"check-types": "tsc --noEmit",
@@ -15,6 +20,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@agentos-software/git": "0.3.3",
"@rivet-dev/agentos": "catalog:",
"effect": "catalog:"
},

View File

@@ -1,9 +1,16 @@
import {
agentOS as createAgentOsActor,
type AgentOSConfigInput,
} from "@rivet-dev/agentos";
/* eslint-disable max-classes-per-file -- the AgentOS service and its tagged error form one adapter contract. */
import git from "@agentos-software/git";
import { agentOS as createAgentOsActor } from "@rivet-dev/agentos";
import type { AgentOSConfigInput } from "@rivet-dev/agentos";
import { Context, Effect, Layer, Schema } from "effect";
const withGitSoftware = (
config: AgentOSConfigInput<undefined>
): AgentOSConfigInput<undefined> => ({
...config,
software: [git, ...(config.software ?? [])],
});
export class AgentOsError extends Schema.TaggedErrorClass<AgentOsError>()(
"AgentOsError",
{
@@ -29,12 +36,12 @@ export class AgentOs extends Context.Service<
static readonly layer = Layer.succeed(
AgentOs,
AgentOs.of({
createActor: Effect.fn("AgentOs.createActor")(function* (
createActor: Effect.fn("AgentOs.createActor")(function* createActor(
config: AgentOSConfigInput<undefined> = {}
) {
return yield* Effect.try({
try: () => createAgentOsActor<undefined>(config),
catch: (cause) => new AgentOsError({ cause }),
try: () => createAgentOsActor<undefined>(withGitSoftware(config)),
});
}),
})
@@ -45,19 +52,19 @@ export const makeAgentOsLayer = (options: AgentOsOptions = {}) =>
Layer.succeed(
AgentOs,
AgentOs.of({
createActor: Effect.fn("AgentOs.createActor")(function* (
createActor: Effect.fn("AgentOs.createActor")(function* createActor(
config: AgentOSConfigInput<undefined> = options.config ?? {}
) {
return yield* Effect.try({
try: () => createAgentOsActor<undefined>(config),
catch: (cause) => new AgentOsError({ cause }),
try: () => createAgentOsActor<undefined>(withGitSoftware(config)),
});
}),
})
);
export const createAgentOsActorEffect = Effect.fn("createAgentOsActorEffect")(
function* (config?: AgentOSConfigInput<undefined>) {
function* createAgentOsActorEffect(config?: AgentOSConfigInput<undefined>) {
const agentOs = yield* AgentOs;
return yield* agentOs.createActor(config);
}

View File

@@ -0,0 +1,145 @@
import { Effect, Exit } from "effect";
import { describe, expect, test } from "vitest";
import {
decideGitLifecycle,
inspectGitWorkspace,
makeCommitMessage,
validatePullRequestMetadata,
} from "./git";
const inspectionInput = {
branch: "work/issue-5",
diff: "diff --git a/file.ts b/file.ts",
status: " M file.ts",
};
const inspection = await Effect.runPromise(
inspectGitWorkspace(inspectionInput)
);
describe("Git lifecycle primitives", () => {
test("parses workspace status and treats a diff as publishable changes", () => {
expect(inspection).toMatchObject({
branch: "work/issue-5",
hasChanges: true,
status: [{ index: " ", path: "file.ts", worktree: "M" }],
});
});
test("requires verification before any publish action", async () => {
const result = await Effect.runPromiseExit(
decideGitLifecycle({
baseBranch: "main",
inspection,
pushed: false,
verification: "not-run",
})
);
expect(Exit.isFailure(result)).toBe(true);
if (Exit.isFailure(result)) {
const failure = result.cause as unknown as {
readonly reasons: readonly [
{ readonly error: { readonly reason: string } },
];
};
expect(failure.reasons[0]?.error.reason).toBe("VerificationRequired");
}
});
test("never publishes the default branch", async () => {
const result = await Effect.runPromiseExit(
decideGitLifecycle({
baseBranch: "work/issue-5",
inspection,
pushed: false,
verification: "passed",
})
);
expect(Exit.isFailure(result)).toBe(true);
if (Exit.isFailure(result)) {
const failure = result.cause as unknown as {
readonly reasons: readonly [
{ readonly error: { readonly reason: string } },
];
};
expect(failure.reasons[0]?.error.reason).toBe("InvalidBranch");
}
});
test("moves verified changes through commit, push, PR, then manual merge", async () => {
const commit = await Effect.runPromise(
decideGitLifecycle({
baseBranch: "main",
inspection,
pushed: false,
verification: "passed",
})
);
expect(commit).toEqual({ decision: "commit", status: "committed" });
const push = await Effect.runPromise(
decideGitLifecycle({
baseBranch: "main",
commitSha: "abc123",
inspection: { ...inspection, hasChanges: false, status: [] },
pushed: false,
verification: "passed",
})
);
expect(push).toEqual({ decision: "push", status: "pushed" });
const pr = await Effect.runPromise(
decideGitLifecycle({
baseBranch: "main",
commitSha: "abc123",
inspection: { ...inspection, hasChanges: false, status: [] },
pushed: true,
verification: "passed",
})
);
expect(pr).toEqual({
decision: "create_pull_request",
status: "pull_request_open",
});
const merge = await Effect.runPromise(
decideGitLifecycle({
baseBranch: "main",
commitSha: "abc123",
inspection: { ...inspection, hasChanges: false, status: [] },
pullRequest: {
baseBranch: "main",
branch: "work/issue-5",
number: 5,
status: "open",
url: "https://git.openputer.com/puter/zopu-code/pulls/5",
},
pushed: true,
verification: "passed",
})
);
expect(merge).toEqual({
decision: "manual_merge",
status: "pull_request_open",
});
});
test("validates PR metadata and keeps merge manual", async () => {
const metadata = await Effect.runPromise(
validatePullRequestMetadata({
baseBranch: "main",
branch: "work/issue-5",
number: 5,
status: "open",
url: "https://git.openputer.com/puter/zopu-code/pulls/5",
})
);
expect(metadata.status).toBe("open");
expect(makeCommitMessage(5, "Ship Git lifecycle")).toBe(
"feat(issue-5): Ship Git lifecycle"
);
});
});

View File

@@ -0,0 +1,250 @@
import { Effect, Schema } from "effect";
const MeaningfulString = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
expected: "a non-empty string",
})
);
export const GitBranchName = MeaningfulString.pipe(
Schema.check(
Schema.makeFilter(
(value) =>
!value.startsWith("-") &&
!value.includes("..") &&
!value.includes(" ") &&
!value.includes("~") &&
!value.includes("^") &&
!value.includes(":") &&
!value.includes("\\"),
{ expected: "a valid Git branch name" }
)
)
);
export type GitBranchName = typeof GitBranchName.Type;
export const GitStatusEntry = Schema.Struct({
index: Schema.String,
path: MeaningfulString,
raw: Schema.String,
worktree: Schema.String,
});
export type GitStatusEntry = typeof GitStatusEntry.Type;
export const GitWorkspaceInspection = Schema.Struct({
branch: GitBranchName,
diff: Schema.String,
hasChanges: Schema.Boolean,
status: Schema.Array(GitStatusEntry),
});
export type GitWorkspaceInspection = typeof GitWorkspaceInspection.Type;
export const GitVerification = Schema.Literals(["passed", "failed", "not-run"]);
export type GitVerification = typeof GitVerification.Type;
export const PullRequestStatus = Schema.Literals(["open", "closed", "merged"]);
export type PullRequestStatus = typeof PullRequestStatus.Type;
export const PullRequestMetadata = Schema.Struct({
baseBranch: GitBranchName,
branch: GitBranchName,
number: Schema.Int.check(Schema.isGreaterThan(0)),
status: PullRequestStatus,
url: Schema.String.check(
Schema.makeFilter(
(value) => {
try {
return new URL(value).protocol.startsWith("http");
} catch {
return false;
}
},
{ expected: "a valid HTTP pull request URL" }
)
),
});
export type PullRequestMetadata = typeof PullRequestMetadata.Type;
export const GitLifecycleStatus = Schema.Literals([
"no_changes",
"committed",
"pushed",
"pull_request_open",
"failed",
]);
export type GitLifecycleStatus = typeof GitLifecycleStatus.Type;
export const GitLifecycleResult = Schema.Struct({
baseBranch: GitBranchName,
branch: GitBranchName,
commitSha: Schema.optional(MeaningfulString),
pullRequest: Schema.optional(PullRequestMetadata),
status: GitLifecycleStatus,
});
export type GitLifecycleResult = typeof GitLifecycleResult.Type;
export const GitLifecycleDecision = Schema.Literals([
"finish",
"commit",
"push",
"create_pull_request",
"manual_merge",
]);
export type GitLifecycleDecision = typeof GitLifecycleDecision.Type;
export const GitLifecycleDecisionInput = Schema.Struct({
baseBranch: GitBranchName,
commitSha: Schema.optional(MeaningfulString),
inspection: GitWorkspaceInspection,
pullRequest: Schema.optional(PullRequestMetadata),
pushed: Schema.Boolean,
verification: GitVerification,
});
export type GitLifecycleDecisionInput = typeof GitLifecycleDecisionInput.Type;
export const GitLifecycleDecisionResult = Schema.Struct({
decision: GitLifecycleDecision,
status: GitLifecycleStatus,
});
export type GitLifecycleDecisionResult = typeof GitLifecycleDecisionResult.Type;
export const GitLifecycleErrorReason = Schema.Literals([
"Authentication",
"CommandFailed",
"InvalidInput",
"InvalidBranch",
"NoChanges",
"InvalidPullRequest",
"RemoteRejected",
"RequestFailed",
"VerificationRequired",
]);
export type GitLifecycleErrorReason = typeof GitLifecycleErrorReason.Type;
export class GitLifecycleError extends Schema.TaggedErrorClass<GitLifecycleError>()(
"GitLifecycleError",
{
message: Schema.String,
reason: GitLifecycleErrorReason,
}
) {}
const invalidInput = (message: string) =>
new GitLifecycleError({ message, reason: "InvalidInput" });
export const parseGitStatus = (rawStatus: string): GitStatusEntry[] =>
rawStatus
.split("\n")
.filter((line) => line.length > 0)
.map((raw) => ({
index: raw.slice(0, 1),
path: raw.slice(3).trim(),
raw,
worktree: raw.slice(1, 2),
}));
export const inspectGitWorkspace = Effect.fn("Git.inspectWorkspace")(
function* inspectGitWorkspace(input: unknown) {
const decoded = yield* Schema.decodeUnknownEffect(
Schema.Struct({
branch: GitBranchName,
diff: Schema.String,
status: Schema.String,
})
)(input).pipe(Effect.mapError((cause) => invalidInput(cause.message)));
const status = parseGitStatus(decoded.status);
return {
branch: decoded.branch,
diff: decoded.diff,
hasChanges: status.length > 0 || decoded.diff.length > 0,
status,
} satisfies GitWorkspaceInspection;
}
);
export const decideGitLifecycle = Effect.fn("Git.decideLifecycle")(
function* decideGitLifecycle(input: unknown) {
const decoded = yield* Schema.decodeUnknownEffect(
GitLifecycleDecisionInput
)(input).pipe(Effect.mapError((cause) => invalidInput(cause.message)));
if (decoded.inspection.branch === decoded.baseBranch) {
return yield* Effect.fail(
new GitLifecycleError({
message: `Refusing to publish the default branch ${decoded.baseBranch}`,
reason: "InvalidBranch",
})
);
}
if (decoded.verification !== "passed") {
return yield* Effect.fail(
new GitLifecycleError({
message: "Verified changes are required before publishing",
reason: "VerificationRequired",
})
);
}
if (!decoded.inspection.hasChanges && decoded.commitSha === undefined) {
return {
decision: "finish" as const,
status: "no_changes" as const,
} satisfies GitLifecycleDecisionResult;
}
if (decoded.commitSha === undefined) {
return {
decision: "commit" as const,
status: "committed" as const,
} satisfies GitLifecycleDecisionResult;
}
if (!decoded.pushed) {
return {
decision: "push" as const,
status: "pushed" as const,
} satisfies GitLifecycleDecisionResult;
}
if (decoded.pullRequest === undefined) {
return {
decision: "create_pull_request" as const,
status: "pull_request_open" as const,
} satisfies GitLifecycleDecisionResult;
}
return {
decision: "manual_merge" as const,
status: "pull_request_open" as const,
} satisfies GitLifecycleDecisionResult;
}
);
export const validatePullRequestMetadata = Effect.fn(
"Git.validatePullRequestMetadata"
)(function* validatePullRequestMetadata(input: unknown) {
const metadata = yield* Schema.decodeUnknownEffect(PullRequestMetadata)(
input
).pipe(Effect.mapError((cause) => invalidInput(cause.message)));
if (metadata.branch === metadata.baseBranch) {
return yield* Effect.fail(
new GitLifecycleError({
message: "A pull request must target a different branch",
reason: "InvalidPullRequest",
})
);
}
return metadata;
});
export const makeCommitMessage = (
issueNumber: number,
title: string
): string => {
const normalizedTitle = title.replaceAll(/\s+/gu, " ").trim();
return `feat(issue-${issueNumber}): ${normalizedTitle}`.slice(0, 120);
};

View File

@@ -1,4 +1,9 @@
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
export * from "./agent-os";
export * from "./git";
export * from "./project";
export * from "./project-issue";
export * from "./project-workspace";
export * from "./project-work";
export * from "./signal";
export * from "./smoke";

View File

@@ -0,0 +1,77 @@
import { Effect } from "effect";
import { describe, expect, it } from "vitest";
import {
decodeProjectIssueRequest,
ProjectIssueTransitionError,
ProjectIssueValidationError,
projectIssueDraftFromSignal,
queueProjectIssue,
validateProjectIssueDraft,
} from "./project-issue";
describe("project issue primitives", () => {
it("normalizes an explicit project request", () => {
const request = Effect.runSync(
decodeProjectIssueRequest({
body: " Investigate the staging failure. ",
kind: "explicit",
projectId: "project-1",
title: " Staging failure ",
})
);
expect(request).toEqual({
body: "Investigate the staging failure.",
kind: "explicit",
projectId: "project-1",
title: "Staging failure",
});
});
it("rejects invalid issue drafts before persistence", () => {
const error = Effect.runSync(
Effect.flip(
validateProjectIssueDraft({
body: "too short",
title: "No",
})
)
);
expect(error).toBeInstanceOf(ProjectIssueValidationError);
expect(error.reason).toBe("TitleTooShort");
});
it("projects a project Signal into the existing issue shape", () => {
const draft = Effect.runSync(
projectIssueDraftFromSignal({
problemStatement: {
constraints: ["Do not change production"],
desiredOutcome: "Staging deploys successfully",
summary: "The deploy fails during DNS resolution",
title: "Fix staging deploy DNS failure",
},
signalId: "signal-1",
})
);
expect(draft.title).toBe("Fix staging deploy DNS failure");
expect(draft.body).toContain("The deploy fails during DNS resolution");
expect(draft.body).toContain("Source Signal: signal-1");
});
it("queues retryable issue states and preserves active states", () => {
expect(Effect.runSync(queueProjectIssue("open"))).toBe("queued");
expect(Effect.runSync(queueProjectIssue("failed"))).toBe("queued");
expect(Effect.runSync(queueProjectIssue("needs-input"))).toBe("queued");
expect(Effect.runSync(queueProjectIssue("working"))).toBe("working");
});
it("does not reopen completed issues", () => {
const error = Effect.runSync(Effect.flip(queueProjectIssue("completed")));
expect(error).toBeInstanceOf(ProjectIssueTransitionError);
expect(error.reason).toBe("CompletedIssue");
});
});

View File

@@ -0,0 +1,265 @@
/* eslint-disable max-classes-per-file -- each domain failure has a distinct tagged reason. */
import { Effect, Schema } from "effect";
import { ProblemStatement, ProjectId, SignalId } from "./signal.js";
const MeaningfulString = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
expected: "a non-empty string",
})
);
export const ProjectIssueId = MeaningfulString.pipe(
Schema.brand("ProjectIssueId")
);
export type ProjectIssueId = typeof ProjectIssueId.Type;
export const ProjectIssueStatus = Schema.Literals([
"open",
"queued",
"working",
"needs-input",
"completed",
"failed",
]);
export type ProjectIssueStatus = typeof ProjectIssueStatus.Type;
const ProjectIssueDraftInput = Schema.Struct({
body: Schema.String,
title: Schema.String,
});
export const ProjectIssueDraft = Schema.Struct({
body: MeaningfulString,
title: MeaningfulString,
});
export type ProjectIssueDraft = typeof ProjectIssueDraft.Type;
export const ExplicitProjectIssueRequest = Schema.Struct({
body: Schema.String,
kind: Schema.Literal("explicit"),
projectId: ProjectId,
title: Schema.String,
});
export const SignalProjectIssueRequest = Schema.Struct({
kind: Schema.Literal("signal"),
signalId: SignalId,
});
export const ProjectIssueRequest = Schema.Union([
ExplicitProjectIssueRequest,
SignalProjectIssueRequest,
]);
export type ProjectIssueRequest = typeof ProjectIssueRequest.Type;
export const ProjectIssueDispatchInput = Schema.Struct({
issueId: ProjectIssueId,
kind: Schema.Literal("project.issue.started"),
projectId: ProjectId,
});
export type ProjectIssueDispatchInput = typeof ProjectIssueDispatchInput.Type;
export const ProjectIssueRequestResult = Schema.Struct({
acceptedAt: MeaningfulString,
dispatchId: MeaningfulString,
issueId: ProjectIssueId,
projectId: ProjectId,
status: Schema.Literals(["queued", "working"]),
});
export type ProjectIssueRequestResult = typeof ProjectIssueRequestResult.Type;
export const ProjectIssueValidationReason = Schema.Literals([
"InvalidInput",
"TitleTooShort",
"TitleTooLong",
"BodyTooShort",
"BodyTooLong",
]);
export type ProjectIssueValidationReason =
typeof ProjectIssueValidationReason.Type;
export class ProjectIssueValidationError extends Schema.TaggedErrorClass<ProjectIssueValidationError>()(
"ProjectIssueValidationError",
{
message: Schema.String,
reason: ProjectIssueValidationReason,
}
) {}
export const ProjectIssueRequestErrorReason = Schema.Literals([
"InvalidInput",
"InvalidProjectId",
"InvalidSignalId",
]);
export type ProjectIssueRequestErrorReason =
typeof ProjectIssueRequestErrorReason.Type;
export class ProjectIssueRequestError extends Schema.TaggedErrorClass<ProjectIssueRequestError>()(
"ProjectIssueRequestError",
{
message: Schema.String,
reason: ProjectIssueRequestErrorReason,
}
) {}
export const ProjectIssueTransitionReason = Schema.Literals([
"InvalidStatus",
"CompletedIssue",
]);
export type ProjectIssueTransitionReason =
typeof ProjectIssueTransitionReason.Type;
export class ProjectIssueTransitionError extends Schema.TaggedErrorClass<ProjectIssueTransitionError>()(
"ProjectIssueTransitionError",
{
message: Schema.String,
reason: ProjectIssueTransitionReason,
}
) {}
export const validateProjectIssueDraft = (
input: unknown
): Effect.Effect<ProjectIssueDraft, ProjectIssueValidationError> =>
Effect.gen(function* validateDraft() {
const decoded = yield* Schema.decodeUnknownEffect(ProjectIssueDraftInput)(
input
).pipe(
Effect.mapError(
() =>
new ProjectIssueValidationError({
message: "Issue title and body are required",
reason: "InvalidInput",
})
)
);
const title = decoded.title.trim();
const body = decoded.body.trim();
if (title.length < 3) {
return yield* Effect.fail(
new ProjectIssueValidationError({
message: "Issue title must be between 3 and 160 characters",
reason: "TitleTooShort",
})
);
}
if (title.length > 160) {
return yield* Effect.fail(
new ProjectIssueValidationError({
message: "Issue title must be between 3 and 160 characters",
reason: "TitleTooLong",
})
);
}
if (body.length < 10) {
return yield* Effect.fail(
new ProjectIssueValidationError({
message: "Issue description must be between 10 and 10000 characters",
reason: "BodyTooShort",
})
);
}
if (body.length > 10_000) {
return yield* Effect.fail(
new ProjectIssueValidationError({
message: "Issue description must be between 10 and 10000 characters",
reason: "BodyTooLong",
})
);
}
return { body, title };
});
export const decodeProjectIssueRequest = (
input: unknown
): Effect.Effect<
ProjectIssueRequest,
ProjectIssueRequestError | ProjectIssueValidationError
> =>
Effect.gen(function* decodeRequest() {
const request = yield* Schema.decodeUnknownEffect(ProjectIssueRequest)(
input
).pipe(
Effect.mapError(
() =>
new ProjectIssueRequestError({
message:
"Use a signal request or an explicit request with projectId, title, and body",
reason: "InvalidInput",
})
)
);
if (request.kind === "explicit") {
const draft = yield* validateProjectIssueDraft(request);
return { ...request, ...draft };
}
return request;
});
export const projectIssueDraftFromSignal = (input: unknown) =>
Effect.gen(function* draftFromSignal() {
const source = yield* Schema.decodeUnknownEffect(
Schema.Struct({
problemStatement: ProblemStatement,
signalId: SignalId,
})
)(input).pipe(
Effect.mapError(
() =>
new ProjectIssueRequestError({
message: "Project Signal is missing a valid problem statement",
reason: "InvalidSignalId",
})
)
);
const constraints =
source.problemStatement.constraints.length === 0
? "None"
: source.problemStatement.constraints
.map((constraint) => `- ${constraint}`)
.join("\n");
return yield* validateProjectIssueDraft({
body: [
source.problemStatement.summary,
`Desired outcome: ${source.problemStatement.desiredOutcome}`,
`Constraints:\n${constraints}`,
`Source Signal: ${source.signalId}`,
].join("\n\n"),
title: source.problemStatement.title,
});
});
export const queueProjectIssue = (
input: unknown
): Effect.Effect<"queued" | "working", ProjectIssueTransitionError> =>
Effect.gen(function* queueIssue() {
const status = yield* Schema.decodeUnknownEffect(ProjectIssueStatus)(
input
).pipe(
Effect.mapError(
() =>
new ProjectIssueTransitionError({
message: "Issue has an invalid status",
reason: "InvalidStatus",
})
)
);
if (status === "queued" || status === "working") {
return status;
}
if (status === "completed") {
return yield* Effect.fail(
new ProjectIssueTransitionError({
message: "Completed issues cannot be queued again",
reason: "CompletedIssue",
})
);
}
return "queued" as const;
});

View File

@@ -0,0 +1,44 @@
import { describe, expect, test } from "vitest";
import {
getProjectWorkNextAction,
getProjectWorkProgress,
getProjectWorkStatus,
} from "./project-work";
describe("project work state mapping", () => {
test("maps issue and run statuses to the same product language", () => {
expect(getProjectWorkStatus("working")).toEqual({
label: "In progress",
phase: "active",
verification: "running",
});
expect(getProjectWorkStatus("needs-input")).toEqual({
label: "Needs your input",
phase: "waiting",
verification: "blocked",
});
});
test("keeps progress monotonic through the normal loop", () => {
expect(getProjectWorkProgress("open")).toBeLessThan(
getProjectWorkProgress("queued")
);
expect(getProjectWorkProgress("queued")).toBeLessThan(
getProjectWorkProgress("working")
);
expect(getProjectWorkProgress("working")).toBeLessThan(
getProjectWorkProgress("completed")
);
});
test("maps each state to the next product action", () => {
expect(getProjectWorkNextAction("open")).toBe("Start the project manager");
expect(getProjectWorkNextAction("failed")).toBe(
"Review the failure and retry"
);
expect(getProjectWorkNextAction("completed")).toBe(
"Review the pull request"
);
});
});

View File

@@ -0,0 +1,131 @@
import type { ProjectIssueStatus } from "./project-issue.js";
export const PROJECT_ISSUE_STATUSES = [
"open",
"queued",
"working",
"needs-input",
"completed",
"failed",
] as const;
export const PROJECT_RUN_STATUSES = [
"ready",
"queued",
"working",
"needs-input",
"completed",
"failed",
] as const;
export type ProjectRunStatus = (typeof PROJECT_RUN_STATUSES)[number];
export type ProjectWorkPhase =
| "captured"
| "queued"
| "active"
| "waiting"
| "completed"
| "failed";
export type ProjectVerificationStatus =
| "not-started"
| "running"
| "blocked"
| "passed"
| "failed";
export interface ProjectWorkStatus {
readonly label: string;
readonly phase: ProjectWorkPhase;
readonly verification: ProjectVerificationStatus;
}
const NEXT_ACTION_MAP: Readonly<
Record<ProjectIssueStatus | ProjectRunStatus, string>
> = {
completed: "Review the pull request",
failed: "Review the failure and retry",
"needs-input": "Resolve the open question",
open: "Start the project manager",
queued: "Watch the run progress",
ready: "Watch the run progress",
working: "Wait for verification",
};
const STATUS_MAP: Readonly<
Record<ProjectIssueStatus | ProjectRunStatus, ProjectWorkStatus>
> = {
completed: {
label: "Completed",
phase: "completed",
verification: "passed",
},
failed: {
label: "Failed",
phase: "failed",
verification: "failed",
},
"needs-input": {
label: "Needs your input",
phase: "waiting",
verification: "blocked",
},
open: {
label: "Ready to start",
phase: "captured",
verification: "not-started",
},
queued: {
label: "Queued",
phase: "queued",
verification: "not-started",
},
ready: {
label: "Ready",
phase: "queued",
verification: "not-started",
},
working: {
label: "In progress",
phase: "active",
verification: "running",
},
};
export const getProjectWorkStatus = (
status: ProjectIssueStatus | ProjectRunStatus
): ProjectWorkStatus => STATUS_MAP[status];
export const getProjectWorkProgress = (
status: ProjectIssueStatus | ProjectRunStatus
): number => {
switch (status) {
case "completed": {
return 100;
}
case "failed": {
return 58;
}
case "needs-input": {
return 78;
}
case "working": {
return 68;
}
case "queued":
case "ready": {
return 24;
}
case "open": {
return 8;
}
default: {
return 0;
}
}
};
export const getProjectWorkNextAction = (
status: ProjectIssueStatus | ProjectRunStatus
): string => NEXT_ACTION_MAP[status];

View File

@@ -0,0 +1,141 @@
import { Effect } from "effect";
import { describe, expect, it } from "vitest";
import {
decodeIssueWorkspaceResult,
makeIssueWorkspacePlan,
transitionWorkspaceRun,
WorkspaceExecutionError,
WorkspaceStateError,
WorkspaceValidationError,
} from "./project-workspace";
const makeInput = () => ({
artifacts: [
{
content: "# Work\n",
path: "work.md",
},
],
branchName: undefined,
checkoutPath: undefined,
contextFiles: [
{
content: "# Project\n",
kind: "readme" as const,
path: "README.md",
},
],
defaultBranch: "main",
issueBody: "Make the project run in an isolated checkout.",
issueId: "projectIssues|abc123",
issueNumber: 7,
issueTitle: "Use a real project checkout",
sourceUrl: "https://git.example.test/puter/project",
});
const validationFailure = (input: unknown): WorkspaceValidationError => {
const error = Effect.runSync(Effect.flip(makeIssueWorkspacePlan(input)));
expect(error).toBeInstanceOf(WorkspaceValidationError);
return error;
};
describe("project workspace primitives", () => {
it("plans a deterministic checkout and keeps control files outside the repo", () => {
const plan = Effect.runSync(makeIssueWorkspacePlan(makeInput()));
const secondPlan = Effect.runSync(makeIssueWorkspacePlan(makeInput()));
expect(plan.branchName).toBe("work/issue-7-abc123");
expect(plan.checkoutPath).toBe("/workspace/repository");
expect(plan).toEqual(secondPlan);
expect(plan.operations).toEqual(
expect.arrayContaining([
{
_tag: "WriteFile",
content: expect.any(String),
path: "/workspace/control/issue.md",
},
{
_tag: "WriteFile",
content: "# Project\n",
path: "/workspace/control/context/README.md",
},
{
_tag: "WriteFile",
content: "# Work\n",
path: "/workspace/control/artifacts/work.md",
},
])
);
expect(
plan.operations.some(
(operation) =>
operation._tag === "Exec" && operation.command.includes("git clone")
)
).toBe(true);
});
it("rejects unsafe sources and control-file paths", () => {
const missingSource = { ...makeInput(), sourceUrl: undefined };
expect(validationFailure(missingSource).reason).toBe("MissingSource");
const invalidSource = makeInput();
invalidSource.sourceUrl = "ssh://git@example.test/project";
expect(validationFailure(invalidSource).reason).toBe("InvalidSourceUrl");
const invalidPath = makeInput();
const [artifact] = invalidPath.artifacts;
if (!artifact) {
throw new Error("test artifact missing");
}
artifact.path = "../work.md";
expect(validationFailure(invalidPath).reason).toBe("InvalidPath");
});
it("rejects a checkout result that does not prove the planned branch", () => {
const plan = Effect.runSync(makeIssueWorkspacePlan(makeInput()));
const error = Effect.runSync(
Effect.flip(
decodeIssueWorkspaceResult({
command: {
exitCode: 0,
stderr: "",
stdout: "work/issue-7-other deadbeef\n",
},
plan,
})
)
);
expect(error).toBeInstanceOf(WorkspaceExecutionError);
expect(error.reason).toBe("InvalidResult");
});
it("accepts the planned checkout result and enforces run transitions", () => {
const plan = Effect.runSync(makeIssueWorkspacePlan(makeInput()));
const result = Effect.runSync(
decodeIssueWorkspaceResult({
command: {
exitCode: 0,
stderr: "",
stdout: `${plan.branchName} deadbeef1234567\n`,
},
plan,
})
);
expect(result).toMatchObject({
branchName: plan.branchName,
checkoutPath: "/workspace/repository",
headSha: "deadbeef1234567",
});
expect(
Effect.runSync(transitionWorkspaceRun({ from: "queued", to: "working" }))
).toBe("working");
const error = Effect.runSync(
Effect.flip(transitionWorkspaceRun({ from: "completed", to: "working" }))
);
expect(error).toBeInstanceOf(WorkspaceStateError);
});
});

View File

@@ -0,0 +1,436 @@
/* eslint-disable max-classes-per-file -- workspace errors stay next to their contracts. */
import { Effect, Schema } from "effect";
import { CONTEXT_KINDS } from "./project.js";
export type { ContextKind } from "./project.js";
const MeaningfulString = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
expected: "a non-empty string",
})
);
const PositiveInteger = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));
const WORKSPACE_PATH = "/workspace";
const CHECKOUT_PATH = "/workspace/repository";
const CONTROL_PATH = "/workspace/control";
const isSafeRelativePath = (value: string): boolean => {
if (value.length === 0 || value.startsWith("/") || value.includes("\\")) {
return false;
}
return !value
.split("/")
.some((segment) => segment === "" || segment === "." || segment === "..");
};
const isValidGitRef = (value: string): boolean =>
value.length > 0 &&
!value.startsWith("/") &&
!value.endsWith("/") &&
!value.startsWith(".") &&
!value.endsWith(".") &&
!value.includes("..") &&
!value.includes("//") &&
!value.includes("@{") &&
![...value].some((character) => character < " ") &&
!/[ ~^:?*[\\]/u.test(value);
const isPublicGitUrl = (value: string): boolean => {
try {
const url = new URL(value);
return (
(url.protocol === "http:" || url.protocol === "https:") &&
url.hostname.length > 0 &&
url.pathname !== "/" &&
url.username.length === 0 &&
url.password.length === 0 &&
url.search.length === 0 &&
url.hash.length === 0
);
} catch {
return false;
}
};
const shellQuote = (value: string): string =>
`'${value.replaceAll("'", "'\"'\"'")}'`;
const slugIdentity = (value: string): string => {
const parts = value
.toLowerCase()
.split(/[^a-z0-9]+/gu)
.filter(Boolean);
let result = "";
for (const part of parts) {
result = part;
}
return result.replaceAll(/^-+|-+$/gu, "").slice(-24) || "issue";
};
export const WorkspaceContextFile = Schema.Struct({
content: Schema.String,
kind: Schema.Literals([...CONTEXT_KINDS]),
path: MeaningfulString,
});
export type WorkspaceContextFile = typeof WorkspaceContextFile.Type;
export const WorkspaceArtifactFile = Schema.Struct({
content: Schema.String,
path: MeaningfulString,
});
export type WorkspaceArtifactFile = typeof WorkspaceArtifactFile.Type;
export const IssueWorkspaceInput = Schema.Struct({
artifacts: Schema.Array(WorkspaceArtifactFile),
branchName: Schema.UndefinedOr(MeaningfulString),
checkoutPath: Schema.UndefinedOr(MeaningfulString),
contextFiles: Schema.Array(WorkspaceContextFile),
defaultBranch: Schema.UndefinedOr(MeaningfulString),
issueBody: MeaningfulString,
issueId: MeaningfulString,
issueNumber: PositiveInteger,
issueTitle: MeaningfulString,
sourceUrl: Schema.UndefinedOr(MeaningfulString),
});
export type IssueWorkspaceInput = typeof IssueWorkspaceInput.Type;
export const WorkspaceExecOperation = Schema.Struct({
_tag: Schema.Literal("Exec"),
command: MeaningfulString,
cwd: MeaningfulString,
timeoutMs: PositiveInteger,
});
export const WorkspaceMkdirOperation = Schema.Struct({
_tag: Schema.Literal("Mkdir"),
path: MeaningfulString,
});
export const WorkspaceWriteFileOperation = Schema.Struct({
_tag: Schema.Literal("WriteFile"),
content: Schema.String,
path: MeaningfulString,
});
export const IssueWorkspaceOperation = Schema.Union([
WorkspaceExecOperation,
WorkspaceMkdirOperation,
WorkspaceWriteFileOperation,
]);
export type IssueWorkspaceOperation = typeof IssueWorkspaceOperation.Type;
export const IssueWorkspacePlan = Schema.Struct({
artifactsRoot: MeaningfulString,
baseBranch: MeaningfulString,
branchName: MeaningfulString,
checkoutPath: MeaningfulString,
contextRoot: MeaningfulString,
issuePath: MeaningfulString,
operations: Schema.Array(IssueWorkspaceOperation),
sourceUrl: MeaningfulString,
});
export type IssueWorkspacePlan = typeof IssueWorkspacePlan.Type;
export const IssueWorkspaceCommandResult = Schema.Struct({
exitCode: Schema.Int,
stderr: Schema.String,
stdout: Schema.String,
});
export type IssueWorkspaceCommandResult =
typeof IssueWorkspaceCommandResult.Type;
export const IssueWorkspaceResult = Schema.Struct({
baseBranch: MeaningfulString,
branchName: MeaningfulString,
checkoutPath: MeaningfulString,
headSha: MeaningfulString,
sourceUrl: MeaningfulString,
});
export type IssueWorkspaceResult = typeof IssueWorkspaceResult.Type;
export const WorkspaceValidationReason = Schema.Literals([
"MissingSource",
"InvalidInput",
"InvalidSourceUrl",
"InvalidBranchName",
"InvalidPath",
"DuplicatePath",
]);
export type WorkspaceValidationReason = typeof WorkspaceValidationReason.Type;
export class WorkspaceValidationError extends Schema.TaggedErrorClass<WorkspaceValidationError>()(
"WorkspaceValidationError",
{
message: Schema.String,
reason: WorkspaceValidationReason,
}
) {}
export const WorkspaceExecutionReason = Schema.Literals([
"CommandFailed",
"InvalidResult",
]);
export type WorkspaceExecutionReason = typeof WorkspaceExecutionReason.Type;
export class WorkspaceExecutionError extends Schema.TaggedErrorClass<WorkspaceExecutionError>()(
"WorkspaceExecutionError",
{
message: Schema.String,
reason: WorkspaceExecutionReason,
}
) {}
export const WorkspaceRunStatus = Schema.Literals([
"ready",
"queued",
"working",
"needs-input",
"completed",
"failed",
]);
export type WorkspaceRunStatus = typeof WorkspaceRunStatus.Type;
export class WorkspaceStateError extends Schema.TaggedErrorClass<WorkspaceStateError>()(
"WorkspaceStateError",
{
from: WorkspaceRunStatus,
message: Schema.String,
to: WorkspaceRunStatus,
}
) {}
const validationError = (message: string, reason: WorkspaceValidationReason) =>
new WorkspaceValidationError({ message, reason });
const requireWorkspaceSource = (sourceUrl: string | undefined) => {
if (!sourceUrl || sourceUrl.trim().length === 0) {
return Effect.fail(
validationError("Project source is not connected", "MissingSource")
);
}
if (!isPublicGitUrl(sourceUrl)) {
return Effect.fail(
validationError(
"Issue workspace source must be a public http(s) Git URL",
"InvalidSourceUrl"
)
);
}
return Effect.succeed(sourceUrl);
};
const validateFiles = (
files: readonly { readonly path: string }[],
root: string
): Effect.Effect<void, WorkspaceValidationError> =>
Effect.gen(function* validateWorkspaceFiles() {
const paths = new Set<string>();
for (const file of files) {
if (!isSafeRelativePath(file.path)) {
return yield* Effect.fail(
validationError(
`Invalid workspace file path: ${file.path}`,
"InvalidPath"
)
);
}
const path = `${root}/${file.path}`;
if (paths.has(path)) {
return yield* Effect.fail(
validationError(
`Duplicate workspace file path: ${path}`,
"DuplicatePath"
)
);
}
paths.add(path);
}
});
const makeBranchName = (issueNumber: number, issueId: string): string =>
`work/issue-${issueNumber}-${slugIdentity(issueId)}`;
const makeCheckoutCommand = (input: {
readonly baseBranch: string;
readonly branchName: string;
readonly checkoutPath: string;
readonly sourceUrl: string;
}): string => {
const checkout = shellQuote(input.checkoutPath);
const baseBranch = shellQuote(input.baseBranch);
const branch = shellQuote(input.branchName);
const originBaseBranch = shellQuote(`origin/${input.baseBranch}`);
const source = shellQuote(input.sourceUrl);
return [
"set -eu",
`mkdir -p ${shellQuote(WORKSPACE_PATH)}`,
`if [ ! -d ${checkout}/.git ]; then git clone --branch ${baseBranch} --single-branch ${source} ${checkout}; fi`,
`if [ "$(git -C ${checkout} branch --show-current)" != ${branch} ]; then git -C ${checkout} show-ref --verify --quiet refs/heads/${branch} && git -C ${checkout} checkout ${branch} || git -C ${checkout} checkout -b ${branch} ${originBaseBranch}; fi`,
`printf '%s\\n' "$(git -C ${checkout} branch --show-current)" "$(git -C ${checkout} rev-parse HEAD)"`,
].join(" && ");
};
export const makeIssueWorkspacePlan = Effect.fn("ProjectWorkspace.makePlan")(
function* makeIssueWorkspacePlan(rawInput: unknown) {
const input: IssueWorkspaceInput = yield* Schema.decodeUnknownEffect(
IssueWorkspaceInput
)(rawInput).pipe(
Effect.mapError(() =>
validationError("Invalid issue workspace input", "InvalidInput")
)
);
const sourceUrl = yield* requireWorkspaceSource(input.sourceUrl);
const baseBranch = input.defaultBranch?.trim() || "main";
const branchName =
input.branchName?.trim() ||
makeBranchName(input.issueNumber, input.issueId);
const checkoutPath = input.checkoutPath?.trim() || CHECKOUT_PATH;
if (!isValidGitRef(baseBranch) || !isValidGitRef(branchName)) {
return yield* Effect.fail(
validationError(
"Issue workspace branch name is not a valid Git ref",
"InvalidBranchName"
)
);
}
if (
checkoutPath !== CHECKOUT_PATH ||
!checkoutPath.startsWith(`${WORKSPACE_PATH}/`) ||
checkoutPath.includes("..")
) {
return yield* Effect.fail(
validationError(
"Issue workspace checkout path must be /workspace/repository",
"InvalidPath"
)
);
}
const contextRoot = `${CONTROL_PATH}/context`;
const artifactsRoot = `${CONTROL_PATH}/artifacts`;
yield* validateFiles(input.contextFiles, contextRoot);
yield* validateFiles(input.artifacts, artifactsRoot);
const operations: IssueWorkspaceOperation[] = [
{ _tag: "Mkdir", path: CONTROL_PATH },
{
_tag: "Exec",
command: makeCheckoutCommand({
baseBranch,
branchName,
checkoutPath,
sourceUrl,
}),
cwd: WORKSPACE_PATH,
timeoutMs: 300_000,
},
{ _tag: "Mkdir", path: contextRoot },
{ _tag: "Mkdir", path: artifactsRoot },
{
_tag: "WriteFile",
content: `# Issue ${input.issueNumber}: ${input.issueTitle}\n\n${input.issueBody}\n`,
path: `${CONTROL_PATH}/issue.md`,
},
...input.contextFiles.map((file) => ({
_tag: "WriteFile" as const,
content: file.content,
path: `${contextRoot}/${file.path}`,
})),
...input.artifacts.map((file) => ({
_tag: "WriteFile" as const,
content: file.content,
path: `${artifactsRoot}/${file.path}`,
})),
];
return {
artifactsRoot,
baseBranch,
branchName,
checkoutPath,
contextRoot,
issuePath: `${CONTROL_PATH}/issue.md`,
operations,
sourceUrl,
};
}
);
export const decodeIssueWorkspaceResult = Effect.fn(
"ProjectWorkspace.decodeResult"
)(function* decodeIssueWorkspaceResult(input: {
readonly command: IssueWorkspaceCommandResult;
readonly plan: IssueWorkspacePlan;
}) {
if (input.command.exitCode !== 0) {
return yield* Effect.fail(
new WorkspaceExecutionError({
message:
input.command.stderr || "Issue workspace checkout command failed",
reason: "CommandFailed",
})
);
}
const [branchName, headSha] = input.command.stdout.trim().split(/\s+/u);
if (
branchName !== input.plan.branchName ||
!headSha ||
!/^[0-9a-f]{7,64}$/u.test(headSha)
) {
return yield* Effect.fail(
new WorkspaceExecutionError({
message:
"Issue workspace checkout command returned invalid branch metadata",
reason: "InvalidResult",
})
);
}
return {
baseBranch: input.plan.baseBranch,
branchName,
checkoutPath: input.plan.checkoutPath,
headSha,
sourceUrl: input.plan.sourceUrl,
};
});
const allowedTransitions: Readonly<
Record<WorkspaceRunStatus, readonly WorkspaceRunStatus[]>
> = {
completed: [],
failed: [],
"needs-input": ["queued", "working", "failed"],
queued: ["working", "completed", "failed"],
ready: ["queued", "working", "failed"],
working: ["needs-input", "completed", "failed"],
};
export const transitionWorkspaceRun = Effect.fn(
"ProjectWorkspace.transitionRun"
)(function* transitionWorkspaceRun(input: {
readonly from: WorkspaceRunStatus;
readonly to: WorkspaceRunStatus;
}) {
if (
input.from === input.to ||
allowedTransitions[input.from].includes(input.to)
) {
return input.to;
}
return yield* Effect.fail(
new WorkspaceStateError({
from: input.from,
message: `Cannot transition issue workspace run from ${input.from} to ${input.to}`,
to: input.to,
})
);
});

View File

@@ -223,11 +223,22 @@ describe("composeSignal", () => {
getValidationError(input, "MixedConversation");
});
it("rejects decreasing source timestamps", () => {
it("accepts non-chronological source order with preserved timestamps", () => {
const input = makeSignalInput();
input.sourceMessages[1].createdAt = 99;
// Agent selects the later message first (logical, not chronological order).
const [earlier, later] = input.sourceMessages;
input.sourceMessages = [later, earlier];
getValidationError(input, "OutOfOrderSource");
const signal = runSignal(input);
// Ordinal follows the agent's selection, not timestamps.
expect(signal.sourceMessages.map(({ messageId }) => messageId)).toEqual([
"message-2",
"message-1",
]);
// Original timestamps are preserved exactly on each source.
const timestamps = signal.sourceMessages.map(({ createdAt }) => createdAt);
expect(timestamps).toEqual([200, 100]);
});
it("preserves source order and exact raw whitespace", () => {

View File

@@ -92,7 +92,6 @@ export const SignalValidationReason = Schema.Literals([
"InvalidInput",
"DuplicateSource",
"MixedConversation",
"OutOfOrderSource",
]);
export type SignalValidationReason = typeof SignalValidationReason.Type;
@@ -118,9 +117,11 @@ export const composeSignal = Effect.fn("Signal.compose")(
)
);
const [{ conversationId, createdAt: firstCreatedAt }] =
signal.sourceMessages;
let previousCreatedAt = firstCreatedAt;
// Source order follows the agent's logical selection, not chronological
// timestamp order. Each source retains its original createdAt so temporal
// provenance is preserved exactly; the ordinal array position reflects the
// agent's narrative composition of the problem statement.
const [{ conversationId }] = signal.sourceMessages;
const messageIds = new Set<ConversationMessageId>();
for (const source of signal.sourceMessages) {
@@ -142,16 +143,6 @@ export const composeSignal = Effect.fn("Signal.compose")(
);
}
messageIds.add(source.messageId);
if (source.createdAt < previousCreatedAt) {
return yield* Effect.fail(
new SignalValidationError({
message: "Signal source timestamps must be non-decreasing",
reason: "OutOfOrderSource",
})
);
}
previousCreatedAt = source.createdAt;
}
return signal;

View File

@@ -0,0 +1,164 @@
import { Effect } from "effect";
import { describe, expect, it } from "vitest";
import {
SmokeContractError,
advanceSmokeLoop,
decodeSmokeChatText,
decodeSmokeModelCatalog,
decodeSmokePlan,
decodeSmokeReview,
decodeSmokeWorkerReport,
initialSmokeLoopState,
redactSmokeText,
selectSmokeModelRoles,
validateSmokeCapabilities,
} from "./smoke";
const validRoleSelection = () => ({
plannerReviewerModel: "glm-5.2",
plannerReviewerProvider: "cheaptricks",
workerModel: "minimax-m3",
workerProvider: "cheaptricks",
workerToolCallMode: "serial" as const,
});
const run = <A, E>(effect: Effect.Effect<A, E>): A => Effect.runSync(effect);
describe("smoke primitives", () => {
it("selects the planner/reviewer and serial M3 worker roles", () => {
const roles = run(selectSmokeModelRoles(validRoleSelection()));
expect(roles.worker).toMatchObject({
model: "minimax-m3",
role: "worker",
toolCallMode: "serial",
});
expect(roles.plannerReviewer).toMatchObject({
model: "glm-5.2",
role: "planner-reviewer",
});
});
it("rejects a non-M3 worker or non-GLM planner", () => {
const worker = validRoleSelection();
worker.workerModel = "glm-5.2";
const workerError = run(Effect.flip(selectSmokeModelRoles(worker)));
expect(workerError).toBeInstanceOf(SmokeContractError);
expect(workerError.reason).toBe("InvalidModelRole");
const planner = validRoleSelection();
planner.plannerReviewerModel = "minimax-m3";
const plannerError = run(Effect.flip(selectSmokeModelRoles(planner)));
expect(plannerError.reason).toBe("InvalidModelRole");
});
it("advances only through the serial smoke loop", () => {
let state = initialSmokeLoopState();
state = run(advanceSmokeLoop(state, "preflight-passed"));
state = run(advanceSmokeLoop(state, "plan-validated"));
state = run(advanceSmokeLoop(state, "worker-succeeded"));
state = run(advanceSmokeLoop(state, "review-approved"));
expect(state.phase).toBe("completed");
expect(state.history).toEqual([
"preflight",
"planning",
"worker-running",
"reviewing",
"completed",
]);
});
it("turns contract failures into a terminal state", () => {
const state = run(
advanceSmokeLoop(
initialSmokeLoopState(),
"contract-failed",
"daemon is offline"
)
);
expect(state.phase).toBe("failed");
expect(state.lastError).toBe("daemon is offline");
});
it("rejects out-of-order loop transitions", () => {
const error = run(
Effect.flip(advanceSmokeLoop(initialSmokeLoopState(), "review-approved"))
);
expect(error).toBeInstanceOf(SmokeContractError);
expect(error.reason).toBe("InvalidTransition");
});
it("validates model reports at the primitive boundary", () => {
const plan = run(
decodeSmokePlan({
acceptanceCriteria: ["The button renders"],
nonGoals: [],
steps: ["Add the button", "Run the web check"],
summary: "Add a small button to the disposable project",
})
);
const worker = run(
decodeSmokeWorkerReport({
status: "completed",
summary: "Changed the project and ran the targeted check.",
})
);
const review = run(
decodeSmokeReview({
approved: true,
findings: [],
summary: "The change matches the acceptance criteria.",
})
);
expect(plan.steps).toHaveLength(2);
expect(worker.status).toBe("completed");
expect(review.approved).toBe(true);
});
it("rejects empty or malformed model output", () => {
const empty = run(
Effect.flip(
decodeSmokeChatText({ choices: [{ message: { content: " " } }] })
)
);
expect(empty.reason).toBe("InvalidModelResponse");
const malformed = run(Effect.flip(decodeSmokeReview({ approved: true })));
expect(malformed.reason).toBe("InvalidReport");
});
it("validates the canonical CPA model catalog", () => {
const catalog = run(
decodeSmokeModelCatalog({
data: [{ id: "minimax-m3" }, { id: "glm-5.2" }],
})
);
expect(catalog.data.map(({ id }) => id)).toEqual(["minimax-m3", "glm-5.2"]);
});
it("fails a capability report when any required check fails", () => {
const error = run(
Effect.flip(
validateSmokeCapabilities([
{ id: "web", message: "ready", passed: true },
{ id: "daemon", message: "daemon is offline", passed: false },
])
)
);
expect(error.reason).toBe("InvalidReport");
expect(error.message).toContain("daemon is offline");
});
it("redacts secrets without changing unrelated text", () => {
expect(
redactSmokeText("status token=secret-key endpoint=ok", ["secret-key"])
).toBe("status token=[REDACTED] endpoint=ok");
});
});

View File

@@ -0,0 +1,347 @@
/* eslint-disable max-classes-per-file -- the smoke contract keeps its schemas, errors, and transitions together. */
import { Effect, Schema } from "effect";
const MeaningfulString = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
expected: "a non-empty string",
})
);
const SmokePhase = Schema.Literals([
"preflight",
"planning",
"worker-running",
"reviewing",
"completed",
"failed",
]);
export type SmokePhase = typeof SmokePhase.Type;
const SmokeTransition = Schema.Literals([
"preflight-passed",
"plan-validated",
"worker-succeeded",
"review-approved",
"contract-failed",
"runtime-failed",
]);
type SmokeTransition = typeof SmokeTransition.Type;
export const SmokeRole = Schema.Literals(["planner-reviewer", "worker"]);
export type SmokeRole = typeof SmokeRole.Type;
const SmokeToolCallMode = Schema.Literal("serial");
export const SmokeRoleSelectionInput = Schema.Struct({
plannerReviewerModel: MeaningfulString,
plannerReviewerProvider: MeaningfulString,
workerModel: MeaningfulString,
workerProvider: MeaningfulString,
workerToolCallMode: SmokeToolCallMode,
});
export type SmokeRoleSelectionInput = typeof SmokeRoleSelectionInput.Type;
export const SmokeModelRole = Schema.Struct({
model: MeaningfulString,
provider: MeaningfulString,
role: SmokeRole,
toolCallMode: Schema.Literal("serial"),
});
export type SmokeModelRole = typeof SmokeModelRole.Type;
export const SmokeModelRoles = Schema.Struct({
plannerReviewer: SmokeModelRole,
worker: SmokeModelRole,
});
export type SmokeModelRoles = typeof SmokeModelRoles.Type;
export const SmokeContractReason = Schema.Literals([
"InvalidInput",
"InvalidModelRole",
"InvalidTransition",
"InvalidModelResponse",
"InvalidReport",
]);
export type SmokeContractReason = typeof SmokeContractReason.Type;
export class SmokeContractError extends Schema.TaggedErrorClass<SmokeContractError>()(
"SmokeContractError",
{
message: Schema.String,
reason: SmokeContractReason,
}
) {}
export const selectSmokeModelRoles = Effect.fn("Smoke.selectModelRoles")(
function* selectSmokeModelRoles(input: unknown) {
const selection: SmokeRoleSelectionInput =
yield* Schema.decodeUnknownEffect(SmokeRoleSelectionInput)(input).pipe(
Effect.mapError(
(cause) =>
new SmokeContractError({
message: cause.message,
reason: "InvalidInput",
})
)
);
if (selection.workerModel !== "minimax-m3") {
return yield* Effect.fail(
new SmokeContractError({
message: "The smoke worker must use minimax-m3",
reason: "InvalidModelRole",
})
);
}
if (selection.plannerReviewerModel !== "glm-5.2") {
return yield* Effect.fail(
new SmokeContractError({
message: "The smoke planner/reviewer must use glm-5.2",
reason: "InvalidModelRole",
})
);
}
return {
plannerReviewer: {
model: selection.plannerReviewerModel,
provider: selection.plannerReviewerProvider,
role: "planner-reviewer" as const,
toolCallMode: "serial" as const,
},
worker: {
model: selection.workerModel,
provider: selection.workerProvider,
role: "worker" as const,
toolCallMode: "serial" as const,
},
} satisfies SmokeModelRoles;
}
);
export const SmokePlan = Schema.Struct({
acceptanceCriteria: Schema.NonEmptyArray(MeaningfulString),
nonGoals: Schema.Array(MeaningfulString),
steps: Schema.NonEmptyArray(MeaningfulString),
summary: MeaningfulString,
});
export type SmokePlan = typeof SmokePlan.Type;
export const SmokeWorkerReport = Schema.Struct({
status: Schema.Literals(["completed", "blocked", "failed"]),
summary: MeaningfulString,
});
export type SmokeWorkerReport = typeof SmokeWorkerReport.Type;
export const SmokeReview = Schema.Struct({
approved: Schema.Boolean,
findings: Schema.Array(MeaningfulString),
summary: MeaningfulString,
});
export type SmokeReview = typeof SmokeReview.Type;
export const SmokeChatResponse = Schema.Struct({
choices: Schema.NonEmptyArray(
Schema.Struct({
message: Schema.Struct({ content: Schema.String }),
})
),
});
export const SmokeModelCatalog = Schema.Struct({
data: Schema.Array(Schema.Struct({ id: MeaningfulString })),
});
export type SmokeModelCatalog = typeof SmokeModelCatalog.Type;
export const decodeSmokeModelCatalog = Effect.fn("Smoke.decodeModelCatalog")(
function* decodeSmokeModelCatalog(input: unknown) {
return yield* Schema.decodeUnknownEffect(SmokeModelCatalog)(input).pipe(
Effect.mapError(
(cause) =>
new SmokeContractError({
message: cause.message,
reason: "InvalidModelResponse",
})
)
);
}
);
export const decodeSmokeChatText = Effect.fn("Smoke.decodeChatText")(
function* decodeSmokeChatText(input: unknown) {
const response = yield* Schema.decodeUnknownEffect(SmokeChatResponse)(
input
).pipe(
Effect.mapError(
(cause) =>
new SmokeContractError({
message: cause.message,
reason: "InvalidModelResponse",
})
)
);
const text = response.choices[0].message.content;
if (text.trim().length === 0) {
return yield* Effect.fail(
new SmokeContractError({
message: "The model returned an empty response",
reason: "InvalidModelResponse",
})
);
}
return text;
}
);
export const decodeSmokePlan = Effect.fn("Smoke.decodePlan")(
function* decodeSmokePlan(input: unknown) {
return yield* Schema.decodeUnknownEffect(SmokePlan)(input).pipe(
Effect.mapError(
(cause) =>
new SmokeContractError({
message: cause.message,
reason: "InvalidReport",
})
)
);
}
);
export const decodeSmokeWorkerReport = Effect.fn("Smoke.decodeWorkerReport")(
function* decodeSmokeWorkerReport(input: unknown) {
return yield* Schema.decodeUnknownEffect(SmokeWorkerReport)(input).pipe(
Effect.mapError(
(cause) =>
new SmokeContractError({
message: cause.message,
reason: "InvalidReport",
})
)
);
}
);
export const decodeSmokeReview = Effect.fn("Smoke.decodeReview")(
function* decodeSmokeReview(input: unknown) {
return yield* Schema.decodeUnknownEffect(SmokeReview)(input).pipe(
Effect.mapError(
(cause) =>
new SmokeContractError({
message: cause.message,
reason: "InvalidReport",
})
)
);
}
);
export const SmokeCapabilityCheck = Schema.Struct({
id: MeaningfulString,
message: MeaningfulString,
passed: Schema.Boolean,
});
export type SmokeCapabilityCheck = typeof SmokeCapabilityCheck.Type;
export const validateSmokeCapabilities = Effect.fn(
"Smoke.validateCapabilities"
)(function* validateSmokeCapabilities(input: unknown) {
const checks = yield* Schema.decodeUnknownEffect(
Schema.NonEmptyArray(SmokeCapabilityCheck)
)(input).pipe(
Effect.mapError(
(cause) =>
new SmokeContractError({
message: cause.message,
reason: "InvalidInput",
})
)
);
const failed = checks.filter((check) => !check.passed);
if (failed.length > 0) {
return yield* Effect.fail(
new SmokeContractError({
message: failed
.map((check) => `${check.id}: ${check.message}`)
.join("; "),
reason: "InvalidReport",
})
);
}
return checks;
});
export const SmokeLoopState = Schema.Struct({
history: Schema.Array(SmokePhase),
lastError: Schema.NullOr(Schema.String),
phase: SmokePhase,
});
export type SmokeLoopState = typeof SmokeLoopState.Type;
export const initialSmokeLoopState = (): SmokeLoopState => ({
history: ["preflight"],
lastError: null,
phase: "preflight",
});
const nextPhase: Readonly<
Record<SmokePhase, Partial<Record<SmokeTransition, SmokePhase>>>
> = {
completed: {},
failed: {},
planning: {
"plan-validated": "worker-running",
"runtime-failed": "failed",
},
preflight: {
"contract-failed": "failed",
"preflight-passed": "planning",
"runtime-failed": "failed",
},
reviewing: {
"contract-failed": "failed",
"review-approved": "completed",
"runtime-failed": "failed",
},
"worker-running": {
"contract-failed": "failed",
"runtime-failed": "failed",
"worker-succeeded": "reviewing",
},
};
export const advanceSmokeLoop = Effect.fn("Smoke.advanceLoop")(
function* advanceSmokeLoop(
state: SmokeLoopState,
transition: SmokeTransition,
error?: string
) {
const phase = nextPhase[state.phase][transition];
if (phase === undefined) {
return yield* Effect.fail(
new SmokeContractError({
message: `Cannot apply ${transition} while smoke loop is ${state.phase}`,
reason: "InvalidTransition",
})
);
}
return {
history: [...state.history, phase],
lastError: phase === "failed" ? (error ?? "Smoke loop failed") : null,
phase,
} satisfies SmokeLoopState;
}
);
export const redactSmokeText = (
text: string,
secrets: readonly string[]
): string => {
let redacted = text;
for (const secret of secrets) {
if (secret.length > 0) {
redacted = redacted.replaceAll(secret, "[REDACTED]");
}
}
return redacted;
};

View File

@@ -16,6 +16,11 @@
"dependencies": {
"@base-ui/react": "^1.6.0",
"@shadcn/react": "^0.2.0",
"@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"ai": "^7.0.35",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "catalog:",
@@ -23,9 +28,12 @@
"react": "^19.2.7",
"react-dom": "^19.2.7",
"shadcn": "^4.12.0",
"shiki": "^4.3.1",
"sonner": "catalog:",
"streamdown": "^2.5.0",
"tailwind-merge": "catalog:",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.6"
},
"devDependencies": {
"@code/config": "workspace:*",

View File

@@ -0,0 +1,525 @@
"use client";
import { Button } from "@code/ui/components/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@code/ui/components/select";
import { cn } from "@code/ui/lib/utils";
import { CheckIcon, CopyIcon } from "lucide-react";
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type {
BundledLanguage,
BundledTheme,
HighlighterGeneric,
ThemedToken,
} from "shiki";
import { createHighlighter } from "shiki";
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline.
const hasFontStyle = (fontStyle: number | undefined, flag: number) =>
Math.floor((fontStyle ?? 0) / flag) % 2 === 1;
const isItalic = (fontStyle: number | undefined) => hasFontStyle(fontStyle, 1);
const isBold = (fontStyle: number | undefined) => hasFontStyle(fontStyle, 2);
const isUnderline = (fontStyle: number | undefined) =>
hasFontStyle(fontStyle, 4);
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
interface KeyedToken {
token: ThemedToken;
key: string;
}
interface KeyedLine {
tokens: KeyedToken[];
key: string;
}
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
lines.map((line, lineIdx) => ({
key: `line-${lineIdx}`,
tokens: line.map((token, tokenIdx) => ({
key: `line-${lineIdx}-${tokenIdx}`,
token,
})),
}));
// Token rendering component
const TokenSpan = ({ token }: { token: ThemedToken }) => (
<span
className="dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)]"
style={
{
backgroundColor: token.bgColor,
color: token.color,
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
...token.htmlStyle,
} as CSSProperties
}
>
{token.content}
</span>
);
// Line number styles using CSS counters
const LINE_NUMBER_CLASSES = cn(
"block",
"before:content-[counter(line)]",
"before:inline-block",
"before:[counter-increment:line]",
"before:w-8",
"before:mr-4",
"before:text-right",
"before:text-muted-foreground/50",
"before:font-mono",
"before:select-none"
);
// Line rendering component
const LineSpan = ({
keyedLine,
showLineNumbers,
}: {
keyedLine: KeyedLine;
showLineNumbers: boolean;
}) => (
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
{keyedLine.tokens.length === 0
? "\n"
: keyedLine.tokens.map(({ token, key }) => (
<TokenSpan key={key} token={token} />
))}
</span>
);
// Types
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
};
interface TokenizedCode {
tokens: ThemedToken[][];
fg: string;
bg: string;
}
interface CodeBlockContextType {
code: string;
}
// Context
const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});
// Highlighter cache (singleton per language)
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
>();
// Token cache
const tokensCache = new Map<string, TokenizedCode>();
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return `${language}:${code.length}:${start}:${end}`;
};
const getHighlighter = (
language: BundledLanguage
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
const cached = highlighterCache.get(language);
if (cached) {
return cached;
}
const highlighterPromise = createHighlighter({
langs: [language],
themes: ["github-light", "github-dark"],
});
highlighterCache.set(language, highlighterPromise);
return highlighterPromise;
};
// Create raw tokens for immediate display while highlighting loads
const createRawTokens = (code: string): TokenizedCode => ({
bg: "transparent",
fg: "inherit",
tokens: code.split("\n").map((line) =>
line === ""
? []
: [
{
color: "inherit",
content: line,
} as ThemedToken,
]
),
});
export const highlightCode = async (
code: string,
language: BundledLanguage
): Promise<TokenizedCode> => {
const tokensCacheKey = getTokensCacheKey(code, language);
const cached = tokensCache.get(tokensCacheKey);
if (cached) {
return cached;
}
const highlighter = await getHighlighter(language);
const availableLangs = highlighter.getLoadedLanguages();
const langToUse = availableLangs.includes(language) ? language : "text";
const result = highlighter.codeToTokens(code, {
lang: langToUse,
themes: {
dark: "github-dark",
light: "github-light",
},
});
const tokenized: TokenizedCode = {
bg: result.bg ?? "transparent",
fg: result.fg ?? "inherit",
tokens: result.tokens,
};
tokensCache.set(tokensCacheKey, tokenized);
return tokenized;
};
const CodeBlockBody = memo(
({
tokenized,
showLineNumbers,
className,
}: {
tokenized: TokenizedCode;
showLineNumbers: boolean;
className?: string;
}) => {
const preStyle = useMemo(
() => ({
backgroundColor: tokenized.bg,
color: tokenized.fg,
}),
[tokenized.bg, tokenized.fg]
);
const keyedLines = useMemo(
() => addKeysToTokens(tokenized.tokens),
[tokenized.tokens]
);
return (
<pre
className={cn(
"dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
className
)}
style={preStyle}
>
<code
className={cn(
"font-mono text-sm",
showLineNumbers && "[counter-increment:line_0] [counter-reset:line]"
)}
>
{keyedLines.map((keyedLine) => (
<LineSpan
key={keyedLine.key}
keyedLine={keyedLine}
showLineNumbers={showLineNumbers}
/>
))}
</code>
</pre>
);
},
(prevProps, nextProps) =>
prevProps.tokenized === nextProps.tokenized &&
prevProps.showLineNumbers === nextProps.showLineNumbers &&
prevProps.className === nextProps.className
);
CodeBlockBody.displayName = "CodeBlockBody";
export const CodeBlockContainer = ({
className,
language,
style,
...props
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
<div
className={cn(
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
className
)}
data-language={language}
style={{
containIntrinsicSize: "auto 200px",
contentVisibility: "auto",
...style,
}}
{...props}
/>
);
export const CodeBlockHeader = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
className
)}
{...props}
>
{children}
</div>
);
export const CodeBlockTitle = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex items-center gap-2", className)} {...props}>
{children}
</div>
);
export const CodeBlockFilename = ({
children,
className,
...props
}: HTMLAttributes<HTMLSpanElement>) => (
<span className={cn("font-mono", className)} {...props}>
{children}
</span>
);
export const CodeBlockActions = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
{...props}
>
{children}
</div>
);
export const CodeBlockContent = ({
code,
language,
showLineNumbers = false,
}: {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
}) => {
// Memoized raw tokens for immediate display
const rawTokens = useMemo(() => createRawTokens(code), [code]);
const cacheKey = getTokensCacheKey(code, language);
// Synchronous cache lookup avoids a loading flash after the first highlight.
const syncTokens = useMemo(
() => tokensCache.get(cacheKey) ?? rawTokens,
[cacheKey, rawTokens]
);
const [asyncResult, setAsyncResult] = useState<{
cacheKey: string;
tokens: TokenizedCode;
} | null>(null);
useEffect(() => {
let cancelled = false;
const loadHighlightedTokens = async () => {
try {
const result = await highlightCode(code, language);
if (!cancelled) {
setAsyncResult({ cacheKey, tokens: result });
}
} catch (error) {
console.error("Failed to highlight code:", error);
}
};
void loadHighlightedTokens();
return () => {
cancelled = true;
};
}, [cacheKey, code, language]);
const tokenized =
asyncResult?.cacheKey === cacheKey ? asyncResult.tokens : syncTokens;
return (
<div className="relative overflow-auto">
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
</div>
);
};
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => {
const contextValue = useMemo(() => ({ code }), [code]);
return (
<CodeBlockContext.Provider value={contextValue}>
<CodeBlockContainer className={className} language={language} {...props}>
{children}
<CodeBlockContent
code={code}
language={language}
showLineNumbers={showLineNumbers}
/>
</CodeBlockContainer>
</CodeBlockContext.Provider>
);
};
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = useCallback(async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
if (!isCopied) {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
timeoutRef.current = window.setTimeout(
() => setIsCopied(false),
timeout
);
}
} catch (error) {
onError?.(error as Error);
}
}, [code, onCopy, onError, timeout, isCopied]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[]
);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
export const CodeBlockLanguageSelector = (
props: CodeBlockLanguageSelectorProps
) => <Select {...props} />;
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
typeof SelectTrigger
>;
export const CodeBlockLanguageSelectorTrigger = ({
className,
...props
}: CodeBlockLanguageSelectorTriggerProps) => (
<SelectTrigger
className={cn(
"h-7 border-none bg-transparent px-2 text-xs shadow-none",
className
)}
size="sm"
{...props}
/>
);
export type CodeBlockLanguageSelectorValueProps = ComponentProps<
typeof SelectValue
>;
export const CodeBlockLanguageSelectorValue = (
props: CodeBlockLanguageSelectorValueProps
) => <SelectValue {...props} />;
export type CodeBlockLanguageSelectorContentProps = ComponentProps<
typeof SelectContent
>;
export const CodeBlockLanguageSelectorContent = ({
align = "end",
...props
}: CodeBlockLanguageSelectorContentProps) => (
<SelectContent align={align} {...props} />
);
export type CodeBlockLanguageSelectorItemProps = ComponentProps<
typeof SelectItem
>;
export const CodeBlockLanguageSelectorItem = (
props: CodeBlockLanguageSelectorItemProps
) => <SelectItem {...props} />;

View File

@@ -0,0 +1,168 @@
"use client";
import { Button } from "@code/ui/components/button";
import { cn } from "@code/ui/lib/utils";
import type { UIMessage } from "ai";
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
);
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string;
description?: string;
icon?: React.ReactNode;
};
export const ConversationEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};
const getMessageText = (message: UIMessage): string =>
message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
export type ConversationDownloadProps = Omit<
ComponentProps<typeof Button>,
"onClick"
> & {
messages: UIMessage[];
filename?: string;
formatMessage?: (message: UIMessage, index: number) => string;
};
const defaultFormatMessage = (message: UIMessage): string => {
const roleLabel =
message.role.charAt(0).toUpperCase() + message.role.slice(1);
return `**${roleLabel}:** ${getMessageText(message)}`;
};
export const messagesToMarkdown = (
messages: UIMessage[],
formatMessage: (
message: UIMessage,
index: number
) => string = defaultFormatMessage
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
export const ConversationDownload = ({
messages,
filename = "conversation.md",
formatMessage = defaultFormatMessage,
className,
children,
...props
}: ConversationDownloadProps) => {
const handleDownload = useCallback(() => {
const markdown = messagesToMarkdown(messages, formatMessage);
const blob = new Blob([markdown], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}, [messages, filename, formatMessage]);
return (
<Button
className={cn(
"absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted",
className
)}
onClick={handleDownload}
size="icon"
type="button"
variant="outline"
{...props}
>
{children ?? <DownloadIcon className="size-4" />}
</Button>
);
};

View File

@@ -0,0 +1,357 @@
"use client";
import { Button } from "@code/ui/components/button";
import { ButtonGroup, ButtonGroupText } from "@code/ui/components/button-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@code/ui/components/tooltip";
import { cn } from "@code/ui/lib/utils";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { Streamdown } from "streamdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[95%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"group-[.is-assistant]:text-foreground",
className
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({
className,
children,
...props
}: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon-sm",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
interface MessageBranchContextType {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
}
const MessageBranchContext = createContext<MessageBranchContextType | null>(
null
);
const useMessageBranch = () => {
const context = useContext(MessageBranchContext);
if (!context) {
throw new Error(
"MessageBranch components must be used within MessageBranch"
);
}
return context;
};
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = useCallback(
(newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
},
[onBranchChange]
);
const goToPrevious = useCallback(() => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
}, [currentBranch, branches.length, handleBranchChange]);
const goToNext = useCallback(() => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
}, [currentBranch, branches.length, handleBranchChange]);
const contextValue = useMemo<MessageBranchContextType>(
() => ({
branches,
currentBranch,
goToNext,
goToPrevious,
setBranches,
totalBranches: branches.length,
}),
[branches, currentBranch, goToNext, goToPrevious]
);
return (
<MessageBranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({
children,
...props
}: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch();
const childrenArray = useMemo(
() => (Array.isArray(children) ? children : [children]),
[children]
);
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
export const MessageBranchSelector = ({
className,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className={cn(
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
className
)}
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({
children,
...props
}: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({
children,
...props
}: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({
className,
...props
}: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn(
"border-none bg-transparent text-muted-foreground shadow-none",
className
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
const streamdownPlugins = { cjk, code, math, mermaid };
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
plugins={streamdownPlugins}
{...props}
/>
),
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
nextProps.isAnimating === prevProps.isAnimating
);
MessageResponse.displayName = "MessageResponse";
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({
className,
children,
...props
}: MessageToolbarProps) => (
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className
)}
{...props}
>
{children}
</div>
);

View File

@@ -0,0 +1,173 @@
"use client";
import { Badge } from "@code/ui/components/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@code/ui/components/collapsible";
import { cn } from "@code/ui/lib/utils";
import type { DynamicToolUIPart, ToolUIPart } from "ai";
import {
CheckCircleIcon,
ChevronDownIcon,
CircleIcon,
ClockIcon,
WrenchIcon,
XCircleIcon,
} from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { isValidElement } from "react";
import { CodeBlock } from "./code-block";
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn("group not-prose mb-4 w-full rounded-md border", className)}
{...props}
/>
);
export type ToolPart = ToolUIPart | DynamicToolUIPart;
export type ToolHeaderProps = {
title?: string;
className?: string;
} & (
| { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
| {
type: DynamicToolUIPart["type"];
state: DynamicToolUIPart["state"];
toolName: string;
}
);
const statusLabels: Record<ToolPart["state"], string> = {
"approval-requested": "Awaiting Approval",
"approval-responded": "Responded",
"input-available": "Running",
"input-streaming": "Pending",
"output-available": "Completed",
"output-denied": "Denied",
"output-error": "Error",
};
const statusIcons: Record<ToolPart["state"], ReactNode> = {
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
"input-streaming": <CircleIcon className="size-4" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
};
export const getStatusBadge = (status: ToolPart["state"]) => (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
{statusIcons[status]}
{statusLabels[status]}
</Badge>
);
export const ToolHeader = ({
className,
title,
type,
state,
toolName,
...props
}: ToolHeaderProps) => {
const derivedName =
type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center justify-between gap-4 p-3",
className
)}
{...props}
>
<div className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{title ?? derivedName}</span>
{getStatusBadge(state)}
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
);
};
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 space-y-4 p-4 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolPart["input"];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn("space-y-2 overflow-hidden", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md bg-muted/50">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
</div>
</div>
);
export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolPart["output"];
errorText: ToolPart["errorText"];
};
export const ToolOutput = ({
className,
output,
errorText,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
return null;
}
let Output = <div>{output as ReactNode}</div>;
if (typeof output === "object" && !isValidElement(output)) {
Output = (
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
);
} else if (typeof output === "string") {
Output = <CodeBlock code={output} language="json" />;
}
return (
<div className={cn("space-y-2", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? "Error" : "Result"}
</h4>
<div
className={cn(
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
errorText
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground"
)}
>
{errorText && <div>{errorText}</div>}
{Output}
</div>
</div>
);
};

View File

@@ -0,0 +1,51 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "@code/ui/lib/utils";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-none border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
defaultVariants: {
variant: "default",
},
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
},
},
}
);
const Badge = ({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) =>
useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
});
export { Badge, badgeVariants };

View File

@@ -0,0 +1,82 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { Separator } from "@code/ui/components/separator";
import { cn } from "@code/ui/lib/utils";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
const buttonGroupVariants = cva(
"m-0 flex min-w-0 w-fit items-stretch rounded-none border-0 p-0 *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-none [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
defaultVariants: {
orientation: "horizontal",
},
variants: {
orientation: {
horizontal:
"*:data-slot:rounded-r-none [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
vertical:
"flex-col *:data-slot:rounded-b-none [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
},
}
);
const ButtonGroup = ({
className,
orientation,
...props
}: React.ComponentProps<"fieldset"> &
VariantProps<typeof buttonGroupVariants>) => (
<fieldset
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
const ButtonGroupText = ({
className,
render,
...props
}: useRender.ComponentProps<"div">) =>
useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex items-center gap-2 rounded-none border bg-muted px-2.5 text-xs font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
),
},
props
),
render,
state: {
slot: "button-group-text",
},
});
const ButtonGroupSeparator = ({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) => (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className
)}
{...props}
/>
);
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
};

View File

@@ -0,0 +1,17 @@
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
const Collapsible = ({ ...props }: CollapsiblePrimitive.Root.Props) => (
<CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
);
const CollapsibleTrigger = ({
...props
}: CollapsiblePrimitive.Trigger.Props) => (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
);
const CollapsibleContent = ({ ...props }: CollapsiblePrimitive.Panel.Props) => (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
);
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,363 @@
import { cn } from "@code/ui/lib/utils";
import { ArrowUp, GripVertical, LoaderCircle } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
const MobileViewport = ({ className, ...props }: ComponentProps<"div">) => (
<div
data-slot="mobile-viewport"
className={cn(
"relative mx-auto h-[100dvh] min-h-0 w-full max-w-[390px] overflow-hidden bg-background text-foreground sm:border-x sm:border-border/70",
className
)}
{...props}
/>
);
const MobileChatMark = ({ className, ...props }: ComponentProps<"span">) => (
<span
aria-hidden="true"
data-slot="mobile-chat-mark"
className={cn(
"inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-[#f1f1ef] text-[#171716]",
className
)}
{...props}
>
<GripVertical className="size-3.5" strokeWidth={2.2} />
</span>
);
interface MobileChatHeaderProps extends Omit<
ComponentProps<"header">,
"children"
> {
active?: boolean;
label?: string;
statusLabel: string;
}
const MobileChatHeader = ({
active = false,
className,
label = "Zopu",
statusLabel,
...props
}: MobileChatHeaderProps) => (
<header
data-slot="mobile-chat-header"
className={cn(
"flex h-[78px] shrink-0 items-center border-b border-[#e9e9e7] bg-[#fefefe] px-4",
className
)}
{...props}
>
<div className="flex w-full items-center gap-3">
<MobileChatMark className="size-7 rounded-[7px]" />
<h1 className="text-[17px] leading-none font-semibold tracking-[-0.015em] text-[#171716]">
{label}
</h1>
<span
aria-hidden="true"
className={cn(
"ml-auto size-1.5 rounded-full bg-[#b9bbb8]",
active && "bg-[#17c777]"
)}
/>
<span className="sr-only" aria-live="polite">
{statusLabel}
</span>
</div>
</header>
);
interface MobileChatMessageProps extends Omit<
ComponentProps<"article">,
"role"
> {
sender: "assistant" | "user";
}
const MobileChatMessage = ({
className,
sender,
...props
}: MobileChatMessageProps) => (
<article
data-sender={sender}
data-slot="mobile-chat-message"
className={cn(
"flex w-full min-w-0",
sender === "user" ? "justify-end" : "justify-start",
className
)}
{...props}
/>
);
interface MobileChatBubbleProps extends Omit<ComponentProps<"div">, "role"> {
sender: "assistant" | "user";
}
const MobileChatBubble = ({
className,
sender,
...props
}: MobileChatBubbleProps) => (
<div
data-sender={sender}
data-slot="mobile-chat-bubble"
className={cn(
"min-w-0 text-[15px] tracking-[-0.012em] wrap-break-word",
sender === "user"
? "max-w-[260px] rounded-[20px] bg-[#0d0d0c] px-[14px] py-[11px] leading-[18px] text-[#fafafa]"
: "w-full leading-[18px] text-[#232321]",
className
)}
{...props}
/>
);
interface MobileChatAssistantLabelProps extends ComponentProps<"div"> {
label?: string;
state?: ReactNode;
}
const MobileChatAssistantLabel = ({
className,
label = "Zopu",
state,
...props
}: MobileChatAssistantLabelProps) => (
<div
data-slot="mobile-chat-assistant-label"
className={cn(
"mb-3 flex items-center gap-2 text-[14px] leading-5 text-[#6f706e]",
className
)}
{...props}
>
<MobileChatMark className="size-5 rounded-[5px] [&_svg]:size-3" />
<span>{label}</span>
{state}
</div>
);
interface MobileChatToolCallProps extends ComponentProps<"div"> {
detail?: ReactNode;
icon?: ReactNode;
status: string;
tone?: "error" | "neutral" | "success";
toolName: string;
}
const MobileChatToolCall = ({
children,
className,
detail,
icon,
status,
tone = "neutral",
toolName,
...props
}: MobileChatToolCallProps) => {
let content: ReactNode;
if (children) {
content = <div className="mt-2 grid gap-2">{children}</div>;
} else if (detail) {
content = (
<div className="mt-2 flex items-start gap-2">
<MobileChatMark className="mt-0.5 size-4 rounded-[4px] [&_svg]:size-2.5" />
<p className="line-clamp-2 min-w-0 text-[11px] leading-[15px] text-[#555653]">
{detail}
</p>
</div>
);
}
return (
<div
data-slot="mobile-chat-tool-call"
className={cn(
"mb-3 overflow-hidden rounded-[16px] bg-[#f4f4f2] px-3 py-3 text-[#171716]",
className
)}
{...props}
>
<div className="flex min-h-5 items-center gap-2">
<span className="flex size-5 shrink-0 items-center justify-center rounded-[5px] bg-[#e8e8e5] text-[#171716]">
{icon ?? <GripVertical className="size-3" strokeWidth={2.2} />}
</span>
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 font-semibold">
{toolName}
</span>
<span
className={cn(
"rounded-full bg-[#e6e7e4] px-2 py-1 text-[9px] leading-none font-medium text-[#454643]",
tone === "success" && "bg-[#dff5e8] text-[#174b32]",
tone === "error" && "bg-[#f8dfdc] text-[#7b2821]"
)}
>
{status}
</span>
</div>
{content}
</div>
);
};
interface MobileChatToolResultProps extends ComponentProps<"div"> {
icon?: ReactNode;
source?: string;
title: string;
}
const MobileChatToolResult = ({
className,
icon,
source,
title,
...props
}: MobileChatToolResultProps) => (
<div
data-slot="mobile-chat-tool-result"
className={cn("flex min-w-0 items-start gap-2", className)}
{...props}
>
<span className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-[4px] bg-[#e8e8e5] text-[#171716]">
{icon ?? <GripVertical className="size-2.5" strokeWidth={2.2} />}
</span>
<span className="min-w-0">
<span className="block truncate text-[11px] leading-[14px] font-medium text-[#292a28]">
{title}
</span>
{source ? (
<span className="block truncate text-[9px] leading-[12px] text-[#9a9b98]">
{source}
</span>
) : null}
</span>
</div>
);
interface MobileChatComposerDockProps extends ComponentProps<"div"> {
errorMessage?: string;
statusMessage?: ReactNode;
}
const MobileChatComposerDock = ({
children,
className,
errorMessage,
statusMessage,
...props
}: MobileChatComposerDockProps) => {
const message = errorMessage ?? statusMessage;
return (
<div
data-slot="mobile-chat-composer-dock"
className={cn(
"shrink-0 border-t border-[#e8e8e6] bg-[#fefefe] pb-[env(safe-area-inset-bottom)]",
className
)}
{...props}
>
{message ? (
<div
id={errorMessage ? "composer-error" : undefined}
className={cn(
"px-3 pt-2 text-center text-[10px] leading-4 text-[#777875]",
errorMessage && "text-destructive"
)}
>
{message}
</div>
) : null}
{children}
</div>
);
};
interface MobileChatComposerProps extends Omit<
ComponentProps<"form">,
"children"
> {
busy?: boolean;
canSend: boolean;
errorMessage?: string;
statusMessage?: ReactNode;
textareaProps: ComponentProps<"textarea">;
}
const MobileChatComposer = ({
busy = false,
canSend,
className,
errorMessage,
statusMessage,
textareaProps,
...props
}: MobileChatComposerProps) => {
const { className: textareaClassName, ...inputProps } = textareaProps;
return (
<MobileChatComposerDock
errorMessage={errorMessage}
statusMessage={statusMessage}
>
<form
aria-label="Send a message"
className={cn("w-full px-3 pt-[11px] pb-3", className)}
{...props}
>
<div className="flex items-end gap-2.5">
<textarea
aria-label="Message Zopu"
placeholder="Message Zopu..."
rows={1}
className={cn(
"min-h-10 max-h-28 flex-1 resize-none rounded-[20px] border-0 bg-[#f4f4f2] px-4 py-[10px] text-[15px] leading-5 text-[#232321] outline-none placeholder:text-center placeholder:text-[#b6b7b4] focus-visible:ring-2 focus-visible:ring-[#171716]/15 disabled:cursor-not-allowed disabled:opacity-60",
textareaClassName
)}
{...inputProps}
/>
<button
aria-label="Send message"
className="inline-flex size-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0c] text-[#fafafa] transition-transform active:scale-[0.96] disabled:bg-[#dededb] disabled:text-[#9b9c99]"
disabled={!canSend}
type="submit"
>
{busy ? (
<LoaderCircle className="size-[18px] animate-spin" />
) : (
<ArrowUp className="size-[18px]" strokeWidth={2.2} />
)}
</button>
</div>
</form>
</MobileChatComposerDock>
);
};
export {
MobileChatAssistantLabel,
MobileChatBubble,
MobileChatComposer,
MobileChatComposerDock,
MobileChatHeader,
MobileChatMark,
MobileChatMessage,
MobileChatToolResult,
MobileChatToolCall,
MobileViewport,
};
export type {
MobileChatAssistantLabelProps,
MobileChatBubbleProps,
MobileChatComposerProps,
MobileChatComposerDockProps,
MobileChatHeaderProps,
MobileChatMessageProps,
MobileChatToolCallProps,
MobileChatToolResultProps,
};

View File

@@ -0,0 +1,17 @@
export {
MobileAssistantChatScreen,
MobileExpandedWorkScreen,
MobileHomeScreen,
MobileWorkListScreen,
MobileWorkUnitDetailScreen,
} from "./mobile-workspace/index";
export type {
MobileAssistantMessageView,
MobileAssistantView,
MobileProjectView,
MobileSignalView,
MobileWorkspaceScreenProps,
MobileWorkspaceView,
MobileWorkUnitTone,
MobileWorkUnitView,
} from "./mobile-workspace/index";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
export { MobileAssistantChatScreen } from "./mobile-assistant-chat-screen";
export { MobileExpandedWorkScreen } from "./mobile-expanded-work-screen";
export { MobileHomeScreen } from "./mobile-home-screen";
export { MobileWorkListScreen } from "./mobile-work-list-screen";
export { MobileWorkUnitDetailScreen } from "./mobile-work-unit-detail-screen";
export type {
MobileAssistantMessageView,
MobileAssistantView,
MobileProjectView,
MobileSignalView,
MobileWorkspaceScreenProps,
MobileWorkspaceView,
MobileWorkUnitTone,
MobileWorkUnitView,
} from "./types";

View File

@@ -0,0 +1,6 @@
import { MobileWorkspaceScreenRenderer } from "../mobile-workspace-screens";
import type { MobileWorkspaceScreenProps } from "./types";
export const MobileAssistantChatScreen = (
props: MobileWorkspaceScreenProps
) => <MobileWorkspaceScreenRenderer {...props} variant="character-pass" />;

View File

@@ -0,0 +1,6 @@
import { MobileWorkspaceScreenRenderer } from "../mobile-workspace-screens";
import type { MobileWorkspaceScreenProps } from "./types";
export const MobileExpandedWorkScreen = (props: MobileWorkspaceScreenProps) => (
<MobileWorkspaceScreenRenderer {...props} variant="home-expanded-in-place" />
);

View File

@@ -0,0 +1,6 @@
import { MobileWorkspaceScreenRenderer } from "../mobile-workspace-screens";
import type { MobileWorkspaceScreenProps } from "./types";
export const MobileHomeScreen = (props: MobileWorkspaceScreenProps) => (
<MobileWorkspaceScreenRenderer {...props} variant="work-units-home" />
);

View File

@@ -0,0 +1,6 @@
import { MobileWorkspaceScreenRenderer } from "../mobile-workspace-screens";
import type { MobileWorkspaceScreenProps } from "./types";
export const MobileWorkListScreen = (props: MobileWorkspaceScreenProps) => (
<MobileWorkspaceScreenRenderer {...props} variant="vertical-work-stack" />
);

View File

@@ -0,0 +1,6 @@
import { MobileWorkspaceScreenRenderer } from "../mobile-workspace-screens";
import type { MobileWorkspaceScreenProps } from "./types";
export const MobileWorkUnitDetailScreen = (
props: MobileWorkspaceScreenProps
) => <MobileWorkspaceScreenRenderer {...props} variant="expanded-work-unit" />;

View File

@@ -0,0 +1,63 @@
export type MobileWorkUnitTone = "blue" | "green" | "orange" | "purple";
export interface MobileProjectView {
readonly connected: boolean;
readonly id: string;
readonly name: string;
}
export interface MobileWorkUnitView {
readonly artifactCount: number;
readonly code: string;
readonly id: string;
readonly canRetry: boolean;
readonly canStart: boolean;
readonly nextAction: string;
readonly pullRequestNumber?: number;
readonly progress: number;
readonly reviewUrl?: string;
readonly statusLabel: string;
readonly summary: string;
readonly title: string;
readonly tone: MobileWorkUnitTone;
readonly updatedLabel: string;
}
export interface MobileSignalView {
readonly desiredOutcome: string;
readonly id: string;
readonly projectId?: string;
readonly summary: string;
readonly title: string;
}
export interface MobileAssistantMessageView {
readonly id: string;
readonly role: "assistant" | "user";
readonly text: string;
}
export interface MobileAssistantView {
readonly isBusy: boolean;
readonly messages: readonly MobileAssistantMessageView[];
readonly statusLabel: string;
readonly statusTone: "amber" | "green" | "red";
}
export interface MobileWorkspaceView {
readonly activeCount: number;
readonly activityCount: number;
readonly artifactCount: number;
readonly assistant: MobileAssistantView;
readonly isLoading: boolean;
readonly latestSignal?: MobileSignalView;
readonly needsAttentionCount: number;
readonly organizationLabel: string;
readonly projects: readonly MobileProjectView[];
readonly projectName?: string;
readonly selectedProjectId?: string;
readonly selectedWorkUnit?: MobileWorkUnitView;
readonly shippedCount: number;
readonly totalCount: number;
readonly workUnits: readonly MobileWorkUnitView[];
}

View File

@@ -0,0 +1,16 @@
import type { MobileWorkspaceRendererProps } from "../mobile-workspace-screens";
export type MobileWorkspaceScreenProps = Omit<
MobileWorkspaceRendererProps,
"variant"
>;
export type {
MobileAssistantMessageView,
MobileAssistantView,
MobileProjectView,
MobileSignalView,
MobileWorkspaceView,
MobileWorkUnitTone,
MobileWorkUnitView,
} from "./models";

View File

@@ -0,0 +1,183 @@
"use client";
import { Select as SelectPrimitive } from "@base-ui/react/select";
import { cn } from "@code/ui/lib/utils";
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react";
import * as React from "react";
const Select = SelectPrimitive.Root;
const SelectGroup = ({ className, ...props }: SelectPrimitive.Group.Props) => (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1", className)}
{...props}
/>
);
const SelectValue = ({ className, ...props }: SelectPrimitive.Value.Props) => (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
);
const SelectTrigger = ({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default";
}) => (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-none border border-input bg-transparent py-2 pr-2 pl-2.5 text-xs whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-none *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
);
const SelectScrollUpButton = ({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) => (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpArrow>
);
const SelectScrollDownButton = ({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) => (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownArrow>
);
const SelectContent = ({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-none bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
const SelectLabel = ({
className,
...props
}: SelectPrimitive.GroupLabel.Props) => (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-2 py-2 text-xs text-muted-foreground", className)}
{...props}
/>
);
const SelectItem = ({
className,
children,
...props
}: SelectPrimitive.Item.Props) => (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
const SelectSeparator = ({
className,
...props
}: SelectPrimitive.Separator.Props) => (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 h-px bg-border", className)}
{...props}
/>
);
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -42,41 +42,41 @@
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--background: oklch(0.13 0.003 90);
--foreground: oklch(0.93 0.002 90);
--card: oklch(0.17 0.004 90);
--card-foreground: oklch(0.93 0.002 90);
--popover: oklch(0.17 0.004 90);
--popover-foreground: oklch(0.93 0.002 90);
--primary: oklch(0.87 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--primary-foreground: oklch(0.17 0.004 90);
--secondary: oklch(0.22 0.004 90);
--secondary-foreground: oklch(0.93 0.002 90);
--muted: oklch(0.22 0.004 90);
--muted-foreground: oklch(0.62 0.004 90);
--accent: oklch(0.27 0.005 90);
--accent-foreground: oklch(0.93 0.002 90);
--destructive: oklch(0.65 0.2 25);
--border: oklch(0.28 0.004 90);
--input: oklch(0.22 0.004 90);
--ring: oklch(0.5 0.004 90);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar: oklch(0.17 0.004 90);
--sidebar-foreground: oklch(0.93 0.002 90);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--sidebar-primary-foreground: oklch(0.93 0.002 90);
--sidebar-accent: oklch(0.22 0.004 90);
--sidebar-accent-foreground: oklch(0.93 0.002 90);
--sidebar-border: oklch(0.28 0.004 90);
--sidebar-ring: oklch(0.5 0.004 90);
}
@theme inline {
--font-sans: "Inter Variable", sans-serif;
--font-sans: "Inter", sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);