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.
368 lines
12 KiB
TypeScript
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,
|
|
})
|
|
);
|