From 7caad057db23ad1183483345d1b07d3971e850be Mon Sep 17 00:00:00 2001 From: -Puter <22245429+puterhimself@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:19:35 +0530 Subject: [PATCH] feat: prepare issue workspaces from connected repositories --- bun.lock | 5 + packages/agents/package.json | 2 + packages/agents/src/agents/project-manager.ts | 8 +- packages/agents/src/sandboxes/agent-os.ts | 75 ++- packages/backend/convex/agentWorkspace.ts | 112 ++++- packages/backend/convex/projectEvents.test.ts | 8 +- packages/backend/convex/schema.ts | 4 + packages/primitives/package.json | 2 + packages/primitives/src/agent-os.ts | 25 +- packages/primitives/src/index.ts | 1 + .../primitives/src/project-workspace.test.ts | 141 ++++++ packages/primitives/src/project-workspace.ts | 436 ++++++++++++++++++ 12 files changed, 794 insertions(+), 25 deletions(-) create mode 100644 packages/primitives/src/project-workspace.test.ts create mode 100644 packages/primitives/src/project-workspace.ts diff --git a/bun.lock b/bun.lock index e142821..4b1fd8c 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", }, @@ -232,6 +234,7 @@ "name": "@code/primitives", "version": "0.0.0", "dependencies": { + "@agentos-software/git": "0.3.3", "@rivet-dev/agentos": "catalog:", "effect": "catalog:", }, @@ -316,6 +319,8 @@ "@agentos-software/gawk": ["@agentos-software/gawk@0.3.4", "", {}, "sha512-NlU6nGxoqIUc1I2zdBHFwH04gE0tBygP2FcBO12VBptty7lbgq1CNLk909jIcSNFEwlm3VRPHeZNkbdVKZ2Ujg=="], + "@agentos-software/git": ["@agentos-software/git@0.3.3", "", {}, "sha512-6hCVv4P9eZ5JiPUcLv7y2I5rgxgKvXfdNRvyXCHecMyz6oxE5T0VW8WkSMuwmGTQFUhIIvPn7DpyNDbCaGMpgw=="], + "@agentos-software/grep": ["@agentos-software/grep@0.3.4", "", {}, "sha512-Bta2Ljl+kCX/3Bjg06Q9N9LPRcf13S92lHRaYG+CeGVDXabIIgkHLUGIQlI1OKuIr7IRAktjgelUAuJnxGFvvw=="], "@agentos-software/gzip": ["@agentos-software/gzip@0.3.4", "", {}, "sha512-l7Y/Vwiwsqgna68yYwdNCnUBPGBg5zdmtjEFeG3hnDDFLe/02h17q0gUIqJmDBIAy1q9GoizqcFi7zXO2xygKg=="], 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/agents/project-manager.ts b/packages/agents/src/agents/project-manager.ts index 10316da..c5de1ab 100644 --- a/packages/agents/src/agents/project-manager.ts +++ b/packages/agents/src/agents/project-manager.ts @@ -14,15 +14,15 @@ export default defineAgent(({ env, id }) => { const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env); return { - cwd: "/workspace", + cwd: "/workspace/repository", description, instructions: `You are the issue-scoped project manager and coding agent. -Start every run by calling lookup_issue_context, reading issue.md, project.md, business.md, design.md, agent.md, work.md, steps.md, artifacts.md, signals.md, agent-manager.md, context.md, and card.md from the AgentOS workspace. Call report_work_status with working before editing. +Start every run by calling lookup_issue_context, reading /workspace/control/issue.md, the canonical context files under /workspace/control/context, and the operational artifacts under /workspace/control/artifacts. Call report_work_status with working before editing. -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. +Work only on the bound issue. The repository checkout is the current working directory; inspect existing source 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 install, edit, test, or scenario command 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 operational files under /workspace/control/artifacts 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.`, model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`, sandbox: agentOs(env), tools: createProjectTools(id, env), diff --git a/packages/agents/src/sandboxes/agent-os.ts b/packages/agents/src/sandboxes/agent-os.ts index b5cf64d..a3a973f 100644 --- a/packages/agents/src/sandboxes/agent-os.ts +++ b/packages/agents/src/sandboxes/agent-os.ts @@ -1,9 +1,14 @@ import { api } from "@code/backend/convex/_generated/api"; import type { Id } from "@code/backend/convex/_generated/dataModel"; import { parseAgentEnv } from "@code/env/agent"; +import { + decodeIssueWorkspaceResult, + makeIssueWorkspacePlan, +} from "@code/primitives/project-workspace"; import { createSandboxSessionEnv } from "@flue/runtime"; import type { FileStat, SandboxApi, SandboxFactory } from "@flue/runtime"; import { ConvexHttpClient } from "convex/browser"; +import { Effect } from "effect"; import * as v from "valibot"; const execResultSchema = v.object({ @@ -218,15 +223,69 @@ export const agentOs = ( issueId, token: env.FLUE_DB_TOKEN, }); - await sandbox.mkdir("/workspace", { recursive: true }); - await Promise.all( - context.artifacts.map((artifact) => - sandbox.writeFile(`/workspace/${artifact.path}`, artifact.content) - ) + + const plan = await Effect.runPromise( + makeIssueWorkspacePlan({ + artifacts: context.artifacts.map((artifact) => ({ + content: artifact.content, + path: artifact.path, + })), + branchName: context.run?.branchName, + checkoutPath: context.run?.checkoutPath, + contextFiles: context.contextDocuments.map((document) => ({ + content: document.content, + kind: document.kind, + path: document.path, + })), + defaultBranch: + context.run?.baseBranch ?? context.source?.defaultBranch, + issueBody: context.issue.body, + issueId: String(context.issue._id), + issueNumber: context.issue.number, + issueTitle: context.issue.title, + sourceUrl: context.run?.sourceUrl ?? context.source?.url, + }) ); - await sandbox.writeFile( - "/workspace/issue.md", - `# Issue ${context.issue.number}: ${context.issue.title}\n\n${context.issue.body}\n` + + const [controlDirectory, checkoutOperation, ...stagingOperations] = + plan.operations; + if (!controlDirectory || controlDirectory._tag !== "Mkdir") { + throw new Error( + "Issue workspace plan must start with a control directory" + ); + } + await sandbox.mkdir(controlDirectory.path, { recursive: true }); + if (!checkoutOperation || checkoutOperation._tag !== "Exec") { + throw new Error("Issue workspace plan must include a checkout command"); + } + const checkoutCommandResult = await sandbox.exec( + checkoutOperation.command, + { + cwd: checkoutOperation.cwd, + timeoutMs: checkoutOperation.timeoutMs, + } + ); + + await Promise.all( + stagingOperations + .filter((operation) => operation._tag === "Mkdir") + .map((operation) => + sandbox.mkdir(operation.path, { recursive: true }) + ) + ); + await Promise.all( + stagingOperations + .filter((operation) => operation._tag === "WriteFile") + .map((operation) => + sandbox.writeFile(operation.path, operation.content) + ) + ); + + await Effect.runPromise( + decodeIssueWorkspaceResult({ + command: checkoutCommandResult, + plan, + }) ); return createSandboxSessionEnv(sandbox, "/workspace"); }, diff --git a/packages/backend/convex/agentWorkspace.ts b/packages/backend/convex/agentWorkspace.ts index 3a39e1b..545577d 100644 --- a/packages/backend/convex/agentWorkspace.ts +++ b/packages/backend/convex/agentWorkspace.ts @@ -1,5 +1,12 @@ import { env } from "@code/env/convex"; +import { + makeIssueWorkspacePlan, + transitionWorkspaceRun, + WorkspaceStateError, + WorkspaceValidationError, +} from "@code/primitives/project-workspace"; import { ConvexError, v } from "convex/values"; +import { Effect } from "effect"; import { mutation, query } from "./_generated/server"; import { artifactPath } from "./artifactModel"; @@ -17,6 +24,37 @@ const agentStatus = v.union( v.literal("failed") ); +const workspacePlanFor = (input: { + readonly issueBody: string; + readonly issueId: string; + readonly issueNumber: number; + readonly issueTitle: string; + readonly sourceUrl: string | undefined; + readonly defaultBranch: string | undefined; +}) => { + try { + return Effect.runSync( + makeIssueWorkspacePlan({ + artifacts: [], + branchName: undefined, + checkoutPath: undefined, + contextFiles: [], + defaultBranch: input.defaultBranch, + issueBody: input.issueBody, + issueId: input.issueId, + issueNumber: input.issueNumber, + issueTitle: input.issueTitle, + sourceUrl: input.sourceUrl, + }) + ); + } catch (error) { + if (error instanceof WorkspaceValidationError) { + throw new ConvexError(error.message); + } + throw error; + } +}; + export const get = query({ args: { issueId: v.id("projectIssues"), token: v.string() }, handler: async (ctx, args) => { @@ -33,7 +71,26 @@ export const get = query({ .query("projectArtifacts") .withIndex("by_project", (q) => q.eq("projectId", project._id)) .take(25); - return { artifacts, issue, project }; + const [source] = await ctx.db + .query("projectSources") + .withIndex("by_project", (q) => q.eq("projectId", project._id)) + .take(1); + const contextDocuments = await ctx.db + .query("projectContextDocuments") + .withIndex("by_project", (q) => q.eq("projectId", project._id)) + .take(25); + const run = await ctx.db + .query("projectWorkRuns") + .withIndex("by_issue", (q) => q.eq("issueId", issue._id)) + .unique(); + return { + artifacts, + contextDocuments, + issue, + project, + run: run ?? null, + source: source ?? null, + }; }, }); @@ -49,12 +106,38 @@ export const ensureRun = mutation({ if (!issue) { throw new ConvexError("Issue not found"); } + const [source] = await ctx.db + .query("projectSources") + .withIndex("by_project", (q) => q.eq("projectId", issue.projectId)) + .take(1); + const plan = workspacePlanFor({ + defaultBranch: source?.defaultBranch, + issueBody: issue.body, + issueId: String(issue._id), + issueNumber: issue.number, + issueTitle: issue.title, + sourceUrl: source?.url, + }); const existing = await ctx.db .query("projectWorkRuns") .withIndex("by_issue", (q) => q.eq("issueId", issue._id)) .unique(); if (existing) { - return existing; + if ( + existing.baseBranch !== plan.baseBranch || + existing.branchName !== plan.branchName || + existing.checkoutPath !== plan.checkoutPath || + existing.sourceUrl !== plan.sourceUrl + ) { + await ctx.db.patch("projectWorkRuns", existing._id, { + baseBranch: plan.baseBranch, + branchName: plan.branchName, + checkoutPath: plan.checkoutPath, + sourceUrl: plan.sourceUrl, + updatedAt: Date.now(), + }); + } + return await ctx.db.get("projectWorkRuns", existing._id); } const timestamp = Date.now(); const actorKey = [ @@ -66,16 +149,26 @@ export const ensureRun = mutation({ const runId = await ctx.db.insert("projectWorkRuns", { actorKey, agentId: String(issue._id), + baseBranch: plan.baseBranch, + branchName: plan.branchName, + checkoutPath: plan.checkoutPath, createdAt: timestamp, daemonId: args.daemonId, issueId: issue._id, projectId: issue.projectId, + sourceUrl: plan.sourceUrl, status: "queued", updatedAt: timestamp, }); await ctx.db.insert("projectEvents", { createdAt: timestamp, - data: { actorKey, daemonId: args.daemonId }, + data: { + actorKey, + branchName: plan.branchName, + checkoutPath: plan.checkoutPath, + daemonId: args.daemonId, + sourceUrl: plan.sourceUrl, + }, issueId: issue._id, kind: "agent.workspace.created", projectId: issue.projectId, @@ -147,6 +240,19 @@ export const setStatus = mutation({ if (!run) { throw new ConvexError("Agent work run not found"); } + try { + Effect.runSync( + transitionWorkspaceRun({ + from: run.status, + to: args.status, + }) + ); + } catch (error) { + if (error instanceof WorkspaceStateError) { + throw new ConvexError(error.message); + } + throw error; + } const timestamp = Date.now(); const terminal = args.status === "completed" || args.status === "failed"; await ctx.db.patch("projectIssues", issue._id, { diff --git a/packages/backend/convex/projectEvents.test.ts b/packages/backend/convex/projectEvents.test.ts index d9695ce..8ac80e8 100644 --- a/packages/backend/convex/projectEvents.test.ts +++ b/packages/backend/convex/projectEvents.test.ts @@ -168,11 +168,17 @@ describe("projectEvents", () => { .withIdentity(identityA) .mutation(api.projectIssues.begin, { issueId }); - await t.mutation(api.agentWorkspace.ensureRun, { + const run = await t.mutation(api.agentWorkspace.ensureRun, { daemonId: "daemon-1", issueId, token: FLUE_DB_TOKEN, }); + expect(run).toMatchObject({ + baseBranch: "main", + checkoutPath: "/workspace/repository", + sourceUrl: SOURCE.url, + }); + expect(run?.branchName).toMatch(/^work\/issue-1-/u); await t.mutation(api.agentWorkspace.setStatus, { issueId, status: "completed", diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index b39c1e1..63ec174 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -143,11 +143,15 @@ export default defineSchema({ .index("by_project_and_number", ["projectId", "number"]) .index("by_project_and_status", ["projectId", "status"]), projectWorkRuns: defineTable({ + baseBranch: v.optional(v.string()), + branchName: v.optional(v.string()), + checkoutPath: v.optional(v.string()), projectId: v.id("projects"), issueId: v.id("projectIssues"), agentId: v.string(), daemonId: v.string(), actorKey: v.array(v.string()), + sourceUrl: v.optional(v.string()), status: v.union( v.literal("ready"), v.literal("queued"), diff --git a/packages/primitives/package.json b/packages/primitives/package.json index 7b3f661..43cbf5a 100644 --- a/packages/primitives/package.json +++ b/packages/primitives/package.json @@ -7,6 +7,7 @@ ".": "./src/index.ts", "./agent-os": "./src/agent-os.ts", "./project": "./src/project.ts", + "./project-workspace": "./src/project-workspace.ts", "./signal": "./src/signal.ts" }, "scripts": { @@ -15,6 +16,7 @@ "test:watch": "vitest" }, "dependencies": { + "@agentos-software/git": "0.3.3", "@rivet-dev/agentos": "catalog:", "effect": "catalog:" }, diff --git a/packages/primitives/src/agent-os.ts b/packages/primitives/src/agent-os.ts index 065dc5f..f77040b 100644 --- a/packages/primitives/src/agent-os.ts +++ b/packages/primitives/src/agent-os.ts @@ -1,9 +1,16 @@ -import { - agentOS as createAgentOsActor, - type AgentOSConfigInput, -} from "@rivet-dev/agentos"; +/* eslint-disable max-classes-per-file -- the AgentOS service and its tagged error form one adapter contract. */ +import git from "@agentos-software/git"; +import { agentOS as createAgentOsActor } from "@rivet-dev/agentos"; +import type { AgentOSConfigInput } from "@rivet-dev/agentos"; import { Context, Effect, Layer, Schema } from "effect"; +const withGitSoftware = ( + config: AgentOSConfigInput +): AgentOSConfigInput => ({ + ...config, + software: [git, ...(config.software ?? [])], +}); + export class AgentOsError extends Schema.TaggedErrorClass()( "AgentOsError", { @@ -29,12 +36,12 @@ export class AgentOs extends Context.Service< static readonly layer = Layer.succeed( AgentOs, AgentOs.of({ - createActor: Effect.fn("AgentOs.createActor")(function* ( + createActor: Effect.fn("AgentOs.createActor")(function* createActor( config: AgentOSConfigInput = {} ) { return yield* Effect.try({ - try: () => createAgentOsActor(config), catch: (cause) => new AgentOsError({ cause }), + try: () => createAgentOsActor(withGitSoftware(config)), }); }), }) @@ -45,19 +52,19 @@ export const makeAgentOsLayer = (options: AgentOsOptions = {}) => Layer.succeed( AgentOs, AgentOs.of({ - createActor: Effect.fn("AgentOs.createActor")(function* ( + createActor: Effect.fn("AgentOs.createActor")(function* createActor( config: AgentOSConfigInput = options.config ?? {} ) { return yield* Effect.try({ - try: () => createAgentOsActor(config), catch: (cause) => new AgentOsError({ cause }), + try: () => createAgentOsActor(withGitSoftware(config)), }); }), }) ); export const createAgentOsActorEffect = Effect.fn("createAgentOsActorEffect")( - function* (config?: AgentOSConfigInput) { + function* createAgentOsActorEffect(config?: AgentOSConfigInput) { const agentOs = yield* AgentOs; return yield* agentOs.createActor(config); } diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index d252f41..ef2ade2 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 "./project"; +export * from "./project-workspace"; export * from "./signal"; diff --git a/packages/primitives/src/project-workspace.test.ts b/packages/primitives/src/project-workspace.test.ts new file mode 100644 index 0000000..a1d5723 --- /dev/null +++ b/packages/primitives/src/project-workspace.test.ts @@ -0,0 +1,141 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + decodeIssueWorkspaceResult, + makeIssueWorkspacePlan, + transitionWorkspaceRun, + WorkspaceExecutionError, + WorkspaceStateError, + WorkspaceValidationError, +} from "./project-workspace"; + +const makeInput = () => ({ + artifacts: [ + { + content: "# Work\n", + path: "work.md", + }, + ], + branchName: undefined, + checkoutPath: undefined, + contextFiles: [ + { + content: "# Project\n", + kind: "readme" as const, + path: "README.md", + }, + ], + defaultBranch: "main", + issueBody: "Make the project run in an isolated checkout.", + issueId: "projectIssues|abc123", + issueNumber: 7, + issueTitle: "Use a real project checkout", + sourceUrl: "https://git.example.test/puter/project", +}); + +const validationFailure = (input: unknown): WorkspaceValidationError => { + const error = Effect.runSync(Effect.flip(makeIssueWorkspacePlan(input))); + expect(error).toBeInstanceOf(WorkspaceValidationError); + return error; +}; + +describe("project workspace primitives", () => { + it("plans a deterministic checkout and keeps control files outside the repo", () => { + const plan = Effect.runSync(makeIssueWorkspacePlan(makeInput())); + const secondPlan = Effect.runSync(makeIssueWorkspacePlan(makeInput())); + + expect(plan.branchName).toBe("work/issue-7-abc123"); + expect(plan.checkoutPath).toBe("/workspace/repository"); + expect(plan).toEqual(secondPlan); + expect(plan.operations).toEqual( + expect.arrayContaining([ + { + _tag: "WriteFile", + content: expect.any(String), + path: "/workspace/control/issue.md", + }, + { + _tag: "WriteFile", + content: "# Project\n", + path: "/workspace/control/context/README.md", + }, + { + _tag: "WriteFile", + content: "# Work\n", + path: "/workspace/control/artifacts/work.md", + }, + ]) + ); + expect( + plan.operations.some( + (operation) => + operation._tag === "Exec" && operation.command.includes("git clone") + ) + ).toBe(true); + }); + + it("rejects unsafe sources and control-file paths", () => { + const missingSource = { ...makeInput(), sourceUrl: undefined }; + expect(validationFailure(missingSource).reason).toBe("MissingSource"); + + const invalidSource = makeInput(); + invalidSource.sourceUrl = "ssh://git@example.test/project"; + expect(validationFailure(invalidSource).reason).toBe("InvalidSourceUrl"); + + const invalidPath = makeInput(); + const [artifact] = invalidPath.artifacts; + if (!artifact) { + throw new Error("test artifact missing"); + } + artifact.path = "../work.md"; + expect(validationFailure(invalidPath).reason).toBe("InvalidPath"); + }); + + it("rejects a checkout result that does not prove the planned branch", () => { + const plan = Effect.runSync(makeIssueWorkspacePlan(makeInput())); + const error = Effect.runSync( + Effect.flip( + decodeIssueWorkspaceResult({ + command: { + exitCode: 0, + stderr: "", + stdout: "work/issue-7-other deadbeef\n", + }, + plan, + }) + ) + ); + + expect(error).toBeInstanceOf(WorkspaceExecutionError); + expect(error.reason).toBe("InvalidResult"); + }); + + it("accepts the planned checkout result and enforces run transitions", () => { + const plan = Effect.runSync(makeIssueWorkspacePlan(makeInput())); + const result = Effect.runSync( + decodeIssueWorkspaceResult({ + command: { + exitCode: 0, + stderr: "", + stdout: `${plan.branchName} deadbeef1234567\n`, + }, + plan, + }) + ); + + expect(result).toMatchObject({ + branchName: plan.branchName, + checkoutPath: "/workspace/repository", + headSha: "deadbeef1234567", + }); + expect( + Effect.runSync(transitionWorkspaceRun({ from: "queued", to: "working" })) + ).toBe("working"); + + const error = Effect.runSync( + Effect.flip(transitionWorkspaceRun({ from: "completed", to: "working" })) + ); + expect(error).toBeInstanceOf(WorkspaceStateError); + }); +}); diff --git a/packages/primitives/src/project-workspace.ts b/packages/primitives/src/project-workspace.ts new file mode 100644 index 0000000..19581d4 --- /dev/null +++ b/packages/primitives/src/project-workspace.ts @@ -0,0 +1,436 @@ +/* eslint-disable max-classes-per-file -- workspace errors stay next to their contracts. */ +import { Effect, Schema } from "effect"; + +import { CONTEXT_KINDS } from "./project.js"; + +export type { ContextKind } from "./project.js"; + +const MeaningfulString = Schema.String.check( + Schema.makeFilter((value) => value.trim().length > 0, { + expected: "a non-empty string", + }) +); + +const PositiveInteger = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); + +const WORKSPACE_PATH = "/workspace"; +const CHECKOUT_PATH = "/workspace/repository"; +const CONTROL_PATH = "/workspace/control"; + +const isSafeRelativePath = (value: string): boolean => { + if (value.length === 0 || value.startsWith("/") || value.includes("\\")) { + return false; + } + return !value + .split("/") + .some((segment) => segment === "" || segment === "." || segment === ".."); +}; + +const isValidGitRef = (value: string): boolean => + value.length > 0 && + !value.startsWith("/") && + !value.endsWith("/") && + !value.startsWith(".") && + !value.endsWith(".") && + !value.includes("..") && + !value.includes("//") && + !value.includes("@{") && + ![...value].some((character) => character < " ") && + !/[ ~^:?*[\\]/u.test(value); + +const isPublicGitUrl = (value: string): boolean => { + try { + const url = new URL(value); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + url.hostname.length > 0 && + url.pathname !== "/" && + url.username.length === 0 && + url.password.length === 0 && + url.search.length === 0 && + url.hash.length === 0 + ); + } catch { + return false; + } +}; + +const shellQuote = (value: string): string => + `'${value.replaceAll("'", "'\"'\"'")}'`; + +const slugIdentity = (value: string): string => { + const parts = value + .toLowerCase() + .split(/[^a-z0-9]+/gu) + .filter(Boolean); + let result = ""; + for (const part of parts) { + result = part; + } + return result.replaceAll(/^-+|-+$/gu, "").slice(-24) || "issue"; +}; + +export const WorkspaceContextFile = Schema.Struct({ + content: Schema.String, + kind: Schema.Literals([...CONTEXT_KINDS]), + path: MeaningfulString, +}); +export type WorkspaceContextFile = typeof WorkspaceContextFile.Type; + +export const WorkspaceArtifactFile = Schema.Struct({ + content: Schema.String, + path: MeaningfulString, +}); +export type WorkspaceArtifactFile = typeof WorkspaceArtifactFile.Type; + +export const IssueWorkspaceInput = Schema.Struct({ + artifacts: Schema.Array(WorkspaceArtifactFile), + branchName: Schema.UndefinedOr(MeaningfulString), + checkoutPath: Schema.UndefinedOr(MeaningfulString), + contextFiles: Schema.Array(WorkspaceContextFile), + defaultBranch: Schema.UndefinedOr(MeaningfulString), + issueBody: MeaningfulString, + issueId: MeaningfulString, + issueNumber: PositiveInteger, + issueTitle: MeaningfulString, + sourceUrl: Schema.UndefinedOr(MeaningfulString), +}); +export type IssueWorkspaceInput = typeof IssueWorkspaceInput.Type; + +export const WorkspaceExecOperation = Schema.Struct({ + _tag: Schema.Literal("Exec"), + command: MeaningfulString, + cwd: MeaningfulString, + timeoutMs: PositiveInteger, +}); + +export const WorkspaceMkdirOperation = Schema.Struct({ + _tag: Schema.Literal("Mkdir"), + path: MeaningfulString, +}); + +export const WorkspaceWriteFileOperation = Schema.Struct({ + _tag: Schema.Literal("WriteFile"), + content: Schema.String, + path: MeaningfulString, +}); + +export const IssueWorkspaceOperation = Schema.Union([ + WorkspaceExecOperation, + WorkspaceMkdirOperation, + WorkspaceWriteFileOperation, +]); +export type IssueWorkspaceOperation = typeof IssueWorkspaceOperation.Type; + +export const IssueWorkspacePlan = Schema.Struct({ + artifactsRoot: MeaningfulString, + baseBranch: MeaningfulString, + branchName: MeaningfulString, + checkoutPath: MeaningfulString, + contextRoot: MeaningfulString, + issuePath: MeaningfulString, + operations: Schema.Array(IssueWorkspaceOperation), + sourceUrl: MeaningfulString, +}); +export type IssueWorkspacePlan = typeof IssueWorkspacePlan.Type; + +export const IssueWorkspaceCommandResult = Schema.Struct({ + exitCode: Schema.Int, + stderr: Schema.String, + stdout: Schema.String, +}); +export type IssueWorkspaceCommandResult = + typeof IssueWorkspaceCommandResult.Type; + +export const IssueWorkspaceResult = Schema.Struct({ + baseBranch: MeaningfulString, + branchName: MeaningfulString, + checkoutPath: MeaningfulString, + headSha: MeaningfulString, + sourceUrl: MeaningfulString, +}); +export type IssueWorkspaceResult = typeof IssueWorkspaceResult.Type; + +export const WorkspaceValidationReason = Schema.Literals([ + "MissingSource", + "InvalidInput", + "InvalidSourceUrl", + "InvalidBranchName", + "InvalidPath", + "DuplicatePath", +]); +export type WorkspaceValidationReason = typeof WorkspaceValidationReason.Type; + +export class WorkspaceValidationError extends Schema.TaggedErrorClass()( + "WorkspaceValidationError", + { + message: Schema.String, + reason: WorkspaceValidationReason, + } +) {} + +export const WorkspaceExecutionReason = Schema.Literals([ + "CommandFailed", + "InvalidResult", +]); +export type WorkspaceExecutionReason = typeof WorkspaceExecutionReason.Type; + +export class WorkspaceExecutionError extends Schema.TaggedErrorClass()( + "WorkspaceExecutionError", + { + message: Schema.String, + reason: WorkspaceExecutionReason, + } +) {} + +export const WorkspaceRunStatus = Schema.Literals([ + "ready", + "queued", + "working", + "needs-input", + "completed", + "failed", +]); +export type WorkspaceRunStatus = typeof WorkspaceRunStatus.Type; + +export class WorkspaceStateError extends Schema.TaggedErrorClass()( + "WorkspaceStateError", + { + from: WorkspaceRunStatus, + message: Schema.String, + to: WorkspaceRunStatus, + } +) {} + +const validationError = (message: string, reason: WorkspaceValidationReason) => + new WorkspaceValidationError({ message, reason }); + +const requireWorkspaceSource = (sourceUrl: string | undefined) => { + if (!sourceUrl || sourceUrl.trim().length === 0) { + return Effect.fail( + validationError("Project source is not connected", "MissingSource") + ); + } + if (!isPublicGitUrl(sourceUrl)) { + return Effect.fail( + validationError( + "Issue workspace source must be a public http(s) Git URL", + "InvalidSourceUrl" + ) + ); + } + return Effect.succeed(sourceUrl); +}; + +const validateFiles = ( + files: readonly { readonly path: string }[], + root: string +): Effect.Effect => + Effect.gen(function* validateWorkspaceFiles() { + const paths = new Set(); + for (const file of files) { + if (!isSafeRelativePath(file.path)) { + return yield* Effect.fail( + validationError( + `Invalid workspace file path: ${file.path}`, + "InvalidPath" + ) + ); + } + const path = `${root}/${file.path}`; + if (paths.has(path)) { + return yield* Effect.fail( + validationError( + `Duplicate workspace file path: ${path}`, + "DuplicatePath" + ) + ); + } + paths.add(path); + } + }); + +const makeBranchName = (issueNumber: number, issueId: string): string => + `work/issue-${issueNumber}-${slugIdentity(issueId)}`; + +const makeCheckoutCommand = (input: { + readonly baseBranch: string; + readonly branchName: string; + readonly checkoutPath: string; + readonly sourceUrl: string; +}): string => { + const checkout = shellQuote(input.checkoutPath); + const baseBranch = shellQuote(input.baseBranch); + const branch = shellQuote(input.branchName); + const originBaseBranch = shellQuote(`origin/${input.baseBranch}`); + const source = shellQuote(input.sourceUrl); + + return [ + "set -eu", + `mkdir -p ${shellQuote(WORKSPACE_PATH)}`, + `if [ ! -d ${checkout}/.git ]; then git clone --branch ${baseBranch} --single-branch ${source} ${checkout}; fi`, + `if [ "$(git -C ${checkout} branch --show-current)" != ${branch} ]; then git -C ${checkout} show-ref --verify --quiet refs/heads/${branch} && git -C ${checkout} checkout ${branch} || git -C ${checkout} checkout -b ${branch} ${originBaseBranch}; fi`, + `printf '%s\\n' "$(git -C ${checkout} branch --show-current)" "$(git -C ${checkout} rev-parse HEAD)"`, + ].join(" && "); +}; + +export const makeIssueWorkspacePlan = Effect.fn("ProjectWorkspace.makePlan")( + function* makeIssueWorkspacePlan(rawInput: unknown) { + const input: IssueWorkspaceInput = yield* Schema.decodeUnknownEffect( + IssueWorkspaceInput + )(rawInput).pipe( + Effect.mapError(() => + validationError("Invalid issue workspace input", "InvalidInput") + ) + ); + + const sourceUrl = yield* requireWorkspaceSource(input.sourceUrl); + + const baseBranch = input.defaultBranch?.trim() || "main"; + const branchName = + input.branchName?.trim() || + makeBranchName(input.issueNumber, input.issueId); + const checkoutPath = input.checkoutPath?.trim() || CHECKOUT_PATH; + + if (!isValidGitRef(baseBranch) || !isValidGitRef(branchName)) { + return yield* Effect.fail( + validationError( + "Issue workspace branch name is not a valid Git ref", + "InvalidBranchName" + ) + ); + } + if ( + checkoutPath !== CHECKOUT_PATH || + !checkoutPath.startsWith(`${WORKSPACE_PATH}/`) || + checkoutPath.includes("..") + ) { + return yield* Effect.fail( + validationError( + "Issue workspace checkout path must be /workspace/repository", + "InvalidPath" + ) + ); + } + + const contextRoot = `${CONTROL_PATH}/context`; + const artifactsRoot = `${CONTROL_PATH}/artifacts`; + yield* validateFiles(input.contextFiles, contextRoot); + yield* validateFiles(input.artifacts, artifactsRoot); + + const operations: IssueWorkspaceOperation[] = [ + { _tag: "Mkdir", path: CONTROL_PATH }, + { + _tag: "Exec", + command: makeCheckoutCommand({ + baseBranch, + branchName, + checkoutPath, + sourceUrl, + }), + cwd: WORKSPACE_PATH, + timeoutMs: 300_000, + }, + { _tag: "Mkdir", path: contextRoot }, + { _tag: "Mkdir", path: artifactsRoot }, + { + _tag: "WriteFile", + content: `# Issue ${input.issueNumber}: ${input.issueTitle}\n\n${input.issueBody}\n`, + path: `${CONTROL_PATH}/issue.md`, + }, + ...input.contextFiles.map((file) => ({ + _tag: "WriteFile" as const, + content: file.content, + path: `${contextRoot}/${file.path}`, + })), + ...input.artifacts.map((file) => ({ + _tag: "WriteFile" as const, + content: file.content, + path: `${artifactsRoot}/${file.path}`, + })), + ]; + + return { + artifactsRoot, + baseBranch, + branchName, + checkoutPath, + contextRoot, + issuePath: `${CONTROL_PATH}/issue.md`, + operations, + sourceUrl, + }; + } +); + +export const decodeIssueWorkspaceResult = Effect.fn( + "ProjectWorkspace.decodeResult" +)(function* decodeIssueWorkspaceResult(input: { + readonly command: IssueWorkspaceCommandResult; + readonly plan: IssueWorkspacePlan; +}) { + if (input.command.exitCode !== 0) { + return yield* Effect.fail( + new WorkspaceExecutionError({ + message: + input.command.stderr || "Issue workspace checkout command failed", + reason: "CommandFailed", + }) + ); + } + + const [branchName, headSha] = input.command.stdout.trim().split(/\s+/u); + if ( + branchName !== input.plan.branchName || + !headSha || + !/^[0-9a-f]{7,64}$/u.test(headSha) + ) { + return yield* Effect.fail( + new WorkspaceExecutionError({ + message: + "Issue workspace checkout command returned invalid branch metadata", + reason: "InvalidResult", + }) + ); + } + + return { + baseBranch: input.plan.baseBranch, + branchName, + checkoutPath: input.plan.checkoutPath, + headSha, + sourceUrl: input.plan.sourceUrl, + }; +}); + +const allowedTransitions: Readonly< + Record +> = { + completed: [], + failed: [], + "needs-input": ["queued", "working", "failed"], + queued: ["working", "completed", "failed"], + ready: ["queued", "working", "failed"], + working: ["needs-input", "completed", "failed"], +}; + +export const transitionWorkspaceRun = Effect.fn( + "ProjectWorkspace.transitionRun" +)(function* transitionWorkspaceRun(input: { + readonly from: WorkspaceRunStatus; + readonly to: WorkspaceRunStatus; +}) { + if ( + input.from === input.to || + allowedTransitions[input.from].includes(input.to) + ) { + return input.to; + } + return yield* Effect.fail( + new WorkspaceStateError({ + from: input.from, + message: `Cannot transition issue workspace run from ${input.from} to ${input.to}`, + to: input.to, + }) + ); +});