feat(zopu): chat server, standalone chat route, and CORS
This commit is contained in:
@@ -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
|
||||
|
||||
186
apps/web/src/components/standalone-chat/standalone-chat-page.tsx
Normal file
186
apps/web/src/components/standalone-chat/standalone-chat-page.tsx
Normal 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>
|
||||
);
|
||||
@@ -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"),
|
||||
]),
|
||||
|
||||
16
apps/web/src/routes/standalone/chat/page.tsx
Normal file
16
apps/web/src/routes/standalone/chat/page.tsx
Normal 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 />;
|
||||
}
|
||||
33
bun.lock
33
bun.lock
@@ -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
1281
docs/dogfood-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||
|
||||
2
packages/backend/convex/_generated/api.d.ts
vendored
2
packages/backend/convex/_generated/api.d.ts
vendored
@@ -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;
|
||||
}>;
|
||||
|
||||
1
packages/env/src/web.ts
vendored
1
packages/env/src/web.ts
vendored
@@ -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,
|
||||
|
||||
13
packages/primitives/package.json.new
Normal file
13
packages/primitives/package.json.new
Normal 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"
|
||||
},
|
||||
BIN
packages/server/data/zopu.db
Normal file
BIN
packages/server/data/zopu.db
Normal file
Binary file not shown.
11
packages/server/flue.config.ts
Normal file
11
packages/server/flue.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "@flue/cli/config";
|
||||
|
||||
export default defineConfig({ target: "node" });
|
||||
|
||||
export const vite = {
|
||||
server: {
|
||||
watch: {
|
||||
ignored: ["**/data/**", "**/dist/**"],
|
||||
},
|
||||
},
|
||||
};
|
||||
29
packages/server/package.json
Normal file
29
packages/server/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
42
packages/server/src/agents/zopu.ts
Normal file
42
packages/server/src/agents/zopu.ts
Normal 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();
|
||||
31
packages/server/src/app.ts
Normal file
31
packages/server/src/app.ts
Normal 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;
|
||||
4
packages/server/src/db.ts
Normal file
4
packages/server/src/db.ts
Normal 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");
|
||||
46
packages/server/src/env.ts
Normal file
46
packages/server/src/env.ts
Normal 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",
|
||||
});
|
||||
360
packages/server/src/git/gitea-service.ts
Normal file
360
packages/server/src/git/gitea-service.ts
Normal 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,
|
||||
})
|
||||
)
|
||||
)
|
||||
),
|
||||
})
|
||||
);
|
||||
}
|
||||
114
packages/server/src/sandboxes/agentos.ts
Normal file
114
packages/server/src/sandboxes/agentos.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
68
packages/server/src/tools/issues.ts
Normal file
68
packages/server/src/tools/issues.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
68
packages/server/src/tools/pull-requests.ts
Normal file
68
packages/server/src/tools/pull-requests.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
});
|
||||
22
packages/server/src/tools/runtime.ts
Normal file
22
packages/server/src/tools/runtime.ts
Normal 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 };
|
||||
22
packages/server/src/tools/workflows.ts
Normal file
22
packages/server/src/tools/workflows.ts
Normal 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 };
|
||||
},
|
||||
});
|
||||
159
packages/server/src/workflows/resolve-issue.ts
Normal file
159
packages/server/src/workflows/resolve-issue.ts
Normal 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();
|
||||
6
packages/server/tsconfig.json
Normal file
6
packages/server/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@code/config/tsconfig.base.json",
|
||||
"compilerOptions": { "types": ["bun"] },
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user