Files
zopu-code/packages/primitives/src/git-local-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

269 lines
8.1 KiB
TypeScript

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