Files
zopu-code/packages/primitives/src/git-remote-runtime.ts
-Puter e744ac4687 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.
2026-07-25 03:18:23 +05:30

368 lines
12 KiB
TypeScript

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