Slice 1 polish: archive dormant code, merge work-os into primitives, add futures
- Archive dormant apps (daemon, desktop, native, tui) and superseded packages/server into repos/ for reference - Archive 21 dead web components (chat page shell, work-os cluster, strays) into repos/web/; keep reused message-rendering primitives in components/chat - Merge @code/work-os into @code/primitives; work.ts absorbed via ./work subpath and barrel export; update all four importers - Add docs/futures.md tracking audio/video (blocked at Flue + provider), web layout cleanup, and message rendering quality - Repoint dev:zopu script to @code/agents (the live Flue server) - Note repos/ reference-code convention in AGENTS.md
This commit is contained in:
BIN
repos/server/data/zopu.db
Normal file
BIN
repos/server/data/zopu.db
Normal file
Binary file not shown.
11
repos/server/flue.config.ts
Normal file
11
repos/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
repos/server/package.json
Normal file
29
repos/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
repos/server/src/agents/zopu.ts
Normal file
42
repos/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
repos/server/src/app.ts
Normal file
31
repos/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
repos/server/src/db.ts
Normal file
4
repos/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
repos/server/src/env.ts
Normal file
46
repos/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
repos/server/src/git/gitea-service.ts
Normal file
360
repos/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
repos/server/src/sandboxes/agentos.ts
Normal file
114
repos/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
repos/server/src/tools/issues.ts
Normal file
68
repos/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
repos/server/src/tools/pull-requests.ts
Normal file
68
repos/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
repos/server/src/tools/runtime.ts
Normal file
22
repos/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
repos/server/src/tools/workflows.ts
Normal file
22
repos/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
repos/server/src/workflows/resolve-issue.ts
Normal file
159
repos/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
repos/server/tsconfig.json
Normal file
6
repos/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