feat(zopu): chat server, standalone chat route, and CORS

This commit is contained in:
-Puter
2026-07-25 16:12:04 +05:30
parent 19f771ea71
commit d4745591a9
25 changed files with 2520 additions and 5 deletions

View File

@@ -32,3 +32,6 @@ AGENT_MODEL_BASE_URL=https://ai.example.com/v1
AGENT_MODEL_API_KEY=replace-with-provider-api-key
AGENT_MODEL_CONTEXT_WINDOW=262000
AGENT_MODEL_MAX_TOKENS=131072
# Zopu lean server (standalone chat + work actor)
VITE_ZOPU_SERVER_URL=http://localhost:3590

View File

@@ -0,0 +1,186 @@
import { env } from "@code/env/web";
import { FlueProvider, useFlueAgent } from "@flue/react";
import { createFlueClient } from "@flue/sdk";
import { ArrowUp, Loader2, MessageSquare } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
const STANDALONE_CHAT_ID = "zopu-standalone";
const StandaloneFlueProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const client = useMemo(
() =>
createFlueClient({
baseUrl: env.VITE_ZOPU_SERVER_URL,
}),
[]
);
return <FlueProvider client={client}>{children}</FlueProvider>;
};
const extractText = (parts: { type: string; text?: string }[]): string =>
parts
.filter((p) => p.type === "text" && p.text)
.map((p) => p.text ?? "")
.join("");
const ChatMessage = ({
parts,
role,
}: {
parts: { type: string; text?: string }[];
role: string;
}) => {
const isUser = role === "user";
return (
<div
className={`flex w-full gap-3 px-4 py-3 ${isUser ? "justify-end" : "justify-start"}`}
>
{!isUser && (
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-[#1a1a19] text-[#e8e8e6]">
<MessageSquare className="size-3.5" />
</div>
)}
<div
className={`max-w-[78%] whitespace-pre-wrap rounded-2xl px-4 py-2.5 text-[15px] leading-relaxed ${
isUser ? "bg-[#e8e8e6] text-[#0e0e0d]" : "bg-[#1a1a19] text-[#e8e8e6]"
}`}
>
{extractText(parts) || <span className="text-[#888]">...</span>}
</div>
</div>
);
};
const ChatContent = () => {
const agent = useFlueAgent({
id: STANDALONE_CHAT_ID,
name: "zopu",
});
const [input, setInput] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
const hasMessages = agent.messages.length > 0;
const isBusy = agent.status === "submitted" || agent.status === "streaming";
useEffect(() => {
const el = scrollRef.current;
if (el) {
el.scrollTop = el.scrollHeight;
}
}, [agent.messages, isBusy]);
const submit = async () => {
const message = input.trim();
if (!message || isBusy) {
return;
}
setInput("");
await agent.sendMessage(message);
};
return (
<div className="flex h-svh flex-col bg-[#0e0e0d]">
<header className="flex items-center gap-2 border-b border-[#1f1f1e] px-4 py-3">
<div className="flex size-7 items-center justify-center rounded-full bg-[#e8e8e6]">
<span className="text-sm font-bold text-[#0e0e0d]">Z</span>
</div>
<div className="flex-1">
<h1 className="text-[15px] font-semibold text-[#e8e8e6]">Zopu</h1>
<p className="text-xs text-[#888]">
{isBusy ? "working..." : "ready"}
</p>
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto py-2" ref={scrollRef}>
{!hasMessages && !agent.historyReady && (
<div className="flex h-full items-center justify-center">
<Loader2 className="size-5 animate-spin text-[#555]" />
</div>
)}
{!hasMessages && agent.historyReady && (
<div className="flex h-full flex-col items-center justify-center gap-4 px-8 text-center">
<div className="flex size-12 items-center justify-center rounded-2xl bg-[#1a1a19]">
<MessageSquare className="size-5 text-[#e8e8e6]" />
</div>
<div>
<p className="text-[15px] font-medium text-[#e8e8e6]">
Chat with Zopu
</p>
<p className="mt-1 text-sm text-[#888]">
Ask about issues, trigger work, or check PR status.
</p>
</div>
<div className="flex flex-wrap justify-center gap-2 pt-2">
{[
"List recent pull requests",
"Create an issue for adding a settings page",
].map((suggestion) => (
<button
className="rounded-full border border-[#2a2a29] bg-[#161615] px-3 py-1.5 text-xs text-[#aaa] transition hover:border-[#3a3a39] hover:text-[#e8e8e6]"
key={suggestion}
onClick={() => void agent.sendMessage(suggestion)}
type="button"
>
{suggestion}
</button>
))}
</div>
</div>
)}
{agent.messages.map((message) => (
<ChatMessage
key={message.id}
parts={message.parts}
role={message.role}
/>
))}
</div>
<div className="border-t border-[#1f1f1e] px-3 py-3">
<div className="flex items-end gap-2 rounded-2xl border border-[#2a2a29] bg-[#161615] px-3 py-2">
<textarea
className="flex-1 resize-none bg-transparent text-[15px] text-[#e8e8e6] placeholder-[#666] outline-none"
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void submit();
}
}}
placeholder="Message Zopu..."
rows={1}
style={{ maxHeight: 120 }}
value={input}
/>
<button
className={`flex size-8 shrink-0 items-center justify-center rounded-lg transition ${
input.trim() && !isBusy
? "bg-[#e8e8e6] text-[#0e0e0d]"
: "bg-[#2a2a29] text-[#555]"
}`}
disabled={!input.trim() || isBusy}
onClick={() => void submit()}
type="button"
>
{isBusy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ArrowUp className="size-4" />
)}
</button>
</div>
</div>
</div>
);
};
export const StandaloneChatPage = () => (
<StandaloneFlueProvider>
<ChatContent />
</StandaloneFlueProvider>
);

View File

@@ -2,6 +2,8 @@ import { index, layout, route } from "@react-router/dev/routes";
import type { RouteConfig } from "@react-router/dev/routes";
export default [
// Standalone chat page — no auth, mobile-first, talks to the zopu server.
route("chat", "./routes/standalone/chat/page.tsx"),
layout("./routes/auth/layout.tsx", [
route("login", "./routes/auth/login/page.tsx"),
route("signup", "./routes/auth/signup/page.tsx"),
@@ -9,7 +11,7 @@ export default [
layout("./routes/app/layout.tsx", [
layout("./routes/app/mobile/layout.tsx", [
index("./routes/app/mobile/page.tsx"),
route("chat", "./routes/app/mobile/chat/page.tsx"),
route("mobile/chat", "./routes/app/mobile/chat/page.tsx"),
route("work", "./routes/app/mobile/work-list/page.tsx"),
route("work/:workUnitId", "./routes/app/mobile/work/page.tsx"),
]),

View File

@@ -0,0 +1,16 @@
import { StandaloneChatPage } from "@/components/standalone-chat/standalone-chat-page";
import type { Route } from "./+types/page";
export const meta = (_args: Route.MetaArgs) => [
{
content: "width=device-width, initial-scale=1, viewport-fit=cover",
name: "viewport",
},
{ content: "Chat with Zopu", name: "description" },
{ title: "Zopu Chat" },
];
export default function Page() {
return <StandaloneChatPage />;
}

View File

@@ -257,6 +257,27 @@
"vitest": "catalog:",
},
},
"packages/server": {
"name": "@code/server",
"version": "0.0.0",
"dependencies": {
"@agentos-software/codex-cli": "0.3.4",
"@agentos-software/git": "0.3.3",
"@agentos-software/opencode": "0.2.7",
"@agentos-software/pi": "0.2.7",
"@flue/runtime": "1.0.0-beta.9",
"@rivet-dev/agentos-core": "catalog:",
"effect": "catalog:",
"hono": "4.12.30",
"valibot": "^1.4.2",
},
"devDependencies": {
"@code/config": "workspace:*",
"@flue/cli": "latest",
"@types/bun": "catalog:",
"typescript": "catalog:",
},
},
"packages/ui": {
"name": "@code/ui",
"version": "0.0.0",
@@ -653,6 +674,8 @@
"@code/primitives": ["@code/primitives@workspace:packages/primitives"],
"@code/server": ["@code/server@workspace:packages/server"],
"@code/ui": ["@code/ui@workspace:packages/ui"],
"@convex-dev/better-auth": ["@convex-dev/better-auth@0.12.5", "", { "dependencies": { "@better-fetch/fetch": "^1.1.18", "common-tags": "^1.8.2", "convex-helpers": "^0.1.95", "jose": "^6.1.0", "remeda": "^2.32.0", "semver": "^7.7.3", "type-fest": "^5.0.0", "zod": "^4.0.0" }, "peerDependencies": { "better-auth": ">=1.6.11 <1.7.0", "convex": "^1.25.0", "react": "^18.3.1 || ^19.0.0" } }, "sha512-YFUbGk04OlINno9FpOTNZN67AP+kSQsblfep5zs1f/WTgMWjD5FymU6rSSh3mu52gukmDNnBJTjC/lj4QRpjlQ=="],
@@ -3189,7 +3212,7 @@
"parse-png": ["parse-png@2.1.0", "", { "dependencies": { "pngjs": "^3.3.0" } }, "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="],
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="],
@@ -3981,6 +4004,8 @@
"@code/backend/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="],
"@code/server/hono": ["hono@4.12.30", "", {}, "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog=="],
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
@@ -4153,8 +4178,6 @@
"cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"cli-highlight/parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="],
"cli-highlight/yargs": ["yargs@16.2.2", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -4241,6 +4264,10 @@
"googleapis-common/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
"hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"hosted-git-info/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],

1281
docs/dogfood-plan.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -61,7 +61,9 @@
"subtree": "bun run scripts/subtree.ts",
"fix": "ultracite fix",
"orb:proof": "bun run scripts/orb-proof.ts",
"orb:run": "bun run scripts/orb-project-run.ts"
"orb:run": "bun run scripts/orb-project-run.ts",
"dev:zopu": "vp run --filter @code/server dev",
"dev:zopu:web": "vp run --filter web dev"
},
"dependencies": {},
"devDependencies": {

View File

@@ -26,6 +26,7 @@ import type * as projectIssues from "../projectIssues.js";
import type * as projectStore from "../projectStore.js";
import type * as projects from "../projects.js";
import type * as publicGit from "../publicGit.js";
import type * as signalRouting from "../signalRouting.js";
import type * as signals from "../signals.js";
import type * as todos from "../todos.js";
@@ -54,6 +55,7 @@ declare const fullApi: ApiFromModules<{
projectStore: typeof projectStore;
projects: typeof projects;
publicGit: typeof publicGit;
signalRouting: typeof signalRouting;
signals: typeof signals;
todos: typeof todos;
}>;

View File

@@ -11,6 +11,7 @@ export const env = createEnv({
VITE_CONVEX_SITE_URL: convexUrlSchema("example.convex.site"),
VITE_CONVEX_URL: convexUrlSchema("example.convex.cloud"),
VITE_FLUE_URL: z.url(),
VITE_ZOPU_SERVER_URL: z.url(),
},
clientPrefix: "VITE_",
emptyStringAsUndefined: true,

View File

@@ -0,0 +1,13 @@
{
"name": "@code/primitives",
"exports": {
".": "./src/index.ts",
"./agent-os": "./src/agent-os.ts",
"./git": "./src/git.ts",
"./project": "./src/project.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"
},

Binary file not shown.

View File

@@ -0,0 +1,11 @@
import { defineConfig } from "@flue/cli/config";
export default defineConfig({ target: "node" });
export const vite = {
server: {
watch: {
ignored: ["**/data/**", "**/dist/**"],
},
},
};

View File

@@ -0,0 +1,29 @@
{
"name": "@code/server",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --env-file=../../.env flue dev --port 3590",
"build": "bun --env-file=../../.env flue build --target node",
"start": "bun --env-file=../../.env dist/server.mjs",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@agentos-software/codex-cli": "0.3.4",
"@agentos-software/git": "0.3.3",
"@agentos-software/opencode": "0.2.7",
"@agentos-software/pi": "0.2.7",
"@flue/runtime": "1.0.0-beta.9",
"@rivet-dev/agentos-core": "catalog:",
"effect": "catalog:",
"hono": "4.12.30",
"valibot": "^1.4.2"
},
"devDependencies": {
"@code/config": "workspace:*",
"@flue/cli": "latest",
"@types/bun": "catalog:",
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,42 @@
import { defineAgent } from "@flue/runtime";
import { createIssueTool, editIssueTool } from "../tools/issues";
import {
checkPrStatusTool,
listPullRequestsTool,
} from "../tools/pull-requests";
import { env } from "../tools/runtime";
import { triggerWorkflowTool } from "../tools/workflows";
const INSTRUCTIONS = `You are Zopu, a lean coding-agent orchestrator for a single Git repository.
You chat with the user and help them manage work on the repo. You have these tools:
- create_issue: Track new work as a Gitea issue when the user describes something actionable.
- edit_issue: Update or close an existing issue (set state to "closed" to resolve).
- trigger_workflow: Start the resolve-issue work actor for a given issue number. The actor boots an isolated AgentOS VM, implements the fix, commits, and opens a pull request.
- list_pull_requests: Show recent PRs and their state.
- check_pr_status: Check whether a specific PR is open, merged, or closed — use this to tell the user whether an issue's work landed.
Guidelines:
- Keep conversation natural. Only create issues when the user describes concrete work.
- When the user asks to "work on" or "fix" an issue, trigger the workflow with the issue number.
- After triggering, tell the user the run ID. They can ask you to check the PR status later.
- Be concise. Confirm actions with the key detail (issue number, PR number, URL).`;
export default defineAgent(() => ({
description:
"Lean chat agent that manages issues, triggers the resolve-issue work actor, and checks PR status.",
instructions: INSTRUCTIONS,
model: `${env.AGENT_MODEL_PROVIDER}/${env.AGENT_MODEL_NAME}`,
tools: [
createIssueTool,
editIssueTool,
triggerWorkflowTool,
listPullRequestsTool,
checkPrStatusTool,
],
}));
// Allow HTTP prompts and event streaming for this agent.
export const route = (_c: unknown, next: () => Promise<void>) => next();

View File

@@ -0,0 +1,31 @@
import { registerProvider } from "@flue/runtime";
import { flue } from "@flue/runtime/routing";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { env } from "./tools/runtime";
// Register the GLM 5.2 provider once at module load.
registerProvider(env.AGENT_MODEL_PROVIDER, {
api: env.AGENT_MODEL_API,
apiKey: env.AGENT_MODEL_API_KEY,
baseUrl: env.AGENT_MODEL_BASE_URL,
models: {
[env.AGENT_MODEL_NAME]: {
contextWindow: env.AGENT_MODEL_CONTEXT_WINDOW,
maxTokens: env.AGENT_MODEL_MAX_TOKENS,
},
},
});
const app = new Hono();
// The chat UI is served from the web app origin, so browser calls here are
// cross-origin; without CORS headers every preflight 405s and sends fail.
app.use("*", cors());
app.get("/health", (c) => c.json({ model: env.AGENT_MODEL_NAME, ok: true }));
app.route("/", flue());
export default app;

View File

@@ -0,0 +1,4 @@
import { sqlite } from "@flue/runtime/node";
// File-based SQLite persistence so conversations survive restarts.
export default sqlite("./data/zopu.db");

View File

@@ -0,0 +1,46 @@
// Lean env config for the zopu server. Read from process.env; no zod dependency.
const required = (key: string): string => {
const value = process.env[key];
if (!value) {
throw new Error(`Missing required env var: ${key}`);
}
return value;
};
export interface ServerEnv {
readonly AGENT_MODEL_PROVIDER: string;
readonly AGENT_MODEL_NAME: string;
readonly AGENT_MODEL_API: string;
readonly AGENT_MODEL_API_KEY: string;
readonly AGENT_MODEL_BASE_URL: string;
readonly AGENT_MODEL_CONTEXT_WINDOW: number;
readonly AGENT_MODEL_MAX_TOKENS: number;
readonly GITEA_URL: string;
readonly GITEA_TOKEN: string;
readonly GIT_REPO: string;
readonly GIT_SSH_REMOTE: string;
readonly GIT_DEFAULT_BRANCH: string;
readonly WORKTREE_ROOT: string;
readonly PORT: string;
}
export const parseServerEnv = (): ServerEnv => ({
AGENT_MODEL_API: process.env.AGENT_MODEL_API ?? "openai-completions",
AGENT_MODEL_API_KEY: required("AGENT_MODEL_API_KEY"),
AGENT_MODEL_BASE_URL: required("AGENT_MODEL_BASE_URL"),
AGENT_MODEL_CONTEXT_WINDOW: Number(
process.env.AGENT_MODEL_CONTEXT_WINDOW ?? 262_000
),
AGENT_MODEL_MAX_TOKENS: Number(process.env.AGENT_MODEL_MAX_TOKENS ?? 131_072),
AGENT_MODEL_NAME: process.env.AGENT_MODEL_NAME ?? "glm-5.2",
AGENT_MODEL_PROVIDER: process.env.AGENT_MODEL_PROVIDER ?? "cheaptricks",
GITEA_TOKEN: process.env.GITEA_TOKEN ?? "",
GITEA_URL: process.env.GITEA_URL ?? "https://git.openputer.com",
GIT_DEFAULT_BRANCH: process.env.GIT_DEFAULT_BRANCH ?? "main",
GIT_REPO: process.env.GIT_REPO ?? "puter/zopu-code",
GIT_SSH_REMOTE:
process.env.GIT_SSH_REMOTE ??
"ssh://git@git.openputer.com:2222/puter/zopu-code.git",
PORT: process.env.PORT ?? "3590",
WORKTREE_ROOT: process.env.WORKTREE_ROOT ?? "/tmp/zopu-worktrees",
});

View File

@@ -0,0 +1,360 @@
/* eslint-disable max-classes-per-file -- GiteaError and Gitea form one adapter contract. */
import { Context, Effect, Layer, Schema } from "effect";
const toPrState = (
merged: boolean,
state: string
): "merged" | "closed" | "open" => {
if (merged) {
return "merged";
}
if (state === "closed") {
return "closed";
}
return "open";
};
// ---------------------------------------------------------------------------
// Schemas / errors
// ---------------------------------------------------------------------------
export class GiteaError extends Schema.TaggedErrorClass<GiteaError>()(
"GiteaError",
{
message: Schema.String,
status: Schema.Number.pipe(Schema.optional),
}
) {}
export interface GiteaIssue {
readonly number: number;
readonly title: string;
readonly body: string;
readonly state: "open" | "closed";
readonly htmlUrl: string;
}
export interface GiteaPullRequest {
readonly number: number;
readonly title: string;
readonly state: "open" | "closed" | "merged";
readonly htmlUrl: string;
readonly head: string;
readonly base: string;
readonly merged: boolean;
}
export interface GiteaRepository {
readonly cloneUrl: string;
readonly sshUrl: string;
readonly htmlUrl: string;
readonly defaultBranch: string;
readonly name: string;
}
export interface CreateIssueInput {
readonly title: string;
readonly body: string;
}
export interface EditIssueInput {
readonly title?: string;
readonly body?: string;
readonly state?: "open" | "closed";
}
export interface CreatePrInput {
readonly title: string;
readonly body: string;
readonly head: string;
readonly base: string;
}
// ---------------------------------------------------------------------------
// Service interface
// ---------------------------------------------------------------------------
export interface GiteaService {
readonly getIssue: (number: number) => Effect.Effect<GiteaIssue, GiteaError>;
readonly createIssue: (
input: CreateIssueInput
) => Effect.Effect<GiteaIssue, GiteaError>;
readonly editIssue: (
number: number,
input: EditIssueInput
) => Effect.Effect<GiteaIssue, GiteaError>;
readonly listPullRequests: () => Effect.Effect<
GiteaPullRequest[],
GiteaError
>;
readonly getPullRequest: (
number: number
) => Effect.Effect<GiteaPullRequest, GiteaError>;
readonly createPullRequest: (
input: CreatePrInput
) => Effect.Effect<GiteaPullRequest, GiteaError>;
readonly getRepository: () => Effect.Effect<GiteaRepository, GiteaError>;
}
export interface GiteaConfig {
readonly baseUrl: string;
readonly token: string;
readonly repoPath: string;
}
// ---------------------------------------------------------------------------
// HTTP helper
// ---------------------------------------------------------------------------
const toHttpPath = (repoPath: string): string =>
repoPath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
const authHeaders = (token: string): Record<string, string> => {
const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/json",
};
if (token) {
headers.Authorization = `token ${token}`;
}
return headers;
};
const giteaFetch = <T>(
config: GiteaConfig,
path: string,
init?: RequestInit
): Effect.Effect<T, GiteaError> =>
Effect.tryPromise({
catch: (cause) => {
if (
cause instanceof Error &&
"status" in cause &&
typeof cause.status === "number"
) {
return new GiteaError({
message: cause.message,
status: cause.status,
});
}
return new GiteaError({
message: cause instanceof Error ? cause.message : String(cause),
});
},
try: async () => {
const response = await fetch(
`${config.baseUrl.replace(/\/$/u, "")}${path}`,
{
...init,
headers: { ...authHeaders(config.token), ...init?.headers },
}
);
if (!response.ok) {
const detail = await response.text();
throw Object.assign(new Error(`Gitea ${response.status}: ${detail}`), {
status: response.status,
});
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
},
});
// ---------------------------------------------------------------------------
// Service + layer
// ---------------------------------------------------------------------------
export class Gitea extends Context.Service<Gitea, GiteaService>()(
"@zopu/server/Gitea"
) {
static readonly layer = (config: GiteaConfig) =>
Layer.succeed(
Gitea,
Gitea.of({
createIssue: (input) =>
giteaFetch<{
number: number;
title: string;
body: string;
state: string;
html_url: string;
}>(config, `/api/v1/repos/${toHttpPath(config.repoPath)}/issues`, {
body: JSON.stringify({ body: input.body, title: input.title }),
method: "POST",
}).pipe(
Effect.map(
(i): GiteaIssue => ({
body: i.body ?? "",
htmlUrl: i.html_url,
number: i.number,
state: "open",
title: i.title,
})
)
),
createPullRequest: (input) =>
giteaFetch<{
number: number;
title: string;
state: string;
html_url: string;
merged: boolean;
base: { ref: string };
head: { ref: string };
}>(config, `/api/v1/repos/${toHttpPath(config.repoPath)}/pulls`, {
body: JSON.stringify({
base: input.base,
body: input.body,
head: input.head,
title: input.title,
}),
method: "POST",
}).pipe(
Effect.map(
(pr): GiteaPullRequest => ({
base: pr.base.ref,
head: pr.head.ref,
htmlUrl: pr.html_url,
merged: pr.merged ?? false,
number: pr.number,
state: "open",
title: pr.title,
})
)
),
editIssue: (number, input) =>
giteaFetch<{
number: number;
title: string;
body: string;
state: string;
html_url: string;
}>(
config,
`/api/v1/repos/${toHttpPath(config.repoPath)}/issues/${number}`,
{
body: JSON.stringify({
...(input.body === undefined ? {} : { body: input.body }),
...(input.state === undefined ? {} : { state: input.state }),
...(input.title === undefined ? {} : { title: input.title }),
}),
method: "PATCH",
}
).pipe(
Effect.map(
(i): GiteaIssue => ({
body: i.body ?? "",
htmlUrl: i.html_url,
number: i.number,
state: i.state === "closed" ? "closed" : "open",
title: i.title,
})
)
),
getIssue: (number) =>
giteaFetch<{
number: number;
title: string;
body: string;
state: string;
html_url: string;
}>(
config,
`/api/v1/repos/${toHttpPath(config.repoPath)}/issues/${number}`
).pipe(
Effect.map(
(i): GiteaIssue => ({
body: i.body ?? "",
htmlUrl: i.html_url,
number: i.number,
state: i.state === "closed" ? "closed" : "open",
title: i.title,
})
)
),
getPullRequest: (number) =>
giteaFetch<{
number: number;
title: string;
state: string;
html_url: string;
merged: boolean;
base: { ref: string };
head: { ref: string };
}>(
config,
`/api/v1/repos/${toHttpPath(config.repoPath)}/pulls/${number}`
).pipe(
Effect.map(
(pr): GiteaPullRequest => ({
base: pr.base.ref,
head: pr.head.ref,
htmlUrl: pr.html_url,
merged: pr.merged ?? false,
number: pr.number,
state: toPrState(pr.merged, pr.state),
title: pr.title,
})
)
),
getRepository: () =>
giteaFetch<{
clone_url: string;
ssh_url: string;
html_url: string;
default_branch: string;
name: string;
}>(config, `/api/v1/repos/${toHttpPath(config.repoPath)}`).pipe(
Effect.map(
(r): GiteaRepository => ({
cloneUrl: r.clone_url,
defaultBranch: r.default_branch,
htmlUrl: r.html_url,
name: r.name,
sshUrl: r.ssh_url,
})
)
),
listPullRequests: () =>
giteaFetch<
{
number: number;
title: string;
state: string;
html_url: string;
merged: boolean;
base: { ref: string };
head: { ref: string };
}[]
>(
config,
`/api/v1/repos/${toHttpPath(config.repoPath)}/pulls?state=all&limit=20&sort=recentupdate`
).pipe(
Effect.map((prs): GiteaPullRequest[] =>
prs.map(
(pr): GiteaPullRequest => ({
base: pr.base.ref,
head: pr.head.ref,
htmlUrl: pr.html_url,
merged: pr.merged ?? false,
number: pr.number,
state: toPrState(pr.merged, pr.state),
title: pr.title,
})
)
)
),
})
);
}

View File

@@ -0,0 +1,114 @@
import codex from "@agentos-software/codex-cli";
import git from "@agentos-software/git";
import opencode from "@agentos-software/opencode";
import pi from "@agentos-software/pi";
import { createSandboxSessionEnv } from "@flue/runtime";
import type { FileStat, SandboxApi, SandboxFactory } from "@flue/runtime";
import { AgentOs, createHostDirBackend } from "@rivet-dev/agentos-core";
import type { AgentOsOptions, SoftwareInput } from "@rivet-dev/agentos-core";
const WORK_SOFTWARE: SoftwareInput[] = [git, opencode, codex, pi];
export interface WorkVmOptions {
readonly extraSoftware?: SoftwareInput[];
readonly worktreePath: string;
}
const statResult = (s: {
isDirectory: boolean;
isSymbolicLink: boolean;
mtimeMs: number;
size: number;
}): FileStat => ({
isDirectory: s.isDirectory,
isFile: !s.isDirectory && !s.isSymbolicLink,
isSymbolicLink: s.isSymbolicLink,
mtime: new Date(s.mtimeMs),
size: s.size,
});
export const createWorkVm = async (
options: WorkVmOptions
): Promise<{
readonly destroy: () => Promise<void>;
readonly sandbox: SandboxFactory;
}> => {
const vm = await AgentOs.create({
defaultSoftware: true,
mounts: [
{
path: "/workspace/repository",
plugin: createHostDirBackend({
hostPath: options.worktreePath,
readOnly: false,
}),
readOnly: false,
},
],
software: [...WORK_SOFTWARE, ...(options.extraSoftware ?? [])],
} satisfies AgentOsOptions);
const api: SandboxApi = {
async exec(
command: string,
opts?: {
cwd?: string;
env?: Record<string, string>;
signal?: AbortSignal;
timeoutMs?: number;
}
) {
const result = await vm.exec(command, {
...(opts?.cwd === undefined ? {} : { cwd: opts.cwd }),
...(opts?.env === undefined ? {} : { env: opts.env }),
...(opts?.timeoutMs === undefined ? {} : { timeout: opts.timeoutMs }),
});
return {
exitCode: result.exitCode,
stderr: result.stderr,
stdout: result.stdout,
};
},
async exists(path: string): Promise<boolean> {
try {
return await vm.exists(path);
} catch {
return false;
}
},
async mkdir(path: string, opts?: { recursive?: boolean }) {
await vm.mkdir(path, { recursive: opts?.recursive ?? false });
},
readFile(path: string): Promise<string> {
return vm.readFile(path).then((b) => new TextDecoder().decode(b));
},
readFileBuffer(path: string) {
return vm.readFile(path);
},
readdir(path: string): Promise<string[]> {
return vm.readdir(path);
},
async rm(path: string, opts?: { force?: boolean; recursive?: boolean }) {
if (opts?.force && !(await vm.exists(path))) {
return;
}
await vm.remove(path, { recursive: opts?.recursive });
},
async stat(path: string): Promise<FileStat> {
return statResult(await vm.stat(path));
},
writeFile(path: string, content: string | Uint8Array): Promise<void> {
return vm.writeFile(path, content);
},
};
const sandbox: SandboxFactory = {
createSessionEnv: () =>
Promise.resolve(createSandboxSessionEnv(api, "/workspace")),
};
return {
destroy: () => Promise.resolve(),
sandbox,
};
};

View File

@@ -0,0 +1,68 @@
import { defineTool } from "@flue/runtime";
import { Effect } from "effect";
import * as v from "valibot";
import { Gitea } from "../git/gitea-service";
import { runWithGitea } from "./runtime";
export const createIssueTool = defineTool({
description:
"Create a new issue in the repository. Use when the user describes actionable work that should be tracked.",
input: v.object({
body: v.pipe(v.string(), v.maxLength(20_000)),
title: v.pipe(v.string(), v.minLength(1), v.maxLength(200)),
}),
name: "create_issue",
output: v.object({
htmlUrl: v.string(),
number: v.number(),
state: v.string(),
title: v.string(),
}),
async run({ input }) {
const issue = await runWithGitea(
Effect.gen(function* issue() {
const gitea = yield* Gitea;
return yield* gitea.createIssue(input);
})
);
return {
htmlUrl: issue.htmlUrl,
number: issue.number,
state: issue.state,
title: issue.title,
};
},
});
export const editIssueTool = defineTool({
description:
"Edit an existing issue's title, body, or state. Set state to 'closed' to resolve it.",
input: v.object({
body: v.optional(v.pipe(v.string(), v.maxLength(20_000))),
number: v.pipe(v.number(), v.integer()),
state: v.optional(v.picklist(["open", "closed"])),
title: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(200))),
}),
name: "edit_issue",
output: v.object({
htmlUrl: v.string(),
number: v.number(),
state: v.string(),
title: v.string(),
}),
async run({ input }) {
const issue = await runWithGitea(
Effect.gen(function* issue() {
const gitea = yield* Gitea;
return yield* gitea.editIssue(input.number, input);
})
);
return {
htmlUrl: issue.htmlUrl,
number: issue.number,
state: issue.state,
title: issue.title,
};
},
});

View File

@@ -0,0 +1,68 @@
import { defineTool } from "@flue/runtime";
import { Effect } from "effect";
import * as v from "valibot";
import { Gitea } from "../git/gitea-service";
import type { GiteaPullRequest } from "../git/gitea-service";
import { runWithGitea } from "./runtime";
export const listPullRequestsTool = defineTool({
description:
"List recent pull requests in the repository with their state (open, closed, merged).",
input: v.object({}),
name: "list_pull_requests",
async run() {
const prs: GiteaPullRequest[] = await runWithGitea(
Effect.gen(function* prs() {
const gitea = yield* Gitea;
return yield* gitea.listPullRequests();
})
);
return {
pullRequests: prs.map((pr) => ({
base: pr.base,
head: pr.head,
merged: pr.merged,
number: pr.number,
state: pr.state,
title: pr.title,
url: pr.htmlUrl,
})),
};
},
});
export const checkPrStatusTool = defineTool({
description:
"Check whether a specific pull request is open, merged, or closed. Use to verify if an issue's work was resolved.",
input: v.object({
number: v.pipe(v.number(), v.integer()),
}),
name: "check_pr_status",
output: v.object({
base: v.string(),
head: v.string(),
merged: v.boolean(),
number: v.number(),
state: v.string(),
title: v.string(),
url: v.string(),
}),
async run({ input }) {
const pr: GiteaPullRequest = await runWithGitea(
Effect.gen(function* pr() {
const gitea = yield* Gitea;
return yield* gitea.getPullRequest(input.number);
})
);
return {
base: pr.base,
head: pr.head,
merged: pr.merged,
number: pr.number,
state: pr.state,
title: pr.title,
url: pr.htmlUrl,
};
},
});

View File

@@ -0,0 +1,22 @@
import { Effect } from "effect";
import { parseServerEnv } from "../env";
import { Gitea } from "../git/gitea-service";
import type { GiteaConfig } from "../git/gitea-service";
const env = parseServerEnv();
const config: GiteaConfig = {
baseUrl: env.GITEA_URL,
repoPath: env.GIT_REPO,
token: env.GITEA_TOKEN,
};
const giteaLayer = Gitea.layer(config);
/** Run any Effect that depends on the Gitea service. */
export const runWithGitea = <A, E>(
effect: Effect.Effect<A, E, Gitea>
): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(giteaLayer)));
export { env };

View File

@@ -0,0 +1,22 @@
import { defineTool, invoke } from "@flue/runtime";
import * as v from "valibot";
import resolveIssueWorkflow from "../workflows/resolve-issue";
export const triggerWorkflowTool = defineTool({
description:
"Trigger the resolve-issue workflow for a specific issue number. The workflow boots an isolated AgentOS VM, runs opencode to resolve the issue, then commits and opens a pull request. Returns the workflow run ID.",
input: v.object({
issueNumber: v.pipe(v.number(), v.integer()),
}),
name: "trigger_workflow",
output: v.object({
runId: v.string(),
}),
async run({ input }) {
const receipt = await invoke(resolveIssueWorkflow, {
input: { issueNumber: input.issueNumber },
});
return { runId: receipt.runId };
},
});

View File

@@ -0,0 +1,159 @@
import { execSync } from "node:child_process";
import { mkdirSync, rmSync } from "node:fs";
import path from "node:path";
import { defineAgent, defineWorkflow } from "@flue/runtime";
import type { WorkflowRouteHandler } from "@flue/runtime";
import { local } from "@flue/runtime/node";
import { Effect } from "effect";
import * as v from "valibot";
import { Gitea } from "../git/gitea-service";
import type { GiteaIssue } from "../git/gitea-service";
import { env, runWithGitea } from "../tools/runtime";
const shellQuote = (value: string): string =>
`'${value.replaceAll("'", `'"'"'`)}'`;
const runHost = (command: string, options?: { cwd?: string }): string =>
execSync(command, {
encoding: "utf-8",
timeout: 120_000,
...(options?.cwd ? { cwd: options.cwd } : {}),
}).trim();
const createWorktree = (
issueNumber: number
): { branch: string; path: string } => {
const branch = `zopu/issue-${issueNumber}-${Date.now()}`;
const worktreePath = path.join(env.WORKTREE_ROOT, `issue-${issueNumber}`);
mkdirSync(env.WORKTREE_ROOT, { recursive: true });
rmSync(worktreePath, { force: true, recursive: true });
runHost(
`git worktree add -b ${shellQuote(branch)} ${shellQuote(worktreePath)} ${shellQuote(env.GIT_DEFAULT_BRANCH)}`
);
return { branch, path: worktreePath };
};
const publishChanges = async (
worktreePath: string,
branch: string,
issueNumber: number,
issueTitle: string
): Promise<{
base: string;
head: string;
number: number;
state: string;
status: string;
url: string;
}> => {
const status = runHost("git status --porcelain=v1", { cwd: worktreePath });
if (!status) {
return {
base: "",
head: branch,
number: 0,
state: "",
status: "no_changes",
url: "",
};
}
const commitMessage = `Resolve #${issueNumber}: ${issueTitle}`;
runHost(`git add --all && git commit -m ${shellQuote(commitMessage)}`, {
cwd: worktreePath,
});
runHost(
`git push ${shellQuote(env.GIT_SSH_REMOTE)} HEAD:${shellQuote(branch)}`,
{ cwd: worktreePath }
);
const pr = await runWithGitea(
Effect.gen(function* pr() {
const gitea = yield* Gitea;
return yield* gitea.createPullRequest({
base: env.GIT_DEFAULT_BRANCH,
body: `Resolves #${issueNumber}.\n\nGenerated by the Zopu work actor using GLM 5.2.`,
head: branch,
title: `Resolve #${issueNumber}: ${issueTitle}`,
});
})
);
return {
base: pr.base,
head: pr.head,
number: pr.number,
state: pr.state,
status: "pull_request_open",
url: pr.htmlUrl,
};
};
const cleanupWorktree = (worktreePath: string): void => {
try {
runHost(`git worktree remove ${shellQuote(worktreePath)} --force`);
} catch {
// Best-effort cleanup.
}
};
const workActor = defineAgent(() => ({
cwd: "/workspace/repository",
instructions: `You are the Zopu work actor operating inside an isolated AgentOS VM with a git worktree at /workspace/repository.
Your job:
1. Read the issue you received.
2. Inspect the codebase to find the relevant files.
3. Implement the fix or feature directly.
4. Verify your changes if tests or a typechecker are available.
Do NOT run git commit, push, or create pull requests. The host-side workflow handles publishing after you finish. Focus on correct code changes only.`,
model: `${env.AGENT_MODEL_PROVIDER}/${env.AGENT_MODEL_NAME}`,
sandbox: local({ cwd: "/workspace/repository" }),
}));
export default defineWorkflow({
agent: workActor,
input: v.object({
issueNumber: v.pipe(v.number(), v.integer()),
}),
async run({ harness, input }) {
const issue: GiteaIssue = await runWithGitea(
Effect.gen(function* issue() {
const gitea = yield* Gitea;
return yield* gitea.getIssue(input.issueNumber);
})
);
const { branch, path: worktreePath } = createWorktree(input.issueNumber);
const session = await harness.session();
await session.prompt(
`Resolve issue #${issue.number}: ${issue.title}\n\n${issue.body}\n\nInspect /workspace/repository and implement the fix.`
);
const result = await publishChanges(
worktreePath,
branch,
input.issueNumber,
issue.title
);
cleanupWorktree(worktreePath);
return {
branch,
issueNumber: input.issueNumber,
pullRequest: {
base: result.base,
head: result.head,
number: result.number,
state: result.state,
url: result.url,
},
status: result.status,
};
},
});
export const route: WorkflowRouteHandler = (_c, next) => next();

View File

@@ -0,0 +1,6 @@
{
"extends": "@code/config/tsconfig.base.json",
"compilerOptions": { "types": ["bun"] },
"include": ["src/**/*.ts"],
"exclude": ["dist"]
}