Zopu dev bootstrap: git runtimes, Codex agent-os, dev agent
Merge t3code/explore-primitives-package onto master.
This commit is contained in:
@@ -32,3 +32,7 @@ AGENT_MODEL_BASE_URL=https://ai.example.com/v1
|
|||||||
AGENT_MODEL_API_KEY=replace-with-provider-api-key
|
AGENT_MODEL_API_KEY=replace-with-provider-api-key
|
||||||
AGENT_MODEL_CONTEXT_WINDOW=262000
|
AGENT_MODEL_CONTEXT_WINDOW=262000
|
||||||
AGENT_MODEL_MAX_TOKENS=131072
|
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
|
||||||
|
|||||||
1
bun.lock
1
bun.lock
@@ -246,6 +246,7 @@
|
|||||||
"name": "@code/primitives",
|
"name": "@code/primitives",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@agentos-software/codex-cli": "0.3.4",
|
||||||
"@agentos-software/git": "0.3.3",
|
"@agentos-software/git": "0.3.3",
|
||||||
"@rivet-dev/agentos": "catalog:",
|
"@rivet-dev/agentos": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
"dev": "bun --env-file=../../.env flue dev",
|
"dev": "bun --env-file=../../.env flue dev",
|
||||||
"dev:tailscale": "bun --env-file=../../.env flue dev",
|
"dev:tailscale": "bun --env-file=../../.env flue dev",
|
||||||
"run": "bun --env-file=../../.env flue run",
|
"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": {
|
"dependencies": {
|
||||||
"@agentos-software/opencode": "0.2.7",
|
"@agentos-software/opencode": "0.2.7",
|
||||||
|
|||||||
51
packages/agents/src/agents/zopu-dev.ts
Normal file
51
packages/agents/src/agents/zopu-dev.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
119
packages/agents/src/tools/git-remote.ts
Normal file
119
packages/agents/src/tools/git-remote.ts
Normal 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);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
37
packages/agents/src/tools/workflow.ts
Normal file
37
packages/agents/src/tools/workflow.ts
Normal 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -7,6 +7,8 @@ const app = defineApp({
|
|||||||
NATIVE_APP_URL: v.optional(v.string()),
|
NATIVE_APP_URL: v.optional(v.string()),
|
||||||
SITE_URL: v.string(),
|
SITE_URL: v.string(),
|
||||||
FLUE_DB_TOKEN: v.string(),
|
FLUE_DB_TOKEN: v.string(),
|
||||||
|
GITEA_URL: v.optional(v.string()),
|
||||||
|
GITEA_TOKEN: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
app.use(betterAuth);
|
app.use(betterAuth);
|
||||||
|
|||||||
64
packages/backend/convex/workflows.ts
Normal file
64
packages/backend/convex/workflows.ts
Normal 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",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
2
packages/env/src/convex.ts
vendored
2
packages/env/src/convex.ts
vendored
@@ -7,6 +7,8 @@ export const env = createEnv({
|
|||||||
server: {
|
server: {
|
||||||
CONVEX_SITE_URL: z.url(),
|
CONVEX_SITE_URL: z.url(),
|
||||||
FLUE_DB_TOKEN: z.string().min(1),
|
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://"),
|
NATIVE_APP_URL: z.string().min(1).default("code://"),
|
||||||
SITE_URL: z.url(),
|
SITE_URL: z.url(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,10 +7,12 @@
|
|||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./agent-os": "./src/agent-os.ts",
|
"./agent-os": "./src/agent-os.ts",
|
||||||
"./git": "./src/git.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": "./src/project.ts",
|
||||||
"./project-issue": "./src/project-issue.ts",
|
"./project-issue": "./src/project-issue.ts",
|
||||||
"./project-workspace": "./src/project-workspace.ts",
|
|
||||||
"./project-work": "./src/project-work.ts",
|
"./project-work": "./src/project-work.ts",
|
||||||
|
"./project-workspace": "./src/project-workspace.ts",
|
||||||
"./signal": "./src/signal.ts",
|
"./signal": "./src/signal.ts",
|
||||||
"./smoke": "./src/smoke.ts"
|
"./smoke": "./src/smoke.ts"
|
||||||
},
|
},
|
||||||
@@ -22,7 +24,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentos-software/git": "0.3.3",
|
"@agentos-software/git": "0.3.3",
|
||||||
"@rivet-dev/agentos": "catalog:",
|
"@rivet-dev/agentos": "catalog:",
|
||||||
"effect": "catalog:"
|
"effect": "catalog:",
|
||||||
|
"@agentos-software/codex-cli": "0.3.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@code/config": "workspace:*",
|
"@code/config": "workspace:*",
|
||||||
|
|||||||
25
packages/primitives/src/agent-os.test.ts
Normal file
25
packages/primitives/src/agent-os.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
/* eslint-disable max-classes-per-file -- the AgentOS service and its tagged error form one adapter contract. */
|
/* 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 git from "@agentos-software/git";
|
||||||
import { agentOS as createAgentOsActor } from "@rivet-dev/agentos";
|
import { agentOS as createAgentOsActor } from "@rivet-dev/agentos";
|
||||||
import type { AgentOSConfigInput } 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);
|
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 ?? [])],
|
||||||
|
});
|
||||||
|
|||||||
116
packages/primitives/src/git-local-runtime.test.ts
Normal file
116
packages/primitives/src/git-local-runtime.test.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
268
packages/primitives/src/git-local-runtime.ts
Normal file
268
packages/primitives/src/git-local-runtime.ts
Normal 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,
|
||||||
|
})
|
||||||
|
);
|
||||||
124
packages/primitives/src/git-remote-runtime.test.ts
Normal file
124
packages/primitives/src/git-remote-runtime.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
367
packages/primitives/src/git-remote-runtime.ts
Normal file
367
packages/primitives/src/git-remote-runtime.ts
Normal 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,
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
|
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
|
||||||
export * from "./agent-os";
|
export * from "./agent-os";
|
||||||
export * from "./git";
|
export * from "./git";
|
||||||
|
export * from "./git-local-runtime";
|
||||||
|
export * from "./git-remote-runtime";
|
||||||
export * from "./project";
|
export * from "./project";
|
||||||
export * from "./project-issue";
|
export * from "./project-issue";
|
||||||
export * from "./project-workspace";
|
export * from "./project-workspace";
|
||||||
|
|||||||
Reference in New Issue
Block a user