Zopu dev bootstrap: git runtimes, Codex agent-os, dev agent

Merge t3code/explore-primitives-package onto master.
This commit is contained in:
2026-07-24 21:50:45 +00:00
17 changed files with 1239 additions and 3 deletions

View File

@@ -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

View File

@@ -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:",

View File

@@ -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",

View File

@@ -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: <title> — <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,
}),
],
};
});

View File

@@ -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);
},
}),
];

View File

@@ -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,
});
},
}),
];
};

View File

@@ -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);

View File

@@ -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",
};
},
});

View File

@@ -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(),
},

View File

@@ -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:*",

View File

@@ -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");
});
});

View File

@@ -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 ?? [])],
});

View File

@@ -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",
});
});
});

View File

@@ -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,
})
);

View File

@@ -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");
});
});

View File

@@ -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,
})
);

View File

@@ -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";