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

A vertical slice to bootstrap the product loop on the canonical zopu-code repo.

Primitives (packages/primitives, all tested + lint/type clean):
- GitRemoteRuntime: Gitea REST client (createIssue, createBranch,
  createPullRequest, listIssues, getIssue) as an Effect context service with
  a fetch-backed transport. Live-verified against puter/zopu-code.
- GitLocalRuntime: clone, addWorktree, commit, push, currentBranch,
  setRemoteUrl over a pluggable Shell (host subprocess now, AgentOS VM exec
  later). Tested against real temp git repos.
- agent-os: Codex support via @agentos-software/codex-cli — codexSoftware
  bundle (codex+git), makeCodexAgentOsConfig(), codexSessionEnv() for the
  OpenAI base URL/key.

Env:
- GITEA_URL/GITEA_TOKEN wired into convex.ts and convex.config.ts.
- .env.example documents the self-hosted git section.

Agents (packages/agents):
- zopu-dev: development agent that creates issues on the canonical repo and
  starts autonomous work runs. git-remote tools (create/list/branch/PR) wired
  to GitRemoteRuntime; start_workflow tool enqueues a work run.
- flue run zopu-dev verified live (lists/creates issues on real Gitea).

Backend (packages/backend):
- workflows.startIssueWork mutation: fetches a Gitea issue server-side and
  admits a queued work run. The Codex VM spawn body is the next step.
This commit is contained in:
-Puter
2026-07-25 03:18:23 +05:30
parent a11ed988fb
commit e744ac4687
17 changed files with 1239 additions and 3 deletions

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