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