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:
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,
|
||||
})
|
||||
);
|
||||
Reference in New Issue
Block a user