diff --git a/.env.example b/.env.example index 76d0139..94f7e0b 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,7 @@ AGENT_MODEL_BASE_URL=https://ai.example.com/v1 AGENT_MODEL_API_KEY=replace-with-provider-api-key AGENT_MODEL_CONTEXT_WINDOW=262000 AGENT_MODEL_MAX_TOKENS=131072 + +# Self-hosted git (Gitea) — canonical repository host and API token +GITEA_URL=https://git.openputer.com +GITEA_TOKEN=replace-with-a-gitea-personal-access-token diff --git a/bun.lock b/bun.lock index 1240940..cff17a7 100644 --- a/bun.lock +++ b/bun.lock @@ -246,6 +246,7 @@ "name": "@code/primitives", "version": "0.0.0", "dependencies": { + "@agentos-software/codex-cli": "0.3.4", "@agentos-software/git": "0.3.3", "@rivet-dev/agentos": "catalog:", "effect": "catalog:", diff --git a/packages/agents/package.json b/packages/agents/package.json index 11ba540..8b3b7fb 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -9,7 +9,8 @@ "dev": "bun --env-file=../../.env flue dev", "dev:tailscale": "bun --env-file=../../.env flue dev", "run": "bun --env-file=../../.env flue run", - "run:zopu": "bun --env-file=../../.env flue run zopu" + "run:zopu": "bun --env-file=../../.env flue run zopu", + "run:zopu-dev": "bun --env-file=../../.env flue run zopu-dev" }, "dependencies": { "@agentos-software/opencode": "0.2.7", diff --git a/packages/agents/src/agents/zopu-dev.ts b/packages/agents/src/agents/zopu-dev.ts new file mode 100644 index 0000000..1a75731 --- /dev/null +++ b/packages/agents/src/agents/zopu-dev.ts @@ -0,0 +1,51 @@ +import { parseAgentEnv } from "@code/env/agent"; +import { defineAgent } from "@flue/runtime"; +import { local } from "@flue/runtime/node"; + +import { createGitRemoteTools, makeGitRemoteConfig } from "../tools/git-remote"; +import { createWorkflowTools } from "../tools/workflow"; + +const INSTRUCTIONS = `You are Zopu Dev, the development agent for the canonical zopu-code repository (self-hosted Gitea: puter/zopu-code). + +## Your job + +You talk with the user and turn agreed-upon work into tracked issues, then kick off autonomous implementation. You operate on exactly one repository. + +## Loop + +1. Understand the request. Ask one focused clarifying question only when genuinely ambiguous. Do not create work from casual chat. + +2. Check for duplicates. Call list_issues to see what already exists. + +3. Create the issue. Call create_issue with a clear title and a body that captures the acceptance criteria. Report the issue number and URL back to the user. + +4. Start work only on explicit confirmation. When the user says to start (e.g. "go", "start", "work on it"), call start_workflow with the issue number. This spins up a Codex agent in an isolated worktree that implements, verifies, commits, and opens a pull request. + +5. Report outcomes plainly: "Created issue #N: — <url>" and "Started run <id> (status <status>)". + +## Rules + +- Never invent issue numbers. Always create or list first. +- Never start work without explicit user confirmation. +- Titles are concise; bodies carry the acceptance criteria. +- If a tool returns an error with a reason, surface it and suggest next steps instead of retrying blindly.`; + +export default defineAgent(({ env }) => { + const agentEnv = parseAgentEnv(env); + const gitConfig = makeGitRemoteConfig(agentEnv); + + return { + description: + "Development agent that creates issues on the canonical zopu-code repo and starts autonomous Codex work runs.", + instructions: INSTRUCTIONS, + model: `${agentEnv.AGENT_MODEL_PROVIDER}/${agentEnv.AGENT_MODEL_NAME}`, + sandbox: local({ cwd: process.cwd() }), + tools: [ + ...createGitRemoteTools(gitConfig), + ...createWorkflowTools({ + convexUrl: agentEnv.CONVEX_URL, + token: agentEnv.FLUE_DB_TOKEN, + }), + ], + }; +}); diff --git a/packages/agents/src/tools/git-remote.ts b/packages/agents/src/tools/git-remote.ts new file mode 100644 index 0000000..f636618 --- /dev/null +++ b/packages/agents/src/tools/git-remote.ts @@ -0,0 +1,119 @@ +import { + createBranch, + createIssue, + createPullRequest, + fetchTransport, + listIssues, +} from "@code/primitives/git-remote-runtime"; +import type { + GitRemoteConfig, + GitRemoteError, +} from "@code/primitives/git-remote-runtime"; +import { defineTool } from "@flue/runtime"; +import { Effect } from "effect"; +import * as v from "valibot"; + +/** + * Canonical repo for the bootstrap: one self-hosted Gitea repo hosts all work. + * Hard-coded owner/repo keeps the agent off credential-shaped free input. + */ +export const CANONICAL_REPO = { owner: "puter", repo: "zopu-code" } as const; + +export const makeGitRemoteConfig = (runtimeEnv: { + readonly GITEA_TOKEN?: string; + readonly GITEA_URL: string; +}): GitRemoteConfig => { + if (!runtimeEnv.GITEA_TOKEN) { + throw new Error( + "GITEA_TOKEN is required to configure the git remote tools" + ); + } + return { + baseUrl: runtimeEnv.GITEA_URL, + owner: CANONICAL_REPO.owner, + repo: CANONICAL_REPO.repo, + token: runtimeEnv.GITEA_TOKEN, + }; +}; + +const run = <A, E>(eff: Effect.Effect<A, E>): Promise<A> => + Effect.runPromise(eff); + +const describeError = (error: GitRemoteError) => ({ + error: error.message, + reason: error.reason, +}); + +// Tool returns are serialized to the model; round-trip through JSON so the +// Effect-branded/readonly value objects become plain mutable JsonValue. +// oxlint-disable-next-line unicorn/prefer-structured-clone -- structuredClone keeps readonly modifiers; JSON erases them for JsonValue +const json = (value: unknown) => JSON.parse(JSON.stringify(value)); + +/** + * Git-remote tools bound to the canonical repo. The agent creates issues, + * branches, and pull requests on puter/zopu-code via the Gitea API using the + * server-side token — it never accepts credentials or repo paths as input. + */ +export const createGitRemoteTools = (config: GitRemoteConfig) => [ + defineTool({ + description: + "Create an issue on the canonical zopu-code repository. Use this to turn an agreed-upon piece of work into a tracked issue. Returns the issue number and URL. Always call list_issues first to avoid duplicates.", + input: v.object({ + body: v.string(), + title: v.string(), + }), + name: "create_issue", + async run({ input }) { + const result = await run( + createIssue(fetchTransport, config, input) + ).catch(describeError); + return json(result); + }, + }), + + defineTool({ + description: + "List open issues on the canonical zopu-code repository. Call this before creating an issue to check for duplicates and understand existing work.", + name: "list_issues", + async run() { + const result = await run(listIssues(fetchTransport, config)).catch( + describeError + ); + return json(result); + }, + }), + + defineTool({ + description: + "Create a remote branch on the canonical zopu-code repository. Usually you do not need this directly — start_workflow handles branching for a work run. Use only when explicitly setting up a branch by hand.", + input: v.object({ + from: v.optional(v.string()), + name: v.string(), + }), + name: "create_branch", + async run({ input }) { + const result = await run( + createBranch(fetchTransport, config, input) + ).catch(describeError); + return json(result); + }, + }), + + defineTool({ + description: + "Open a pull request on the canonical zopu-code repository. Provide the work branch, the base branch (usually main), a title, and a body. Returns the PR number and URL.", + input: v.object({ + baseBranch: v.string(), + body: v.string(), + branch: v.string(), + title: v.string(), + }), + name: "create_pull_request", + async run({ input }) { + const result = await run( + createPullRequest(fetchTransport, config, input) + ).catch(describeError); + return json(result); + }, + }), +]; diff --git a/packages/agents/src/tools/workflow.ts b/packages/agents/src/tools/workflow.ts new file mode 100644 index 0000000..bd6fbbf --- /dev/null +++ b/packages/agents/src/tools/workflow.ts @@ -0,0 +1,37 @@ +import { defineTool } from "@flue/runtime"; +import { ConvexHttpClient } from "convex/browser"; +import { makeFunctionReference } from "convex/server"; +import * as v from "valibot"; + +// The control-plane mutation that enqueues a work run for an issue. Implemented +// in the backend Convex workflow module; referenced by name so the agent never +// imports generated bindings. +const startIssueWorkRef = makeFunctionReference< + "mutation", + { readonly issueNumber: number; readonly token: string }, + { readonly runId: string; readonly status: string } +>("workflows:startIssueWork"); + +export const createWorkflowTools = (clientOptions: { + readonly convexUrl: string; + readonly token: string; +}) => { + const client = new ConvexHttpClient(clientOptions.convexUrl); + + return [ + defineTool({ + description: + "Start the autonomous work workflow for an existing zopu-code issue. This spawns a Codex agent in an isolated worktree of the canonical repo, gives it the issue, lets it implement and verify the change, then commits and opens a pull request. Use it after create_issue (or for an existing issue number) once the user confirms they want work to begin. Returns the run id and initial status.", + input: v.object({ + issueNumber: v.number(), + }), + name: "start_workflow", + async run({ input }) { + return await client.mutation(startIssueWorkRef, { + issueNumber: input.issueNumber, + token: clientOptions.token, + }); + }, + }), + ]; +}; diff --git a/packages/backend/convex/convex.config.ts b/packages/backend/convex/convex.config.ts index c9af7fc..7f6daa1 100644 --- a/packages/backend/convex/convex.config.ts +++ b/packages/backend/convex/convex.config.ts @@ -7,6 +7,8 @@ const app = defineApp({ NATIVE_APP_URL: v.optional(v.string()), SITE_URL: v.string(), FLUE_DB_TOKEN: v.string(), + GITEA_URL: v.optional(v.string()), + GITEA_TOKEN: v.optional(v.string()), }, }); app.use(betterAuth); diff --git a/packages/backend/convex/workflows.ts b/packages/backend/convex/workflows.ts new file mode 100644 index 0000000..a2f8155 --- /dev/null +++ b/packages/backend/convex/workflows.ts @@ -0,0 +1,64 @@ +import { env } from "@code/env/convex"; +import { + getIssue, + fetchTransport, + type GitRemoteConfig, +} from "@code/primitives/git-remote-runtime"; +import { ConvexError, v } from "convex/values"; +import { Effect } from "effect"; + +import { mutation } from "./_generated/server"; + +// Agent token guard (service-level auth for Zopu tools), matching signalRouting. +const requireAgent = (token: string) => { + if (token !== env.FLUE_DB_TOKEN) { + throw new ConvexError("Invalid agent control token"); + } +}; + +// Canonical repo for the bootstrap work run. +const remoteConfig = (): GitRemoteConfig => { + if (!env.GITEA_TOKEN) { + throw new ConvexError("GITEA_TOKEN is not configured"); + } + return { + baseUrl: env.GITEA_URL, + owner: "puter", + repo: "zopu-code", + token: env.GITEA_TOKEN, + }; +}; + +/** + * Starts a work run for an existing Gitea issue on the canonical repo. Fetches + * the issue server-side (never trusts caller-supplied content), admits the run, + * and returns the run id plus an initial status. + * + * The Codex VM spawn (clone the canonical repo into an isolated worktree, prompt + * Codex with the issue, verify, commit, and open a Gitea PR) is the durable + * workflow body. It requires the AgentOS registry hosted on the Rivet endpoint + * and is wired as the next step. + */ +export const startIssueWork = mutation({ + args: { + issueNumber: v.number(), + token: v.string(), + }, + handler: async (_ctx, args) => { + requireAgent(args.token); + const config = remoteConfig(); + + const issue = await Effect.runPromise( + getIssue(fetchTransport, config, args.issueNumber) + ); + + const runId = `run_${Date.now().toString(36)}_${args.issueNumber}`; + + return { + issueNumber: issue.number, + issueTitle: issue.title, + runId, + status: "queued", + }; + }, +}); diff --git a/packages/env/src/convex.ts b/packages/env/src/convex.ts index 8578e41..f5ae653 100644 --- a/packages/env/src/convex.ts +++ b/packages/env/src/convex.ts @@ -7,6 +7,8 @@ export const env = createEnv({ server: { CONVEX_SITE_URL: z.url(), FLUE_DB_TOKEN: z.string().min(1), + GITEA_TOKEN: z.string().min(1).optional(), + GITEA_URL: z.url().default("https://git.openputer.com"), NATIVE_APP_URL: z.string().min(1).default("code://"), SITE_URL: z.url(), }, diff --git a/packages/primitives/package.json b/packages/primitives/package.json index 54d58ed..c0da7d4 100644 --- a/packages/primitives/package.json +++ b/packages/primitives/package.json @@ -7,10 +7,12 @@ ".": "./src/index.ts", "./agent-os": "./src/agent-os.ts", "./git": "./src/git.ts", + "./git-local-runtime": "./src/git-local-runtime.ts", + "./git-remote-runtime": "./src/git-remote-runtime.ts", "./project": "./src/project.ts", "./project-issue": "./src/project-issue.ts", - "./project-workspace": "./src/project-workspace.ts", "./project-work": "./src/project-work.ts", + "./project-workspace": "./src/project-workspace.ts", "./signal": "./src/signal.ts", "./smoke": "./src/smoke.ts" }, @@ -22,7 +24,8 @@ "dependencies": { "@agentos-software/git": "0.3.3", "@rivet-dev/agentos": "catalog:", - "effect": "catalog:" + "effect": "catalog:", + "@agentos-software/codex-cli": "0.3.4" }, "devDependencies": { "@code/config": "workspace:*", diff --git a/packages/primitives/src/agent-os.test.ts b/packages/primitives/src/agent-os.test.ts new file mode 100644 index 0000000..ae84cea --- /dev/null +++ b/packages/primitives/src/agent-os.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { codexSessionEnv, makeCodexAgentOsConfig } from "./agent-os"; + +describe("Codex agent-os config", () => { + it("always includes the codex + git software bundle", () => { + const config = makeCodexAgentOsConfig(); + expect(config.software).toHaveLength(2); + expect(config.software?.[0]).toBeTypeOf("object"); + }); + + it("builds model-provider session env", () => { + const env = codexSessionEnv( + { + apiKey: "sk-test", + baseUrl: "https://ai.example.com/v1", + model: "glm-5.2", + }, + { CODEX_MODEL: "glm-5.2" } + ); + expect(env.OPENAI_API_KEY).toBe("sk-test"); + expect(env.OPENAI_BASE_URL).toBe("https://ai.example.com/v1"); + expect(env.CODEX_MODEL).toBe("glm-5.2"); + }); +}); diff --git a/packages/primitives/src/agent-os.ts b/packages/primitives/src/agent-os.ts index f77040b..055476d 100644 --- a/packages/primitives/src/agent-os.ts +++ b/packages/primitives/src/agent-os.ts @@ -1,4 +1,5 @@ /* eslint-disable max-classes-per-file -- the AgentOS service and its tagged error form one adapter contract. */ +import codex from "@agentos-software/codex-cli"; import git from "@agentos-software/git"; import { agentOS as createAgentOsActor } from "@rivet-dev/agentos"; import type { AgentOSConfigInput } from "@rivet-dev/agentos"; @@ -69,3 +70,52 @@ export const createAgentOsActorEffect = Effect.fn("createAgentOsActorEffect")( return yield* agentOs.createActor(config); } ); + +// --------------------------------------------------------------------------- +// Codex harness configuration +// +// The canonical execution registry runs exactly one AgentOS actor for the +// puter/zopu-code repo, booted with the Codex CLI harness + git. The model +// provider is injected as VM env (OpenAI-compatible base URL + key) so Codex +// authenticates against the configured gateway without a second credentials +// path. This is a pure config builder; the RivetKit registry/server that hosts +// the actor lives in the agents package. +// --------------------------------------------------------------------------- + +/** Software bundle for a Codex-capable VM: the Codex harness plus git. */ +export const codexSoftware = [codex, git] as const; + +/** Model-provider env that Codex reads inside the VM. Pass this to the session's + * `env` when opening a Codex session (`agent: "codex"`). */ +export interface CodexModelEnv { + readonly apiKey: string; + readonly baseUrl: string; + readonly model: string; +} + +/** Builds the VM env record Codex reads to authenticate against the model gateway. */ +export const codexSessionEnv = ( + model: CodexModelEnv, + extra: Readonly<Record<string, string>> = {} +): Record<string, string> => ({ + OPENAI_API_KEY: model.apiKey, + OPENAI_BASE_URL: model.baseUrl, + ...extra, +}); + +export interface CodexAgentOsConfigInput { + /** Extra software merged after the Codex+git bundle. */ + readonly software?: NonNullable<AgentOSConfigInput<undefined>["software"]>; +} + +/** + * Builds an AgentOS actor config for a Codex-backed VM. The Codex CLI and git + * are always present; software the caller passes is appended, never replacing + * the harness or git. Model-provider env is supplied per session via + * `codexSessionEnv`, not here — the actor config has no env field. + */ +export const makeCodexAgentOsConfig = ( + input: CodexAgentOsConfigInput = {} +): AgentOSConfigInput<undefined> => ({ + software: [...codexSoftware, ...(input.software ?? [])], +}); diff --git a/packages/primitives/src/git-local-runtime.test.ts b/packages/primitives/src/git-local-runtime.test.ts new file mode 100644 index 0000000..81c82e4 --- /dev/null +++ b/packages/primitives/src/git-local-runtime.test.ts @@ -0,0 +1,116 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +// oxlint-disable-next-line unicorn/import-style -- named import avoids shadowing local `path` vars +import { join } from "node:path"; + +import { Effect } from "effect"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + addWorktree, + commit, + currentBranch, + nodeShell, + push, +} from "./git-local-runtime"; + +const run = <A, E>(eff: Effect.Effect<A, E>) => Effect.runPromise(eff); + +const execPlain = async (cwd: string, cmd: string) => { + const res = await run(nodeShell.exec("sh", ["-c", cmd], { cwd })); + return res.stdout.trim(); +}; + +let root = ""; +let origin = ""; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "git-local-")); + origin = join(root, "origin.git"); + await execPlain(root, `git init --bare -b main ${origin}`); + const seed = join(root, "seed"); + await execPlain(root, `git clone ${origin} ${seed}`); + await execPlain( + seed, + "git config user.email t@t.t && git config user.name t" + ); + await writeFile(join(seed, "README.md"), "# hi\n"); + await execPlain( + seed, + "git add -A && git commit -m init && git push -u origin main" + ); +}); + +afterEach(async () => { + await execPlain(root, "chmod -R u+w . 2>/dev/null; true"); +}); + +describe("GitLocalRuntime", () => { + it("creates a worktree on a new branch", async () => { + const cloneDir = join(root, "work"); + await run(nodeShell.exec("git", ["clone", origin, cloneDir])); + const worktree = join(root, "wt-1"); + const result = await run( + addWorktree(nodeShell, { + branch: "work/1/fix", + path: worktree, + repositoryPath: cloneDir, + }) + ); + expect(result).toBe(worktree); + expect(await execPlain(worktree, "git rev-parse --abbrev-ref HEAD")).toBe( + "work/1/fix" + ); + }); + + it("commits staged changes and returns the new sha", async () => { + const cloneDir = join(root, "work2"); + await run(nodeShell.exec("git", ["clone", origin, cloneDir])); + await run( + nodeShell.exec( + "git", + ["worktree", "add", "-b", "work/2", join(root, "wt-2")], + { cwd: cloneDir } + ) + ); + const worktree = join(root, "wt-2"); + await writeFile(join(worktree, "a.txt"), "a"); + const sha = await run( + commit(nodeShell, { message: "add a", repositoryPath: worktree }) + ); + expect(sha).toMatch(/^[0-9a-f]{7,}/u); + }); + + it("reads the current branch", async () => { + const cloneDir = join(root, "work3"); + await run(nodeShell.exec("git", ["clone", origin, cloneDir])); + expect(await run(currentBranch(nodeShell, cloneDir))).toBe("main"); + }); + + it("pushes a branch to origin", async () => { + const cloneDir = join(root, "work4"); + await run(nodeShell.exec("git", ["clone", origin, cloneDir])); + await run( + nodeShell.exec( + "git", + ["worktree", "add", "-b", "work/4", join(root, "wt-4")], + { cwd: cloneDir } + ) + ); + const worktree = join(root, "wt-4"); + await writeFile(join(worktree, "b.txt"), "b"); + await run( + commit(nodeShell, { message: "add b", repositoryPath: worktree }) + ); + await run(push(nodeShell, { refspec: "work/4", repositoryPath: worktree })); + const branches = await execPlain(origin, "git branch --list"); + expect(branches).toContain("work/4"); + }); + + it("maps a non-repo path to NotARepository", async () => { + const empty = await mkdtemp(join(tmpdir(), "empty-")); + await expect(run(currentBranch(nodeShell, empty))).rejects.toMatchObject({ + reason: "NotARepository", + }); + }); +}); diff --git a/packages/primitives/src/git-local-runtime.ts b/packages/primitives/src/git-local-runtime.ts new file mode 100644 index 0000000..eee6e6d --- /dev/null +++ b/packages/primitives/src/git-local-runtime.ts @@ -0,0 +1,268 @@ +/* eslint-disable max-classes-per-file -- the runtime service and its tagged error form one adapter contract. */ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { Context, Effect, Layer, Schema } from "effect"; + +import { GitBranchName } from "./git.js"; + +const execFileP = promisify(execFile); + +// --------------------------------------------------------------------------- +// Shell abstraction: local git runs as subprocesses, but the same contract can +// be backed by an AgentOS VM exec so worktree ops work identically inside a VM. +// --------------------------------------------------------------------------- + +export interface ShellExecOptions { + readonly cwd?: string; + readonly env?: Record<string, string>; + readonly timeoutMs?: number; +} + +export interface ShellExecResult { + readonly exitCode: number; + readonly stderr: string; + readonly stdout: string; +} + +export interface Shell { + readonly exec: ( + command: string, + args: readonly string[], + options?: ShellExecOptions + ) => Effect.Effect<ShellExecResult, GitLocalError>; +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +export const GitLocalErrorReason = Schema.Literals([ + "InvalidInput", + "CommandFailed", + "Timeout", + "NotARepository", +]); +export type GitLocalErrorReason = typeof GitLocalErrorReason.Type; + +export class GitLocalError extends Schema.TaggedErrorClass<GitLocalError>()( + "GitLocalError", + { + message: Schema.String, + reason: GitLocalErrorReason, + } +) {} + +// --------------------------------------------------------------------------- +// Inputs / outputs +// --------------------------------------------------------------------------- + +export interface CloneInput { + readonly into: string; + readonly url: string; +} + +export interface AddWorktreeInput { + readonly branch: string; + readonly path: string; + readonly repositoryPath: string; +} + +export interface CommitInput { + readonly message: string; + readonly repositoryPath: string; +} + +export interface PushInput { + readonly refspec: string; + readonly repositoryPath: string; +} + +export interface RemoteWithAuthInput { + readonly host: string; + readonly name?: string; + readonly owner: string; + readonly path: string; + readonly repositoryPath: string; + readonly token: string; + readonly user: string; +} + +// --------------------------------------------------------------------------- +// Operations: each is a pure function of (Shell, input) -> Effect. +// --------------------------------------------------------------------------- + +const git = ( + shell: Shell, + args: readonly string[], + options?: ShellExecOptions +): Effect.Effect<string, GitLocalError> => + shell.exec("git", args, options).pipe( + Effect.flatMap((res) => + res.exitCode === 0 + ? Effect.succeed(res.stdout.trim()) + : Effect.fail( + new GitLocalError({ + message: res.stderr.trim() || `git ${args.join(" ")} failed`, + reason: /not a git repository|does not appear to be/iu.test( + res.stderr + ) + ? "NotARepository" + : "CommandFailed", + }) + ) + ) + ); + +export const clone = ( + shell: Shell, + input: CloneInput +): Effect.Effect<string, GitLocalError> => + git(shell, ["clone", input.url, input.into]).pipe( + Effect.map(() => input.into) + ); + +export const addWorktree = ( + shell: Shell, + input: AddWorktreeInput +): Effect.Effect<string, GitLocalError> => + Effect.gen(function* run() { + yield* Schema.decodeUnknownEffect(GitBranchName)(input.branch).pipe( + Effect.mapError( + (cause) => + new GitLocalError({ message: cause.message, reason: "InvalidInput" }) + ) + ); + // -b creates the branch if it does not exist; -B would reset. Use -b. + yield* git(shell, ["worktree", "add", "-b", input.branch, input.path], { + cwd: input.repositoryPath, + }); + return input.path; + }); + +export const commit = ( + shell: Shell, + input: CommitInput +): Effect.Effect<string, GitLocalError> => + Effect.gen(function* run() { + yield* git(shell, ["add", "-A"], { cwd: input.repositoryPath }); + yield* git(shell, ["commit", "-m", input.message], { + cwd: input.repositoryPath, + }); + return yield* git(shell, ["rev-parse", "HEAD"], { + cwd: input.repositoryPath, + }); + }); + +export const push = ( + shell: Shell, + input: PushInput +): Effect.Effect<string, GitLocalError> => + git(shell, ["push", "-u", "origin", input.refspec], { + cwd: input.repositoryPath, + }); + +export const currentBranch = ( + shell: Shell, + repositoryPath: string +): Effect.Effect<string, GitLocalError> => + git(shell, ["rev-parse", "--abbrev-ref", "HEAD"], { + cwd: repositoryPath, + }); + +export const setRemoteUrl = ( + shell: Shell, + input: RemoteWithAuthInput +): Effect.Effect<string, GitLocalError> => { + // Token-in-URL auth for push from a sandboxed checkout. The remote URL is + // scoped to this single repo and the credential never leaves the worktree. + const url = `https://${input.user}:${input.token}@${input.host}/${input.owner}/${input.path}`; + const name = input.name ?? "origin"; + return git(shell, ["remote", "set-url", name, url], { + cwd: input.repositoryPath, + }).pipe(Effect.map(() => url)); +}; + +// --------------------------------------------------------------------------- +// Default host shell (subprocess). The same Shell contract is implemented by +// the AgentOS VM adapter so worktree ops run identically inside a VM. +// --------------------------------------------------------------------------- + +// execFile resolves only on success; non-zero exits throw with stderr in the +// message. We normalize that into a ShellExecResult so callers map exit codes. +const runExecFile = async ( + command: string, + args: readonly string[], + options?: ShellExecOptions +): Promise<ShellExecResult> => { + try { + const { stdout, stderr } = await execFileP(command, [...args], { + cwd: options?.cwd, + env: options?.env, + maxBuffer: 1024 * 1024 * 8, + timeout: options?.timeoutMs, + }); + return { exitCode: 0, stderr, stdout }; + } catch (error) { + const failed = error as NodeJS.ErrnoException & { + stdout?: string; + stderr?: string; + }; + return { + exitCode: 1, + stderr: `${failed.stderr ?? ""}${failed.message}`, + stdout: failed.stdout ?? "", + }; + } +}; + +export const nodeShell: Shell = { + exec: (command, args, options) => + Effect.tryPromise({ + catch: (cause) => + new GitLocalError({ + message: cause instanceof Error ? cause.message : "Spawn failed", + reason: "CommandFailed", + }), + try: () => runExecFile(command, args, options), + }), +}; + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +interface GitLocalRuntimeShape { + readonly shell: Shell; + readonly clone: (input: CloneInput) => Effect.Effect<string, GitLocalError>; + readonly addWorktree: ( + input: AddWorktreeInput + ) => Effect.Effect<string, GitLocalError>; + readonly commit: (input: CommitInput) => Effect.Effect<string, GitLocalError>; + readonly push: (input: PushInput) => Effect.Effect<string, GitLocalError>; + readonly currentBranch: ( + repositoryPath: string + ) => Effect.Effect<string, GitLocalError>; + readonly setRemoteUrl: ( + input: RemoteWithAuthInput + ) => Effect.Effect<string, GitLocalError>; +} + +export class GitLocalRuntime extends Context.Service< + GitLocalRuntime, + GitLocalRuntimeShape +>()("@code/primitives/git-local-runtime/GitLocalRuntime") {} + +export const makeGitLocalRuntimeLayer = (shell: Shell = nodeShell) => + Layer.succeed( + GitLocalRuntime, + GitLocalRuntime.of({ + addWorktree: (input) => addWorktree(shell, input), + clone: (input) => clone(shell, input), + commit: (input) => commit(shell, input), + currentBranch: (repositoryPath) => currentBranch(shell, repositoryPath), + push: (input) => push(shell, input), + setRemoteUrl: (input) => setRemoteUrl(shell, input), + shell, + }) + ); diff --git a/packages/primitives/src/git-remote-runtime.test.ts b/packages/primitives/src/git-remote-runtime.test.ts new file mode 100644 index 0000000..63a2eea --- /dev/null +++ b/packages/primitives/src/git-remote-runtime.test.ts @@ -0,0 +1,124 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + createBranch, + createIssue, + createPullRequest, + getIssue, + listIssues, +} from "./git-remote-runtime"; +import type { GitRemoteConfig, GitRemoteTransport } from "./git-remote-runtime"; + +const config: GitRemoteConfig = { + baseUrl: "https://git.example.com", + owner: "puter", + repo: "zopu-code", + token: "secret", +}; + +const stubTransport = ( + responder: (method: string, path: string, body: unknown) => unknown, + status = 200 +): GitRemoteTransport => ({ + request: ({ body, method, url }) => + Effect.sync(() => { + const path = url.replace( + `${config.baseUrl}/api/v1/repos/puter/zopu-code`, + "" + ); + const result = responder(method, path, body); + return { + json: () => Promise.resolve(result), + status, + }; + }), +}); + +const run = <A, E>(eff: Effect.Effect<A, E>) => Effect.runPromise(eff); + +describe("GitRemoteRuntime", () => { + it("creates an issue and maps Gitea fields", async () => { + const transport = stubTransport(() => ({ + body: "the body", + html_url: "https://git.example.com/puter/zopu-code/issues/1", + number: 1, + state: "open", + title: "Add status control", + })); + const issue = await run( + createIssue(transport, config, { + body: "the body", + title: "Add status control", + }) + ); + expect(issue.number).toBe(1); + expect(issue.url).toContain("/issues/1"); + }); + + it("maps 401 to Unauthorized", async () => { + const transport = stubTransport(() => ({}), 401); + await expect(run(listIssues(transport, config))).rejects.toMatchObject({ + reason: "Unauthorized", + }); + }); + + it("maps 404 to NotFound", async () => { + const transport = stubTransport(() => ({}), 404); + await expect(run(getIssue(transport, config, 7))).rejects.toMatchObject({ + reason: "NotFound", + }); + }); + + it("creates a branch", async () => { + const transport = stubTransport((_m, _p, body) => ({ + name: (body as { new_branch_name: string }).new_branch_name, + })); + const branch = await run( + createBranch(transport, config, { name: "work/1/fix" }) + ); + expect(branch.name).toBe("work/1/fix"); + }); + + it("creates a pull request and reads nested ref fields", async () => { + const transport = stubTransport(() => ({ + base: { ref: "main" }, + head: { ref: "work/1/fix" }, + html_url: "https://git.example.com/puter/zopu-code/pulls/2", + number: 2, + state: "open", + title: "Fix thing", + })); + const pr = await run( + createPullRequest(transport, config, { + baseBranch: "main", + body: "", + branch: "work/1/fix", + title: "Fix thing", + }) + ); + expect(pr.number).toBe(2); + expect(pr.branch).toBe("work/1/fix"); + expect(pr.baseBranch).toBe("main"); + }); + + it("maps merged PR state", async () => { + const transport = stubTransport(() => ({ + base: { ref: "main" }, + head: { ref: "work/1/fix" }, + merged: true, + number: 2, + state: "closed", + title: "x", + })); + const pr = await run( + createPullRequest(transport, config, { + baseBranch: "main", + body: "", + branch: "work/1/fix", + title: "x", + }) + ); + expect(pr.state).toBe("merged"); + }); +}); diff --git a/packages/primitives/src/git-remote-runtime.ts b/packages/primitives/src/git-remote-runtime.ts new file mode 100644 index 0000000..239db69 --- /dev/null +++ b/packages/primitives/src/git-remote-runtime.ts @@ -0,0 +1,367 @@ +/* eslint-disable max-classes-per-file -- the runtime service and its tagged error form one adapter contract. */ +import { Context, Effect, Layer, Schema } from "effect"; + +const MeaningfulString = Schema.String.check( + Schema.makeFilter((value) => value.trim().length > 0, { + expected: "a non-empty string", + }) +); + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +export const GitRemoteConfig = Schema.Struct({ + baseUrl: Schema.String, + owner: MeaningfulString, + repo: MeaningfulString, + token: MeaningfulString, +}); +export type GitRemoteConfig = typeof GitRemoteConfig.Type; + +// --------------------------------------------------------------------------- +// Value objects +// --------------------------------------------------------------------------- + +export const RemoteIssue = Schema.Struct({ + body: Schema.String, + number: Schema.Int.check(Schema.isGreaterThan(0)), + state: Schema.Literals(["open", "closed"]), + title: MeaningfulString, + url: Schema.String, +}); +export type RemoteIssue = typeof RemoteIssue.Type; + +export const RemoteBranch = Schema.Struct({ + name: MeaningfulString, +}); +export type RemoteBranch = typeof RemoteBranch.Type; + +export const RemotePullRequest = Schema.Struct({ + baseBranch: MeaningfulString, + branch: MeaningfulString, + number: Schema.Int.check(Schema.isGreaterThan(0)), + state: Schema.Literals(["open", "closed", "merged"]), + title: MeaningfulString, + url: Schema.String, +}); +export type RemotePullRequest = typeof RemotePullRequest.Type; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +export const GitRemoteErrorReason = Schema.Literals([ + "InvalidInput", + "Unauthorized", + "NotFound", + "Conflict", + "Unreachable", +]); +export type GitRemoteErrorReason = typeof GitRemoteErrorReason.Type; + +export class GitRemoteError extends Schema.TaggedErrorClass<GitRemoteError>()( + "GitRemoteError", + { + message: Schema.String, + reason: GitRemoteErrorReason, + } +) {} + +// --------------------------------------------------------------------------- +// Low-level transport: a tiny fetch wrapper that maps HTTP failures to the +// tagged error. Kept as an injectable dependency so tests can stub the wire. +// --------------------------------------------------------------------------- + +export interface GitRemoteTransport { + readonly request: (input: { + readonly body?: unknown; + readonly headers?: Readonly<Record<string, string>>; + readonly method: string; + readonly url: string; + }) => Effect.Effect< + { readonly status: number; readonly json: () => Promise<unknown> }, + GitRemoteError + >; +} + +const authHeaders = (token: string): Record<string, string> => ({ + Authorization: `token ${token}`, + "Content-Type": "application/json", +}); + +const endpoint = (config: GitRemoteConfig, path: string): string => + `${config.baseUrl.replace(/\/+$/u, "")}/api/v1/repos/${config.owner}/${config.repo}${path}`; + +const fromStatus = (status: number, fallback: string): GitRemoteError => { + if (status === 401 || status === 403) { + return new GitRemoteError({ + message: "Unauthorized", + reason: "Unauthorized", + }); + } + if (status === 404) { + return new GitRemoteError({ message: "Not found", reason: "NotFound" }); + } + if (status === 409) { + return new GitRemoteError({ message: "Conflict", reason: "Conflict" }); + } + if (status >= 500) { + return new GitRemoteError({ message: fallback, reason: "Unreachable" }); + } + return new GitRemoteError({ message: fallback, reason: "InvalidInput" }); +}; + +const request = ( + transport: GitRemoteTransport, + config: GitRemoteConfig, + method: string, + path: string, + body?: unknown +): Effect.Effect<unknown, GitRemoteError> => + transport + .request({ + body, + headers: authHeaders(config.token), + method, + url: endpoint(config, path), + }) + .pipe( + Effect.flatMap((res) => + Effect.gen(function* handleResponse() { + if (res.status >= 400) { + const detail = yield* Effect.tryPromise({ + catch: () => null, + try: () => res.json(), + }).pipe(Effect.orElseSucceed(() => null)); + const message = + detail && typeof detail === "object" && "message" in detail + ? String((detail as { message: unknown }).message) + : `Request ${method} ${path} failed with ${res.status}`; + return yield* Effect.fail(fromStatus(res.status, message)); + } + return yield* Effect.tryPromise({ + catch: (cause) => + new GitRemoteError({ + message: cause instanceof Error ? cause.message : "Bad JSON", + reason: "InvalidInput", + }), + try: () => res.json(), + }); + }) + ) + ); + +// --------------------------------------------------------------------------- +// Operations +// --------------------------------------------------------------------------- + +export interface CreateIssueInput { + readonly body: string; + readonly title: string; +} + +export const createIssue = ( + transport: GitRemoteTransport, + config: GitRemoteConfig, + input: CreateIssueInput +): Effect.Effect<RemoteIssue, GitRemoteError> => + Effect.gen(function* run() { + const raw = yield* request(transport, config, "POST", "/issues", { + body: input.body, + title: input.title, + }); + return yield* Schema.decodeUnknownEffect(RemoteIssue)({ + body: (raw as { body?: string }).body ?? "", + number: (raw as { number: number }).number, + state: (raw as { state: string }).state ?? "open", + title: (raw as { title: string }).title, + url: (raw as { html_url?: string }).html_url ?? "", + }).pipe( + Effect.mapError( + (cause) => + new GitRemoteError({ message: cause.message, reason: "InvalidInput" }) + ) + ); + }); + +export interface CreateBranchInput { + readonly from?: string; + readonly name: string; +} + +export const createBranch = ( + transport: GitRemoteTransport, + config: GitRemoteConfig, + input: CreateBranchInput +): Effect.Effect<RemoteBranch, GitRemoteError> => + Effect.gen(function* run() { + const raw = yield* request(transport, config, "POST", "/branches", { + new_branch_name: input.name, + old_ref_name: input.from ?? "main", + }); + return yield* Schema.decodeUnknownEffect(RemoteBranch)({ + name: (raw as { name: string }).name, + }).pipe( + Effect.mapError( + (cause) => + new GitRemoteError({ message: cause.message, reason: "InvalidInput" }) + ) + ); + }); + +export interface CreatePullRequestInput { + readonly baseBranch: string; + readonly body: string; + readonly branch: string; + readonly title: string; +} + +export const createPullRequest = ( + transport: GitRemoteTransport, + config: GitRemoteConfig, + input: CreatePullRequestInput +): Effect.Effect<RemotePullRequest, GitRemoteError> => + Effect.gen(function* run() { + const raw = yield* request(transport, config, "POST", "/pulls", { + base: input.baseBranch, + body: input.body, + head: input.branch, + title: input.title, + }); + const merged = Boolean((raw as { merged?: boolean }).merged); + return yield* Schema.decodeUnknownEffect(RemotePullRequest)({ + baseBranch: + (raw as { base?: { ref?: string } }).base?.ref ?? input.baseBranch, + branch: (raw as { head?: { ref?: string } }).head?.ref ?? input.branch, + number: (raw as { number: number }).number, + state: merged ? "merged" : ((raw as { state?: string }).state ?? "open"), + title: (raw as { title: string }).title, + url: (raw as { html_url?: string }).html_url ?? "", + }).pipe( + Effect.mapError( + (cause) => + new GitRemoteError({ message: cause.message, reason: "InvalidInput" }) + ) + ); + }); + +export const listIssues = ( + transport: GitRemoteTransport, + config: GitRemoteConfig +): Effect.Effect<readonly RemoteIssue[], GitRemoteError> => + Effect.gen(function* run() { + const raw = yield* request( + transport, + config, + "GET", + "/issues?state=open&type=issues" + ); + const list = (raw as Record<string, unknown>[]) ?? []; + const decoded = list.map((entry) => + Schema.decodeUnknownEffect(RemoteIssue)({ + body: (entry.body as string) ?? "", + number: entry.number as number, + state: (entry.state as string) ?? "open", + title: entry.title as string, + url: (entry.html_url as string) ?? "", + }).pipe( + Effect.mapError( + (cause) => + new GitRemoteError({ + message: cause.message, + reason: "InvalidInput", + }) + ) + ) + ); + return yield* Effect.all(decoded); + }); + +export const getIssue = ( + transport: GitRemoteTransport, + config: GitRemoteConfig, + number: number +): Effect.Effect<RemoteIssue, GitRemoteError> => + Effect.gen(function* run() { + const raw = yield* request(transport, config, "GET", `/issues/${number}`); + return yield* Schema.decodeUnknownEffect(RemoteIssue)({ + body: (raw as { body?: string }).body ?? "", + number: (raw as { number: number }).number, + state: (raw as { state: string }).state ?? "open", + title: (raw as { title: string }).title, + url: (raw as { html_url?: string }).html_url ?? "", + }).pipe( + Effect.mapError( + (cause) => + new GitRemoteError({ message: cause.message, reason: "InvalidInput" }) + ) + ); + }); + +// --------------------------------------------------------------------------- +// Fetch-backed transport (default) + service +// --------------------------------------------------------------------------- + +export const fetchTransport: GitRemoteTransport = { + request: ({ body, headers, method, url }) => + Effect.tryPromise({ + catch: (cause) => + new GitRemoteError({ + message: cause instanceof Error ? cause.message : "Network error", + reason: "Unreachable", + }), + try: async () => { + const init: RequestInit = { headers: headers ?? {}, method }; + if (body !== undefined && method !== "GET" && method !== "HEAD") { + init.body = JSON.stringify(body); + } + const res = await fetch(url, init); + return { + json: () => res.json() as Promise<unknown>, + status: res.status, + }; + }, + }), +}; + +interface GitRemoteRuntimeShape { + readonly config: GitRemoteConfig; + readonly transport: GitRemoteTransport; + readonly createIssue: ( + input: CreateIssueInput + ) => Effect.Effect<RemoteIssue, GitRemoteError>; + readonly createBranch: ( + input: CreateBranchInput + ) => Effect.Effect<RemoteBranch, GitRemoteError>; + readonly createPullRequest: ( + input: CreatePullRequestInput + ) => Effect.Effect<RemotePullRequest, GitRemoteError>; + readonly listIssues: () => Effect.Effect< + readonly RemoteIssue[], + GitRemoteError + >; + readonly getIssue: ( + number: number + ) => Effect.Effect<RemoteIssue, GitRemoteError>; +} + +export class GitRemoteRuntime extends Context.Service< + GitRemoteRuntime, + GitRemoteRuntimeShape +>()("@code/primitives/git-remote-runtime/GitRemoteRuntime") {} + +export const makeGitRemoteRuntimeLayer = (config: GitRemoteConfig) => + Layer.succeed( + GitRemoteRuntime, + GitRemoteRuntime.of({ + config, + createBranch: (input) => createBranch(fetchTransport, config, input), + createIssue: (input) => createIssue(fetchTransport, config, input), + createPullRequest: (input) => + createPullRequest(fetchTransport, config, input), + getIssue: (number) => getIssue(fetchTransport, config, number), + listIssues: () => listIssues(fetchTransport, config), + transport: fetchTransport, + }) + ); diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index 600f61c..6b835ea 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -1,6 +1,8 @@ // oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules. export * from "./agent-os"; export * from "./git"; +export * from "./git-local-runtime"; +export * from "./git-remote-runtime"; export * from "./project"; export * from "./project-issue"; export * from "./project-workspace";