merge: resolve conflicts with origin/master (routes, Caddy ports, scripts)
This commit is contained in:
@@ -36,3 +36,6 @@ AGENT_MODEL_MAX_TOKENS=131072
|
|||||||
# Self-hosted git (Gitea) — canonical repository host and API token
|
# Self-hosted git (Gitea) — canonical repository host and API token
|
||||||
GITEA_URL=https://git.openputer.com
|
GITEA_URL=https://git.openputer.com
|
||||||
GITEA_TOKEN=replace-with-a-gitea-personal-access-token
|
GITEA_TOKEN=replace-with-a-gitea-personal-access-token
|
||||||
|
|
||||||
|
# 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";
|
import type { RouteConfig } from "@react-router/dev/routes";
|
||||||
|
|
||||||
export default [
|
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", [
|
layout("./routes/auth/layout.tsx", [
|
||||||
route("login", "./routes/auth/login/page.tsx"),
|
route("login", "./routes/auth/login/page.tsx"),
|
||||||
route("signup", "./routes/auth/signup/page.tsx"),
|
route("signup", "./routes/auth/signup/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
@@ -174,6 +174,27 @@
|
|||||||
"vitest": "catalog:",
|
"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": {
|
"packages/ui": {
|
||||||
"name": "@code/ui",
|
"name": "@code/ui",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@@ -580,6 +601,8 @@
|
|||||||
|
|
||||||
"@code/primitives": ["@code/primitives@workspace:packages/primitives"],
|
"@code/primitives": ["@code/primitives@workspace:packages/primitives"],
|
||||||
|
|
||||||
|
"@code/server": ["@code/server@workspace:packages/server"],
|
||||||
|
|
||||||
"@code/ui": ["@code/ui@workspace:packages/ui"],
|
"@code/ui": ["@code/ui@workspace:packages/ui"],
|
||||||
|
|
||||||
"@code/work-os": ["@code/work-os@workspace:packages/work-os"],
|
"@code/work-os": ["@code/work-os@workspace:packages/work-os"],
|
||||||
@@ -3056,7 +3079,7 @@
|
|||||||
|
|
||||||
"parse-png": ["parse-png@2.1.0", "", { "dependencies": { "pngjs": "^3.3.0" } }, "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ=="],
|
"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=="],
|
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="],
|
||||||
|
|
||||||
@@ -3828,6 +3851,8 @@
|
|||||||
|
|
||||||
"@code/backend/@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="],
|
"@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=="],
|
"@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=="],
|
"@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||||
@@ -3988,8 +4013,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/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=="],
|
"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/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
@@ -4072,6 +4095,10 @@
|
|||||||
|
|
||||||
"googleapis-common/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
"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=="],
|
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||||
|
|
||||||
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||||
|
|||||||
@@ -1,44 +1,15 @@
|
|||||||
# Caddyfile — Minimal reverse proxy for the Zopu agent service.
|
# Caddyfile — Public reverse proxy for the Zopu web + API.
|
||||||
#
|
#
|
||||||
# This is OPTIONAL. The agent service does not need to be publicly exposed
|
# The web frontend (port 5173) is served at the root, and the Flue agent/API
|
||||||
# for the execution plane to function. Use Caddy only when an HTTP endpoint
|
# (port 3585) is mounted under /api so the browser talks same-origin.
|
||||||
# is genuinely needed (e.g., a webhook ingress from Convex site functions).
|
|
||||||
#
|
|
||||||
# In the default single-node deployment, the agent listens on localhost:3583
|
|
||||||
# and is reachable over Tailscale. Install Caddy only if you need TLS
|
|
||||||
# termination or a public ingress point.
|
|
||||||
#
|
|
||||||
# --- Private/Tailscale deployment (recommended) ---
|
|
||||||
#
|
|
||||||
# Listen only on the Tailscale interface. Replace the hostname with your
|
|
||||||
# Tailscale machine name or IP.
|
|
||||||
#
|
|
||||||
# http://zopu-runtime.tail-xxxx.ts.net {
|
|
||||||
# reverse_proxy localhost:3583
|
|
||||||
# }
|
|
||||||
#
|
|
||||||
# --- Public deployment through Cloudflare Tunnel ---
|
|
||||||
#
|
|
||||||
# Cloudflare terminates TLS and forwards these hosts to Caddy over loopback.
|
|
||||||
#
|
|
||||||
# http://zopu.sai-onchain.me {
|
|
||||||
# bind 127.0.0.1
|
|
||||||
# reverse_proxy 127.0.0.1:13100
|
|
||||||
# }
|
|
||||||
#
|
|
||||||
# http://zopu-api.sai-onchain.me {
|
|
||||||
# bind 127.0.0.1
|
|
||||||
# reverse_proxy 127.0.0.1:3210
|
|
||||||
# }
|
|
||||||
#
|
|
||||||
# http://zopu-site.sai-onchain.me {
|
|
||||||
# bind 127.0.0.1
|
|
||||||
# reverse_proxy 127.0.0.1:3211
|
|
||||||
# }
|
|
||||||
#
|
|
||||||
# http://zopu-agent.sai-onchain.me {
|
|
||||||
# bind 127.0.0.1
|
|
||||||
# reverse_proxy 127.0.0.1:3583
|
|
||||||
# }
|
|
||||||
|
|
||||||
# Default: do not ship an active Caddyfile. The file is documentation.
|
zopu.cheaptricks.puter.wtf {
|
||||||
|
bind 135.181.82.179 2a01:4f9:c013:4a64::1
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
handle_path /api/* {
|
||||||
|
reverse_proxy 127.0.0.1:3585
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy 127.0.0.1:5173
|
||||||
|
}
|
||||||
|
|||||||
1281
docs/dogfood-plan.md
Normal file
1281
docs/dogfood-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -60,7 +60,11 @@
|
|||||||
"docs:update": "bun run scripts/update-docs.ts",
|
"docs:update": "bun run scripts/update-docs.ts",
|
||||||
"subtree": "bun run scripts/subtree.ts",
|
"subtree": "bun run scripts/subtree.ts",
|
||||||
"fix": "ultracite fix",
|
"fix": "ultracite fix",
|
||||||
"slice1": "vp run -r dev"
|
"slice1": "vp run -r dev",
|
||||||
|
"orb:proof": "bun run scripts/orb-proof.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": {},
|
"dependencies": {},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
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_SITE_URL: convexUrlSchema("example.convex.site"),
|
||||||
VITE_CONVEX_URL: convexUrlSchema("example.convex.cloud"),
|
VITE_CONVEX_URL: convexUrlSchema("example.convex.cloud"),
|
||||||
VITE_FLUE_URL: z.url(),
|
VITE_FLUE_URL: z.url(),
|
||||||
|
VITE_ZOPU_SERVER_URL: z.url(),
|
||||||
},
|
},
|
||||||
clientPrefix: "VITE_",
|
clientPrefix: "VITE_",
|
||||||
emptyStringAsUndefined: true,
|
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