diff --git a/apps/web/src/components/projects/project-workspace-page.tsx b/apps/web/src/components/projects/project-workspace-page.tsx
index b171063..bc22d97 100644
--- a/apps/web/src/components/projects/project-workspace-page.tsx
+++ b/apps/web/src/components/projects/project-workspace-page.tsx
@@ -7,6 +7,7 @@ import {
ExternalLink,
FileText,
GitFork,
+ GitPullRequest,
Play,
Plus,
} from "lucide-react";
@@ -139,7 +140,9 @@ interface ArtifactGridProps {
const renderArtifacts = (artifacts: ArtifactGridProps["artifacts"]) => {
if (artifacts === undefined) {
- return
Loading artifacts...
;
+ return (
+ Loading artifacts...
+ );
}
if (artifacts.length === 0) {
return No artifacts yet.
;
@@ -168,6 +171,55 @@ const ArtifactGrid = ({ artifacts }: ArtifactGridProps) => (
);
+interface PullRequestPanelProps {
+ readonly pullRequest:
+ | {
+ readonly baseBranch: string;
+ readonly branch: string;
+ readonly number: number;
+ readonly status: "open" | "closed" | "merged";
+ readonly url: string;
+ }
+ | undefined;
+}
+
+const PullRequestPanel = ({ pullRequest }: PullRequestPanelProps) => {
+ if (!pullRequest) {
+ return null;
+ }
+ return (
+
+
+
+
+ Pull request
+
+
+
+
+
+ {pullRequest.branch} → {pullRequest.baseBranch}
+
+
+ Merge remains manual.
+
+
+
+ );
+};
+
interface IssueComposerProps {
readonly body: string;
readonly busy: boolean;
@@ -354,6 +406,8 @@ export const ProjectWorkspacePage = () => {
+
+
{
api.projectIssues.list,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
+ const events = useQuery(
+ api.projectIssues.events,
+ activeProjectId ? { projectId: activeProjectId } : "skip"
+ );
+ const pullRequest = events
+ ?.map((event) => event.data)
+ .find(
+ (
+ data
+ ): data is {
+ pullRequest: {
+ baseBranch: string;
+ branch: string;
+ number: number;
+ status: "open" | "closed" | "merged";
+ url: string;
+ };
+ } =>
+ typeof data === "object" &&
+ data !== null &&
+ "pullRequest" in data &&
+ typeof data.pullRequest === "object" &&
+ data.pullRequest !== null
+ )?.pullRequest;
const connectRepository = async () => {
setPendingAction("connect");
@@ -99,11 +123,13 @@ export const useProjectWorkspace = () => {
artifacts,
connectRepository,
error,
+ events,
issueBody,
issueTitle,
issues,
pendingAction,
projects,
+ pullRequest,
raiseIssue,
repository,
selectedProject,
diff --git a/bun.lock b/bun.lock
index e142821..7e76667 100644
--- a/bun.lock
+++ b/bun.lock
@@ -149,9 +149,11 @@
"dependencies": {
"@code/backend": "workspace:*",
"@code/env": "workspace:*",
+ "@code/primitives": "workspace:*",
"@flue/runtime": "latest",
"@rivet-dev/agentos-core": "catalog:",
"convex": "catalog:",
+ "effect": "catalog:",
"hono": "4.12.30",
"valibot": "^1.4.2",
},
diff --git a/packages/agents/package.json b/packages/agents/package.json
index 6c7185e..a454ebe 100644
--- a/packages/agents/package.json
+++ b/packages/agents/package.json
@@ -14,9 +14,11 @@
"dependencies": {
"@code/backend": "workspace:*",
"@code/env": "workspace:*",
+ "@code/primitives": "workspace:*",
"@flue/runtime": "latest",
"@rivet-dev/agentos-core": "catalog:",
"convex": "catalog:",
+ "effect": "catalog:",
"hono": "4.12.30",
"valibot": "^1.4.2"
},
diff --git a/packages/agents/src/actions/finalize-gitea-lifecycle.ts b/packages/agents/src/actions/finalize-gitea-lifecycle.ts
new file mode 100644
index 0000000..a1e3fb3
--- /dev/null
+++ b/packages/agents/src/actions/finalize-gitea-lifecycle.ts
@@ -0,0 +1,132 @@
+import { api } from "@code/backend/convex/_generated/api";
+import type { Id } from "@code/backend/convex/_generated/dataModel";
+import { parseAgentEnv } from "@code/env/agent";
+import { defineAction } from "@flue/runtime";
+import { ConvexHttpClient } from "convex/browser";
+import * as v from "valibot";
+
+import {
+ createGiteaHttpTransport,
+ runPostRunGiteaLifecycle,
+} from "../git/gitea";
+
+const pullRequestOutput = v.object({
+ baseBranch: v.string(),
+ branch: v.string(),
+ number: v.number(),
+ status: v.picklist(["open", "closed", "merged"]),
+ url: v.string(),
+});
+
+const output = v.object({
+ baseBranch: v.string(),
+ branch: v.string(),
+ commitSha: v.optional(v.string()),
+ pullRequest: v.optional(pullRequestOutput),
+ status: v.picklist([
+ "no_changes",
+ "committed",
+ "pushed",
+ "pull_request_open",
+ "failed",
+ ]),
+});
+
+export const createFinalizeGiteaLifecycle = (
+ issueAgentId: string,
+ runtimeEnv: Record
+) => {
+ const env = parseAgentEnv(runtimeEnv);
+ const client = new ConvexHttpClient(env.CONVEX_URL);
+ const issueId = issueAgentId as Id<"projectIssues">;
+
+ return defineAction({
+ description:
+ "After verification, inspect the issue workspace, commit and push its work branch, and create an open Gitea pull request. Never merge.",
+ input: v.object({
+ commitMessage: v.optional(v.pipe(v.string(), v.maxLength(120))),
+ verified: v.boolean(),
+ }),
+ name: "finalize_gitea_lifecycle",
+ output,
+ async run({ harness, input, log }) {
+ const context = await client.query(api.agentWorkspace.get, {
+ issueId,
+ token: env.FLUE_DB_TOKEN,
+ });
+ if (!context.source) {
+ throw new Error("Project has no Git source configured");
+ }
+ if (!context.source.defaultBranch) {
+ throw new Error("Project Git source has no default branch");
+ }
+ if (!env.GITEA_TOKEN) {
+ throw new Error(
+ "GITEA_TOKEN is required for Gitea pull request creation"
+ );
+ }
+
+ const runner = {
+ async run(
+ command: string,
+ options?: { cwd?: string; env?: Record }
+ ) {
+ return await harness.shell(command, options);
+ },
+ };
+ const transport = createGiteaHttpTransport({
+ baseUrl: env.GITEA_URL,
+ token: env.GITEA_TOKEN,
+ });
+
+ try {
+ const result = await runPostRunGiteaLifecycle({
+ baseBranch: context.source.defaultBranch,
+ body: `Verified changes for project issue #${context.issue.number}. Merge remains a manual review action.`,
+ commitMessage: input.commitMessage,
+ issueNumber: context.issue.number,
+ issueTitle: context.issue.title,
+ repositoryPath: context.source.repositoryPath,
+ runner,
+ title: `Issue #${context.issue.number}: ${context.issue.title}`,
+ transport,
+ verification: input.verified ? "passed" : "failed",
+ workspace: "/workspace",
+ });
+ await client.mutation(api.agentWorkspace.recordGiteaLifecycle, {
+ baseBranch: result.baseBranch,
+ branch: result.branch,
+ ...(result.commitSha === undefined
+ ? {}
+ : { commitSha: result.commitSha }),
+ issueId,
+ ...(result.pullRequest === undefined
+ ? {}
+ : { pullRequest: result.pullRequest }),
+ status: result.status,
+ token: env.FLUE_DB_TOKEN,
+ });
+ log.info("Gitea lifecycle completed", {
+ branch: result.branch,
+ status: result.status,
+ });
+ return result;
+ } catch (error) {
+ const branchResult = await runner.run("git branch --show-current", {
+ cwd: "/workspace",
+ });
+ const branch = branchResult.stdout.trim() || "unknown";
+ const message = error instanceof Error ? error.message : String(error);
+ await client.mutation(api.agentWorkspace.recordGiteaLifecycle, {
+ baseBranch: context.source.defaultBranch,
+ branch,
+ error: message,
+ issueId,
+ status: "failed",
+ token: env.FLUE_DB_TOKEN,
+ });
+ throw error;
+ }
+ },
+ });
+};
diff --git a/packages/agents/src/agents/project-manager.ts b/packages/agents/src/agents/project-manager.ts
index 10316da..1e7f8d5 100644
--- a/packages/agents/src/agents/project-manager.ts
+++ b/packages/agents/src/agents/project-manager.ts
@@ -2,6 +2,7 @@ import { parseAgentEnv } from "@code/env/agent";
import { defineAgent } from "@flue/runtime";
import type { AgentRouteHandler } from "@flue/runtime";
+import { createFinalizeGiteaLifecycle } from "../actions/finalize-gitea-lifecycle";
import { agentOs } from "../sandboxes/agent-os";
import { createProjectTools } from "../tools/project";
@@ -14,6 +15,7 @@ export default defineAgent(({ env, id }) => {
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
return {
+ actions: [createFinalizeGiteaLifecycle(id, env)],
cwd: "/workspace",
description,
instructions: `You are the issue-scoped project manager and coding agent.
@@ -22,7 +24,7 @@ Start every run by calling lookup_issue_context, reading issue.md, project.md, b
Work only on the bound issue. Inspect existing files before changing them. If a missing product decision makes safe progress impossible, publish the current work.md and steps.md, report needs-input, then ask one focused question. Otherwise implement the complete issue, run the relevant command or scenario in AgentOS, and preserve command evidence.
-Before finishing, update the local work.md, steps.md, artifacts.md, and context.md files and publish each changed canonical artifact with publish_project_artifact. Report completed only after verification. On an unrecoverable error, report failed with the exact blocker. Never claim a repository change or command result you did not observe.`,
+Before finishing, update the local work.md, steps.md, artifacts.md, and context.md files and publish each changed canonical artifact with publish_project_artifact. After verification, call finalize_gitea_lifecycle with verified=true; it owns the post-run Git/Gitea lifecycle and never merges. Report completed only after that action succeeds or reports no_changes. On an unrecoverable error, report failed with the exact blocker. Never claim a repository change or command result you did not observe.`,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
sandbox: agentOs(env),
tools: createProjectTools(id, env),
diff --git a/packages/agents/src/git/gitea.test.ts b/packages/agents/src/git/gitea.test.ts
new file mode 100644
index 0000000..b49e32e
--- /dev/null
+++ b/packages/agents/src/git/gitea.test.ts
@@ -0,0 +1,155 @@
+import { describe, expect, test } from "vitest";
+
+import { createGiteaHttpTransport, runPostRunGiteaLifecycle } from "./gitea";
+import type { GitCommandRunner, GiteaTransport } from "./gitea";
+
+const repository = {
+ cloneUrl: "https://git.openputer.com/puter/zopu-code.git",
+ defaultBranch: "main",
+ htmlUrl: "https://git.openputer.com/puter/zopu-code",
+ name: "zopu-code",
+ sshUrl: "ssh://git@git.openputer.com:2222/puter/zopu-code.git",
+};
+
+const makeRunner = (): GitCommandRunner & { readonly commands: string[] } => {
+ const commands: string[] = [];
+ let statusCalls = 0;
+ return {
+ commands,
+ run(command) {
+ commands.push(command);
+ if (command === "git branch --show-current") {
+ return Promise.resolve({
+ exitCode: 0,
+ stderr: "",
+ stdout: "work/issue-5\n",
+ });
+ }
+ if (command === "git status --porcelain=v1") {
+ statusCalls += 1;
+ return Promise.resolve({
+ exitCode: 0,
+ stderr: "",
+ stdout: statusCalls === 1 ? " M src/gitea.ts\n" : "",
+ });
+ }
+ if (command === "git diff --no-ext-diff --binary") {
+ return Promise.resolve({
+ exitCode: 0,
+ stderr: "",
+ stdout: "diff --git ...",
+ });
+ }
+ if (command === "git rev-parse HEAD") {
+ return Promise.resolve({
+ exitCode: 0,
+ stderr: "",
+ stdout: "abc123\n",
+ });
+ }
+ if (command.includes("git status --porcelain=v1")) {
+ return Promise.resolve({ exitCode: 0, stderr: "", stdout: "" });
+ }
+ return Promise.resolve({ exitCode: 0, stderr: "", stdout: "" });
+ },
+ };
+};
+
+describe("Gitea lifecycle adapter", () => {
+ test("inspects, commits, pushes, and creates an open PR through mocked boundaries", async () => {
+ const runner = makeRunner();
+ const pullRequests: unknown[] = [];
+ const transport: GiteaTransport = {
+ createPullRequest(input) {
+ pullRequests.push(input);
+ return Promise.resolve({
+ base: { ref: input.base },
+ head: { ref: input.head },
+ htmlUrl: "https://git.openputer.com/puter/zopu-code/pulls/5",
+ number: 5,
+ state: "open",
+ });
+ },
+ getRepository() {
+ return Promise.resolve(repository);
+ },
+ };
+
+ const result = await runPostRunGiteaLifecycle({
+ baseBranch: "main",
+ body: "Generated by the verified project run.",
+ issueNumber: 5,
+ issueTitle: "Publish verified changes",
+ repositoryPath: "puter/zopu-code",
+ runner,
+ transport,
+ verification: "passed",
+ workspace: "/workspace",
+ });
+
+ expect(result).toMatchObject({
+ branch: "work/issue-5",
+ commitSha: "abc123",
+ pullRequest: {
+ baseBranch: "main",
+ branch: "work/issue-5",
+ number: 5,
+ status: "open",
+ },
+ status: "pull_request_open",
+ });
+ expect(runner.commands).toEqual([
+ "git branch --show-current",
+ "git status --porcelain=v1",
+ "git diff --no-ext-diff --binary",
+ "git add --all && git commit -m 'feat(issue-5): Publish verified changes'",
+ "git rev-parse HEAD",
+ "git status --porcelain=v1",
+ "git push 'ssh://git@git.openputer.com:2222/puter/zopu-code.git' HEAD:'work/issue-5'",
+ ]);
+ expect(pullRequests).toHaveLength(1);
+ });
+
+ test("mocks the HTTP Gitea boundary without calling the network", async () => {
+ const requests: Request[] = [];
+ const transport = createGiteaHttpTransport({
+ baseUrl: "https://git.openputer.com",
+ fetch: (input, init) => {
+ const request = new Request(String(input), init);
+ requests.push(request);
+ return Promise.resolve(
+ new Response(
+ request.method === "GET"
+ ? JSON.stringify(repository)
+ : JSON.stringify({
+ base: { ref: "main" },
+ head: { ref: "work/issue-5" },
+ html_url: "https://git.openputer.com/puter/zopu-code/pulls/5",
+ number: 5,
+ state: "open",
+ }),
+ { headers: { "content-type": "application/json" } }
+ )
+ );
+ },
+ token: "scoped-test-token",
+ });
+
+ await transport.getRepository("puter/zopu-code");
+ await transport.createPullRequest({
+ base: "main",
+ body: "body",
+ head: "work/issue-5",
+ repositoryPath: "puter/zopu-code",
+ title: "title",
+ });
+
+ expect(requests).toHaveLength(2);
+ expect(requests[0]?.url).toBe(
+ "https://git.openputer.com/api/v1/repos/puter/zopu-code"
+ );
+ expect(requests[1]?.headers.get("authorization")).toBe(
+ "token scoped-test-token"
+ );
+ });
+});
diff --git a/packages/agents/src/git/gitea.ts b/packages/agents/src/git/gitea.ts
new file mode 100644
index 0000000..18eefaf
--- /dev/null
+++ b/packages/agents/src/git/gitea.ts
@@ -0,0 +1,307 @@
+import {
+ decideGitLifecycle,
+ GitLifecycleError,
+ inspectGitWorkspace,
+ makeCommitMessage,
+ validatePullRequestMetadata,
+} from "@code/primitives/git";
+import type {
+ GitLifecycleDecisionResult,
+ GitLifecycleResult,
+ GitWorkspaceInspection,
+} from "@code/primitives/git";
+import { Effect } from "effect";
+
+export interface GitCommandResult {
+ readonly exitCode: number;
+ readonly stderr: string;
+ readonly stdout: string;
+}
+
+export interface GitCommandRunner {
+ readonly run: (
+ command: string,
+ options?: {
+ readonly cwd?: string;
+ readonly env?: Record;
+ }
+ ) => Promise;
+}
+
+export interface GiteaRepository {
+ readonly cloneUrl: string;
+ readonly defaultBranch: string;
+ readonly htmlUrl: string;
+ readonly name: string;
+ readonly sshUrl: string;
+}
+
+export interface GiteaPullRequest {
+ readonly base: { readonly ref: string };
+ readonly head: { readonly ref: string };
+ readonly htmlUrl: string;
+ readonly number: number;
+ readonly state: "open" | "closed" | "merged";
+}
+
+export interface GiteaTransport {
+ readonly createPullRequest: (input: {
+ readonly base: string;
+ readonly body: string;
+ readonly head: string;
+ readonly repositoryPath: string;
+ readonly title: string;
+ }) => Promise;
+ readonly getRepository: (repositoryPath: string) => Promise;
+}
+
+export interface GiteaHttpTransportOptions {
+ readonly baseUrl: string;
+ readonly fetch?: (
+ input: string | URL | Request,
+ init?: RequestInit
+ ) => Promise;
+ readonly token: string;
+}
+
+const shellQuote = (value: string): string =>
+ `'${value.replaceAll("'", `'"'"'`)}'`;
+
+const providerFailure = (
+ message: string,
+ reason: "CommandFailed" | "RemoteRejected"
+) => new GitLifecycleError({ message, reason });
+
+const runChecked = async (
+ runner: GitCommandRunner,
+ command: string,
+ options?: Parameters[1]
+): Promise => {
+ let result: GitCommandResult;
+ try {
+ result = await runner.run(command, options);
+ } catch (error) {
+ throw new GitLifecycleError({
+ message: `Git command could not run: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ reason: "CommandFailed",
+ });
+ }
+ if (result.exitCode !== 0) {
+ throw providerFailure(
+ result.stderr.trim() || `Git command failed: ${command}`,
+ command.startsWith("git push") ? "RemoteRejected" : "CommandFailed"
+ );
+ }
+ return result.stdout;
+};
+
+const toHttpPath = (repositoryPath: string): string =>
+ repositoryPath
+ .split("/")
+ .map((segment) => encodeURIComponent(segment))
+ .join("/");
+
+const requestJson = async (
+ options: GiteaHttpTransportOptions,
+ path: string,
+ init?: RequestInit
+): Promise => {
+ let response: Response;
+ try {
+ response = await (options.fetch ?? globalThis.fetch)(
+ `${options.baseUrl.replace(/\/$/u, "")}${path}`,
+ {
+ ...init,
+ headers: {
+ Accept: "application/json",
+ Authorization: `token ${options.token}`,
+ "Content-Type": "application/json",
+ ...init?.headers,
+ },
+ }
+ );
+ } catch (error) {
+ throw new GitLifecycleError({
+ message: `Gitea request could not run: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ reason: "RequestFailed",
+ });
+ }
+ if (!response.ok) {
+ const detail = await response.text();
+ throw new GitLifecycleError({
+ message: `Gitea request failed (${response.status}): ${detail}`,
+ reason:
+ response.status === 401 || response.status === 403
+ ? "Authentication"
+ : "RequestFailed",
+ });
+ }
+ return (await response.json()) as T;
+};
+
+export const createGiteaHttpTransport = (
+ options: GiteaHttpTransportOptions
+): GiteaTransport => ({
+ async createPullRequest(input) {
+ const response = await requestJson<{
+ base: { ref: string };
+ head: { ref: string };
+ html_url: string;
+ number: number;
+ state: "open" | "closed" | "merged";
+ }>(options, `/api/v1/repos/${toHttpPath(input.repositoryPath)}/pulls`, {
+ body: JSON.stringify({
+ base: input.base,
+ body: input.body,
+ head: input.head,
+ title: input.title,
+ }),
+ method: "POST",
+ });
+ return {
+ base: response.base,
+ head: response.head,
+ htmlUrl: response.html_url,
+ number: response.number,
+ state: response.state,
+ };
+ },
+ async getRepository(repositoryPath) {
+ const response = await requestJson<{
+ clone_url: string;
+ default_branch: string;
+ html_url: string;
+ name: string;
+ ssh_url: string;
+ }>(options, `/api/v1/repos/${toHttpPath(repositoryPath)}`);
+ return {
+ cloneUrl: response.clone_url,
+ defaultBranch: response.default_branch,
+ htmlUrl: response.html_url,
+ name: response.name,
+ sshUrl: response.ssh_url,
+ };
+ },
+});
+
+export interface RunPostRunGiteaLifecycleInput {
+ readonly baseBranch: string;
+ readonly body: string;
+ readonly commitMessage?: string;
+ readonly issueNumber: number;
+ readonly issueTitle: string;
+ readonly repositoryPath: string;
+ readonly runner: GitCommandRunner;
+ readonly title?: string;
+ readonly transport: GiteaTransport;
+ readonly verification: "passed" | "failed" | "not-run";
+ readonly workspace: string;
+}
+
+const inspect = async (
+ input: RunPostRunGiteaLifecycleInput
+): Promise => {
+ const [branch, status, diff] = await Promise.all([
+ runChecked(input.runner, "git branch --show-current", {
+ cwd: input.workspace,
+ }),
+ runChecked(input.runner, "git status --porcelain=v1", {
+ cwd: input.workspace,
+ }),
+ runChecked(input.runner, "git diff --no-ext-diff --binary", {
+ cwd: input.workspace,
+ }),
+ ]);
+ return await Effect.runPromise(
+ inspectGitWorkspace({
+ branch: branch.trim(),
+ diff,
+ status,
+ })
+ );
+};
+
+export const runPostRunGiteaLifecycle = async (
+ input: RunPostRunGiteaLifecycleInput
+): Promise => {
+ const inspection = await inspect(input);
+ const initialDecision = (await Effect.runPromise(
+ decideGitLifecycle({
+ baseBranch: input.baseBranch,
+ inspection,
+ pushed: false,
+ verification: input.verification,
+ })
+ )) as GitLifecycleDecisionResult;
+
+ if (initialDecision.decision === "finish") {
+ return {
+ baseBranch: input.baseBranch as GitLifecycleResult["baseBranch"],
+ branch: inspection.branch,
+ commitSha: undefined,
+ pullRequest: undefined,
+ status: "no_changes",
+ };
+ }
+
+ const repository = await input.transport.getRepository(input.repositoryPath);
+
+ await runChecked(
+ input.runner,
+ `git add --all && git commit -m ${shellQuote(
+ input.commitMessage ??
+ makeCommitMessage(input.issueNumber, input.issueTitle)
+ )}`,
+ { cwd: input.workspace }
+ );
+ const commitOutput = await runChecked(input.runner, "git rev-parse HEAD", {
+ cwd: input.workspace,
+ });
+ const commitSha = commitOutput.trim();
+ const cleanStatus = await runChecked(
+ input.runner,
+ "git status --porcelain=v1",
+ { cwd: input.workspace }
+ );
+ if (cleanStatus.trim().length > 0) {
+ throw providerFailure(
+ "Git workspace remained dirty after commit",
+ "CommandFailed"
+ );
+ }
+
+ await runChecked(
+ input.runner,
+ `git push ${shellQuote(repository.sshUrl)} HEAD:${shellQuote(inspection.branch)}`,
+ { cwd: input.workspace }
+ );
+
+ const pullRequest = await input.transport.createPullRequest({
+ base: input.baseBranch,
+ body: input.body,
+ head: inspection.branch,
+ repositoryPath: input.repositoryPath,
+ title: input.title ?? `Issue #${input.issueNumber}: ${input.issueTitle}`,
+ });
+ const metadata = await Effect.runPromise(
+ validatePullRequestMetadata({
+ baseBranch: pullRequest.base.ref,
+ branch: pullRequest.head.ref,
+ number: pullRequest.number,
+ status: pullRequest.state,
+ url: pullRequest.htmlUrl,
+ })
+ );
+
+ return {
+ baseBranch: input.baseBranch as GitLifecycleResult["baseBranch"],
+ branch: inspection.branch,
+ commitSha,
+ pullRequest: metadata,
+ status: "pull_request_open",
+ };
+};
diff --git a/packages/backend/convex/agentWorkspace.ts b/packages/backend/convex/agentWorkspace.ts
index 3a39e1b..2dc2690 100644
--- a/packages/backend/convex/agentWorkspace.ts
+++ b/packages/backend/convex/agentWorkspace.ts
@@ -29,11 +29,21 @@ export const get = query({
if (!project) {
throw new ConvexError("Project not found");
}
- const artifacts = await ctx.db
- .query("projectArtifacts")
- .withIndex("by_project", (q) => q.eq("projectId", project._id))
- .take(25);
- return { artifacts, issue, project };
+ const [artifacts, source, run] = await Promise.all([
+ ctx.db
+ .query("projectArtifacts")
+ .withIndex("by_project", (q) => q.eq("projectId", project._id))
+ .take(25),
+ ctx.db
+ .query("projectSources")
+ .withIndex("by_project", (q) => q.eq("projectId", project._id))
+ .first(),
+ ctx.db
+ .query("projectWorkRuns")
+ .withIndex("by_issue", (q) => q.eq("issueId", issue._id))
+ .unique(),
+ ]);
+ return { artifacts, issue, project, run, source };
},
});
@@ -172,3 +182,111 @@ export const setStatus = mutation({
});
},
});
+
+const lifecycleStatus = v.union(
+ v.literal("no_changes"),
+ v.literal("committed"),
+ v.literal("pushed"),
+ v.literal("pull_request_open"),
+ v.literal("failed")
+);
+
+const pullRequestMetadata = v.object({
+ baseBranch: v.string(),
+ branch: v.string(),
+ number: v.number(),
+ status: v.union(v.literal("open"), v.literal("closed"), v.literal("merged")),
+ url: v.string(),
+});
+
+export const recordGiteaLifecycle = mutation({
+ args: {
+ baseBranch: v.string(),
+ branch: v.string(),
+ commitSha: v.optional(v.string()),
+ error: v.optional(v.string()),
+ issueId: v.id("projectIssues"),
+ pullRequest: v.optional(pullRequestMetadata),
+ status: lifecycleStatus,
+ token: v.string(),
+ },
+ handler: async (ctx, args) => {
+ requireAgent(args.token);
+ const issue = await ctx.db.get("projectIssues", args.issueId);
+ if (!issue) {
+ throw new ConvexError("Issue not found");
+ }
+ const run = await ctx.db
+ .query("projectWorkRuns")
+ .withIndex("by_issue", (q) => q.eq("issueId", issue._id))
+ .unique();
+ if (!run) {
+ throw new ConvexError("Agent work run not found");
+ }
+
+ const events = await ctx.db
+ .query("projectEvents")
+ .withIndex("by_issue_and_createdAt", (q) => q.eq("issueId", issue._id))
+ .order("desc")
+ .take(100);
+ const existing = events.find((event) => {
+ if (!event.kind.startsWith("gitea.")) {
+ return false;
+ }
+ const data = event.data as {
+ branch?: unknown;
+ commitSha?: unknown;
+ status?: unknown;
+ };
+ return (
+ data.branch === args.branch &&
+ data.commitSha === args.commitSha &&
+ data.status === args.status
+ );
+ });
+ if (existing) {
+ return existing.data;
+ }
+
+ const timestamp = Date.now();
+ const data = {
+ baseBranch: args.baseBranch,
+ branch: args.branch,
+ ...(args.commitSha === undefined ? {} : { commitSha: args.commitSha }),
+ ...(args.error === undefined ? {} : { error: args.error.slice(0, 2000) }),
+ ...(args.pullRequest === undefined
+ ? {}
+ : { pullRequest: args.pullRequest }),
+ status: args.status,
+ };
+ const artifact = await ctx.db
+ .query("projectArtifacts")
+ .withIndex("by_project_and_path", (q) =>
+ q.eq("projectId", issue.projectId).eq("path", "artifacts.md")
+ )
+ .unique();
+ if (artifact) {
+ const pullRequestLine = args.pullRequest
+ ? `- Pull request: [#${args.pullRequest.number}](${args.pullRequest.url}) (${args.pullRequest.status})\n`
+ : "";
+ const commitLine = args.commitSha ? `- Commit: ${args.commitSha}\n` : "";
+ await ctx.db.patch("projectArtifacts", artifact._id, {
+ content: `${artifact.content}\n## Git lifecycle\n\n- Status: ${args.status}\n- Branch: ${args.branch}\n- Base branch: ${args.baseBranch}\n${commitLine}${pullRequestLine}${args.error ? `- Error: ${args.error.slice(0, 1000)}\n` : ""}`,
+ revision: artifact.revision + 1,
+ updatedAt: timestamp,
+ });
+ }
+ await ctx.db.insert("projectEvents", {
+ createdAt: timestamp,
+ data,
+ issueId: issue._id,
+ kind:
+ args.pullRequest === undefined
+ ? "gitea.lifecycle.updated"
+ : "gitea.pull_request.created",
+ projectId: issue.projectId,
+ runId: run._id,
+ });
+ return data;
+ },
+});
diff --git a/packages/backend/convex/projectEvents.test.ts b/packages/backend/convex/projectEvents.test.ts
index d9695ce..5ddb981 100644
--- a/packages/backend/convex/projectEvents.test.ts
+++ b/packages/backend/convex/projectEvents.test.ts
@@ -217,4 +217,63 @@ describe("projectEvents", () => {
const events = await eventsForProject(t, projectId);
expect(events.some((e) => e.kind === "agent.completed")).toBe(false);
});
+
+ test("Gitea lifecycle persists PR metadata in an event and artifact", async () => {
+ const t = convexTest({ schema, modules });
+ const projectId = await createTestProject(t);
+ const issueId = await t
+ .withIdentity(identityA)
+ .mutation(api.projectIssues.create, {
+ body: "A representative issue body long enough to pass validation",
+ projectId,
+ title: "Representative issue",
+ });
+ await t.mutation(api.agentWorkspace.ensureRun, {
+ daemonId: "daemon-1",
+ issueId,
+ token: FLUE_DB_TOKEN,
+ });
+
+ await t.mutation(api.agentWorkspace.recordGiteaLifecycle, {
+ baseBranch: "main",
+ branch: "work/issue-1",
+ commitSha: "abc123",
+ issueId,
+ pullRequest: {
+ baseBranch: "main",
+ branch: "work/issue-1",
+ number: 5,
+ status: "open",
+ url: "https://git.openputer.com/puter/zopu-code/pulls/5",
+ },
+ status: "pull_request_open",
+ token: FLUE_DB_TOKEN,
+ });
+
+ const events = await eventsForProject(t, projectId);
+ const event = events.find(
+ (item) => item.kind === "gitea.pull_request.created"
+ );
+ expect(event?.data).toMatchObject({
+ branch: "work/issue-1",
+ commitSha: "abc123",
+ pullRequest: {
+ number: 5,
+ status: "open",
+ },
+ status: "pull_request_open",
+ });
+
+ const artifacts = await t.query((ctx) =>
+ ctx.db
+ .query("projectArtifacts")
+ .withIndex("by_project_and_path", (q) =>
+ q.eq("projectId", projectId).eq("path", "artifacts.md")
+ )
+ .unique()
+ );
+ expect(artifacts?.content).toContain(
+ "https://git.openputer.com/puter/zopu-code/pulls/5"
+ );
+ });
});
diff --git a/packages/backend/convex/projectIssues.ts b/packages/backend/convex/projectIssues.ts
index 0ac5a54..f1438f2 100644
--- a/packages/backend/convex/projectIssues.ts
+++ b/packages/backend/convex/projectIssues.ts
@@ -130,3 +130,21 @@ export const list = query({
.take(100);
},
});
+
+export const events = query({
+ args: { projectId: v.id("projects") },
+ handler: async (ctx, args) => {
+ try {
+ await requireProjectMember(ctx, args.projectId);
+ } catch {
+ return [];
+ }
+ return await ctx.db
+ .query("projectEvents")
+ .withIndex("by_project_and_createdAt", (q) =>
+ q.eq("projectId", args.projectId)
+ )
+ .order("desc")
+ .take(100);
+ },
+});
diff --git a/packages/env/src/agent.ts b/packages/env/src/agent.ts
index 8651cc6..8710d78 100644
--- a/packages/env/src/agent.ts
+++ b/packages/env/src/agent.ts
@@ -11,6 +11,8 @@ const agentEnvSchema = z.object({
CONVEX_URL: z.url(),
DAEMON_ID: z.string().min(1),
FLUE_DB_TOKEN: z.string().min(1),
+ GITEA_TOKEN: z.string().min(1).optional(),
+ GITEA_URL: z.url().default("https://git.openputer.com"),
});
export type AgentEnv = z.infer;
diff --git a/packages/primitives/package.json b/packages/primitives/package.json
index 7b3f661..ed1315b 100644
--- a/packages/primitives/package.json
+++ b/packages/primitives/package.json
@@ -6,6 +6,7 @@
"exports": {
".": "./src/index.ts",
"./agent-os": "./src/agent-os.ts",
+ "./git": "./src/git.ts",
"./project": "./src/project.ts",
"./signal": "./src/signal.ts"
},
diff --git a/packages/primitives/src/git.test.ts b/packages/primitives/src/git.test.ts
new file mode 100644
index 0000000..cfa42c9
--- /dev/null
+++ b/packages/primitives/src/git.test.ts
@@ -0,0 +1,145 @@
+import { Effect, Exit } from "effect";
+import { describe, expect, test } from "vitest";
+
+import {
+ decideGitLifecycle,
+ inspectGitWorkspace,
+ makeCommitMessage,
+ validatePullRequestMetadata,
+} from "./git";
+
+const inspectionInput = {
+ branch: "work/issue-5",
+ diff: "diff --git a/file.ts b/file.ts",
+ status: " M file.ts",
+};
+
+const inspection = await Effect.runPromise(
+ inspectGitWorkspace(inspectionInput)
+);
+
+describe("Git lifecycle primitives", () => {
+ test("parses workspace status and treats a diff as publishable changes", () => {
+ expect(inspection).toMatchObject({
+ branch: "work/issue-5",
+ hasChanges: true,
+ status: [{ index: " ", path: "file.ts", worktree: "M" }],
+ });
+ });
+
+ test("requires verification before any publish action", async () => {
+ const result = await Effect.runPromiseExit(
+ decideGitLifecycle({
+ baseBranch: "main",
+ inspection,
+ pushed: false,
+ verification: "not-run",
+ })
+ );
+
+ expect(Exit.isFailure(result)).toBe(true);
+ if (Exit.isFailure(result)) {
+ const failure = result.cause as unknown as {
+ readonly reasons: readonly [
+ { readonly error: { readonly reason: string } },
+ ];
+ };
+ expect(failure.reasons[0]?.error.reason).toBe("VerificationRequired");
+ }
+ });
+
+ test("never publishes the default branch", async () => {
+ const result = await Effect.runPromiseExit(
+ decideGitLifecycle({
+ baseBranch: "work/issue-5",
+ inspection,
+ pushed: false,
+ verification: "passed",
+ })
+ );
+
+ expect(Exit.isFailure(result)).toBe(true);
+ if (Exit.isFailure(result)) {
+ const failure = result.cause as unknown as {
+ readonly reasons: readonly [
+ { readonly error: { readonly reason: string } },
+ ];
+ };
+ expect(failure.reasons[0]?.error.reason).toBe("InvalidBranch");
+ }
+ });
+
+ test("moves verified changes through commit, push, PR, then manual merge", async () => {
+ const commit = await Effect.runPromise(
+ decideGitLifecycle({
+ baseBranch: "main",
+ inspection,
+ pushed: false,
+ verification: "passed",
+ })
+ );
+ expect(commit).toEqual({ decision: "commit", status: "committed" });
+
+ const push = await Effect.runPromise(
+ decideGitLifecycle({
+ baseBranch: "main",
+ commitSha: "abc123",
+ inspection: { ...inspection, hasChanges: false, status: [] },
+ pushed: false,
+ verification: "passed",
+ })
+ );
+ expect(push).toEqual({ decision: "push", status: "pushed" });
+
+ const pr = await Effect.runPromise(
+ decideGitLifecycle({
+ baseBranch: "main",
+ commitSha: "abc123",
+ inspection: { ...inspection, hasChanges: false, status: [] },
+ pushed: true,
+ verification: "passed",
+ })
+ );
+ expect(pr).toEqual({
+ decision: "create_pull_request",
+ status: "pull_request_open",
+ });
+
+ const merge = await Effect.runPromise(
+ decideGitLifecycle({
+ baseBranch: "main",
+ commitSha: "abc123",
+ inspection: { ...inspection, hasChanges: false, status: [] },
+ pullRequest: {
+ baseBranch: "main",
+ branch: "work/issue-5",
+ number: 5,
+ status: "open",
+ url: "https://git.openputer.com/puter/zopu-code/pulls/5",
+ },
+ pushed: true,
+ verification: "passed",
+ })
+ );
+ expect(merge).toEqual({
+ decision: "manual_merge",
+ status: "pull_request_open",
+ });
+ });
+
+ test("validates PR metadata and keeps merge manual", async () => {
+ const metadata = await Effect.runPromise(
+ validatePullRequestMetadata({
+ baseBranch: "main",
+ branch: "work/issue-5",
+ number: 5,
+ status: "open",
+ url: "https://git.openputer.com/puter/zopu-code/pulls/5",
+ })
+ );
+ expect(metadata.status).toBe("open");
+ expect(makeCommitMessage(5, "Ship Git lifecycle")).toBe(
+ "feat(issue-5): Ship Git lifecycle"
+ );
+ });
+});
diff --git a/packages/primitives/src/git.ts b/packages/primitives/src/git.ts
new file mode 100644
index 0000000..9cfda4e
--- /dev/null
+++ b/packages/primitives/src/git.ts
@@ -0,0 +1,250 @@
+import { Effect, Schema } from "effect";
+
+const MeaningfulString = Schema.String.check(
+ Schema.makeFilter((value) => value.trim().length > 0, {
+ expected: "a non-empty string",
+ })
+);
+
+export const GitBranchName = MeaningfulString.pipe(
+ Schema.check(
+ Schema.makeFilter(
+ (value) =>
+ !value.startsWith("-") &&
+ !value.includes("..") &&
+ !value.includes(" ") &&
+ !value.includes("~") &&
+ !value.includes("^") &&
+ !value.includes(":") &&
+ !value.includes("\\"),
+ { expected: "a valid Git branch name" }
+ )
+ )
+);
+export type GitBranchName = typeof GitBranchName.Type;
+
+export const GitStatusEntry = Schema.Struct({
+ index: Schema.String,
+ path: MeaningfulString,
+ raw: Schema.String,
+ worktree: Schema.String,
+});
+export type GitStatusEntry = typeof GitStatusEntry.Type;
+
+export const GitWorkspaceInspection = Schema.Struct({
+ branch: GitBranchName,
+ diff: Schema.String,
+ hasChanges: Schema.Boolean,
+ status: Schema.Array(GitStatusEntry),
+});
+export type GitWorkspaceInspection = typeof GitWorkspaceInspection.Type;
+
+export const GitVerification = Schema.Literals(["passed", "failed", "not-run"]);
+export type GitVerification = typeof GitVerification.Type;
+
+export const PullRequestStatus = Schema.Literals(["open", "closed", "merged"]);
+export type PullRequestStatus = typeof PullRequestStatus.Type;
+
+export const PullRequestMetadata = Schema.Struct({
+ baseBranch: GitBranchName,
+ branch: GitBranchName,
+ number: Schema.Int.check(Schema.isGreaterThan(0)),
+ status: PullRequestStatus,
+ url: Schema.String.check(
+ Schema.makeFilter(
+ (value) => {
+ try {
+ return new URL(value).protocol.startsWith("http");
+ } catch {
+ return false;
+ }
+ },
+ { expected: "a valid HTTP pull request URL" }
+ )
+ ),
+});
+export type PullRequestMetadata = typeof PullRequestMetadata.Type;
+
+export const GitLifecycleStatus = Schema.Literals([
+ "no_changes",
+ "committed",
+ "pushed",
+ "pull_request_open",
+ "failed",
+]);
+export type GitLifecycleStatus = typeof GitLifecycleStatus.Type;
+
+export const GitLifecycleResult = Schema.Struct({
+ baseBranch: GitBranchName,
+ branch: GitBranchName,
+ commitSha: Schema.optional(MeaningfulString),
+ pullRequest: Schema.optional(PullRequestMetadata),
+ status: GitLifecycleStatus,
+});
+export type GitLifecycleResult = typeof GitLifecycleResult.Type;
+
+export const GitLifecycleDecision = Schema.Literals([
+ "finish",
+ "commit",
+ "push",
+ "create_pull_request",
+ "manual_merge",
+]);
+export type GitLifecycleDecision = typeof GitLifecycleDecision.Type;
+
+export const GitLifecycleDecisionInput = Schema.Struct({
+ baseBranch: GitBranchName,
+ commitSha: Schema.optional(MeaningfulString),
+ inspection: GitWorkspaceInspection,
+ pullRequest: Schema.optional(PullRequestMetadata),
+ pushed: Schema.Boolean,
+ verification: GitVerification,
+});
+export type GitLifecycleDecisionInput = typeof GitLifecycleDecisionInput.Type;
+
+export const GitLifecycleDecisionResult = Schema.Struct({
+ decision: GitLifecycleDecision,
+ status: GitLifecycleStatus,
+});
+export type GitLifecycleDecisionResult = typeof GitLifecycleDecisionResult.Type;
+
+export const GitLifecycleErrorReason = Schema.Literals([
+ "Authentication",
+ "CommandFailed",
+ "InvalidInput",
+ "InvalidBranch",
+ "NoChanges",
+ "InvalidPullRequest",
+ "RemoteRejected",
+ "RequestFailed",
+ "VerificationRequired",
+]);
+export type GitLifecycleErrorReason = typeof GitLifecycleErrorReason.Type;
+
+export class GitLifecycleError extends Schema.TaggedErrorClass()(
+ "GitLifecycleError",
+ {
+ message: Schema.String,
+ reason: GitLifecycleErrorReason,
+ }
+) {}
+
+const invalidInput = (message: string) =>
+ new GitLifecycleError({ message, reason: "InvalidInput" });
+
+export const parseGitStatus = (rawStatus: string): GitStatusEntry[] =>
+ rawStatus
+ .split("\n")
+ .filter((line) => line.length > 0)
+ .map((raw) => ({
+ index: raw.slice(0, 1),
+ path: raw.slice(3).trim(),
+ raw,
+ worktree: raw.slice(1, 2),
+ }));
+
+export const inspectGitWorkspace = Effect.fn("Git.inspectWorkspace")(
+ function* inspectGitWorkspace(input: unknown) {
+ const decoded = yield* Schema.decodeUnknownEffect(
+ Schema.Struct({
+ branch: GitBranchName,
+ diff: Schema.String,
+ status: Schema.String,
+ })
+ )(input).pipe(Effect.mapError((cause) => invalidInput(cause.message)));
+
+ const status = parseGitStatus(decoded.status);
+ return {
+ branch: decoded.branch,
+ diff: decoded.diff,
+ hasChanges: status.length > 0 || decoded.diff.length > 0,
+ status,
+ } satisfies GitWorkspaceInspection;
+ }
+);
+
+export const decideGitLifecycle = Effect.fn("Git.decideLifecycle")(
+ function* decideGitLifecycle(input: unknown) {
+ const decoded = yield* Schema.decodeUnknownEffect(
+ GitLifecycleDecisionInput
+ )(input).pipe(Effect.mapError((cause) => invalidInput(cause.message)));
+
+ if (decoded.inspection.branch === decoded.baseBranch) {
+ return yield* Effect.fail(
+ new GitLifecycleError({
+ message: `Refusing to publish the default branch ${decoded.baseBranch}`,
+ reason: "InvalidBranch",
+ })
+ );
+ }
+
+ if (decoded.verification !== "passed") {
+ return yield* Effect.fail(
+ new GitLifecycleError({
+ message: "Verified changes are required before publishing",
+ reason: "VerificationRequired",
+ })
+ );
+ }
+
+ if (!decoded.inspection.hasChanges && decoded.commitSha === undefined) {
+ return {
+ decision: "finish" as const,
+ status: "no_changes" as const,
+ } satisfies GitLifecycleDecisionResult;
+ }
+
+ if (decoded.commitSha === undefined) {
+ return {
+ decision: "commit" as const,
+ status: "committed" as const,
+ } satisfies GitLifecycleDecisionResult;
+ }
+
+ if (!decoded.pushed) {
+ return {
+ decision: "push" as const,
+ status: "pushed" as const,
+ } satisfies GitLifecycleDecisionResult;
+ }
+
+ if (decoded.pullRequest === undefined) {
+ return {
+ decision: "create_pull_request" as const,
+ status: "pull_request_open" as const,
+ } satisfies GitLifecycleDecisionResult;
+ }
+
+ return {
+ decision: "manual_merge" as const,
+ status: "pull_request_open" as const,
+ } satisfies GitLifecycleDecisionResult;
+ }
+);
+
+export const validatePullRequestMetadata = Effect.fn(
+ "Git.validatePullRequestMetadata"
+)(function* validatePullRequestMetadata(input: unknown) {
+ const metadata = yield* Schema.decodeUnknownEffect(PullRequestMetadata)(
+ input
+ ).pipe(Effect.mapError((cause) => invalidInput(cause.message)));
+
+ if (metadata.branch === metadata.baseBranch) {
+ return yield* Effect.fail(
+ new GitLifecycleError({
+ message: "A pull request must target a different branch",
+ reason: "InvalidPullRequest",
+ })
+ );
+ }
+
+ return metadata;
+});
+
+export const makeCommitMessage = (
+ issueNumber: number,
+ title: string
+): string => {
+ const normalizedTitle = title.replaceAll(/\s+/gu, " ").trim();
+ return `feat(issue-${issueNumber}): ${normalizedTitle}`.slice(0, 120);
+};
diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts
index d252f41..1444dce 100644
--- a/packages/primitives/src/index.ts
+++ b/packages/primitives/src/index.ts
@@ -1,4 +1,5 @@
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
export * from "./agent-os";
+export * from "./git";
export * from "./project";
export * from "./signal";