Merge branch 'puter/issue-7-gitea-checkout' into puter/issue-14-integration

# Conflicts:
#	bun.lock
#	packages/agents/package.json
#	packages/primitives/package.json
#	packages/primitives/src/index.ts
This commit is contained in:
-Puter
2026-07-24 02:27:13 +05:30
11 changed files with 792 additions and 27 deletions

View File

@@ -153,8 +153,8 @@
"@flue/runtime": "latest", "@flue/runtime": "latest",
"@rivet-dev/agentos-core": "catalog:", "@rivet-dev/agentos-core": "catalog:",
"convex": "catalog:", "convex": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"hono": "4.12.31", "hono": "4.12.31",
"valibot": "^1.4.2", "valibot": "^1.4.2",
}, },
"devDependencies": { "devDependencies": {
@@ -234,6 +234,7 @@
"name": "@code/primitives", "name": "@code/primitives",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@agentos-software/git": "0.3.3",
"@rivet-dev/agentos": "catalog:", "@rivet-dev/agentos": "catalog:",
"effect": "catalog:", "effect": "catalog:",
}, },
@@ -318,6 +319,8 @@
"@agentos-software/gawk": ["@agentos-software/gawk@0.3.4", "", {}, "sha512-NlU6nGxoqIUc1I2zdBHFwH04gE0tBygP2FcBO12VBptty7lbgq1CNLk909jIcSNFEwlm3VRPHeZNkbdVKZ2Ujg=="], "@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/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=="], "@agentos-software/gzip": ["@agentos-software/gzip@0.3.4", "", {}, "sha512-l7Y/Vwiwsqgna68yYwdNCnUBPGBg5zdmtjEFeG3hnDDFLe/02h17q0gUIqJmDBIAy1q9GoizqcFi7zXO2xygKg=="],

View File

@@ -14,15 +14,15 @@ export default defineAgent(({ env, id }) => {
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env); const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
return { return {
cwd: "/workspace", cwd: "/workspace/repository",
description, description,
instructions: `You are the issue-scoped project manager and coding agent. 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}`, model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
sandbox: agentOs(env), sandbox: agentOs(env),
tools: createProjectTools(id, env), tools: createProjectTools(id, env),

View File

@@ -1,9 +1,14 @@
import { api } from "@code/backend/convex/_generated/api"; import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel"; import type { Id } from "@code/backend/convex/_generated/dataModel";
import { parseAgentEnv } from "@code/env/agent"; import { parseAgentEnv } from "@code/env/agent";
import {
decodeIssueWorkspaceResult,
makeIssueWorkspacePlan,
} from "@code/primitives/project-workspace";
import { createSandboxSessionEnv } from "@flue/runtime"; import { createSandboxSessionEnv } from "@flue/runtime";
import type { FileStat, SandboxApi, SandboxFactory } from "@flue/runtime"; import type { FileStat, SandboxApi, SandboxFactory } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser"; import { ConvexHttpClient } from "convex/browser";
import { Effect } from "effect";
import * as v from "valibot"; import * as v from "valibot";
const execResultSchema = v.object({ const execResultSchema = v.object({
@@ -218,15 +223,69 @@ export const agentOs = (
issueId, issueId,
token: env.FLUE_DB_TOKEN, token: env.FLUE_DB_TOKEN,
}); });
await sandbox.mkdir("/workspace", { recursive: true });
await Promise.all( const plan = await Effect.runPromise(
context.artifacts.map((artifact) => makeIssueWorkspacePlan({
sandbox.writeFile(`/workspace/${artifact.path}`, artifact.content) 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", const [controlDirectory, checkoutOperation, ...stagingOperations] =
`# Issue ${context.issue.number}: ${context.issue.title}\n\n${context.issue.body}\n` 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"); return createSandboxSessionEnv(sandbox, "/workspace");
}, },

View File

@@ -1,5 +1,12 @@
import { env } from "@code/env/convex"; import { env } from "@code/env/convex";
import {
makeIssueWorkspacePlan,
transitionWorkspaceRun,
WorkspaceStateError,
WorkspaceValidationError,
} from "@code/primitives/project-workspace";
import { ConvexError, v } from "convex/values"; import { ConvexError, v } from "convex/values";
import { Effect } from "effect";
import { mutation, query } from "./_generated/server"; import { mutation, query } from "./_generated/server";
import { artifactPath } from "./artifactModel"; import { artifactPath } from "./artifactModel";
@@ -17,6 +24,37 @@ const agentStatus = v.union(
v.literal("failed") 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({ export const get = query({
args: { issueId: v.id("projectIssues"), token: v.string() }, args: { issueId: v.id("projectIssues"), token: v.string() },
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -33,7 +71,26 @@ export const get = query({
.query("projectArtifacts") .query("projectArtifacts")
.withIndex("by_project", (q) => q.eq("projectId", project._id)) .withIndex("by_project", (q) => q.eq("projectId", project._id))
.take(25); .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) { if (!issue) {
throw new ConvexError("Issue not found"); 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 const existing = await ctx.db
.query("projectWorkRuns") .query("projectWorkRuns")
.withIndex("by_issue", (q) => q.eq("issueId", issue._id)) .withIndex("by_issue", (q) => q.eq("issueId", issue._id))
.unique(); .unique();
if (existing) { 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 timestamp = Date.now();
const actorKey = [ const actorKey = [
@@ -66,16 +149,26 @@ export const ensureRun = mutation({
const runId = await ctx.db.insert("projectWorkRuns", { const runId = await ctx.db.insert("projectWorkRuns", {
actorKey, actorKey,
agentId: String(issue._id), agentId: String(issue._id),
baseBranch: plan.baseBranch,
branchName: plan.branchName,
checkoutPath: plan.checkoutPath,
createdAt: timestamp, createdAt: timestamp,
daemonId: args.daemonId, daemonId: args.daemonId,
issueId: issue._id, issueId: issue._id,
projectId: issue.projectId, projectId: issue.projectId,
sourceUrl: plan.sourceUrl,
status: "queued", status: "queued",
updatedAt: timestamp, updatedAt: timestamp,
}); });
await ctx.db.insert("projectEvents", { await ctx.db.insert("projectEvents", {
createdAt: timestamp, createdAt: timestamp,
data: { actorKey, daemonId: args.daemonId }, data: {
actorKey,
branchName: plan.branchName,
checkoutPath: plan.checkoutPath,
daemonId: args.daemonId,
sourceUrl: plan.sourceUrl,
},
issueId: issue._id, issueId: issue._id,
kind: "agent.workspace.created", kind: "agent.workspace.created",
projectId: issue.projectId, projectId: issue.projectId,
@@ -147,6 +240,19 @@ export const setStatus = mutation({
if (!run) { if (!run) {
throw new ConvexError("Agent work run not found"); 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 timestamp = Date.now();
const terminal = args.status === "completed" || args.status === "failed"; const terminal = args.status === "completed" || args.status === "failed";
await ctx.db.patch("projectIssues", issue._id, { await ctx.db.patch("projectIssues", issue._id, {

View File

@@ -168,11 +168,17 @@ describe("projectEvents", () => {
.withIdentity(identityA) .withIdentity(identityA)
.mutation(api.projectIssues.begin, { issueId }); .mutation(api.projectIssues.begin, { issueId });
await t.mutation(api.agentWorkspace.ensureRun, { const run = await t.mutation(api.agentWorkspace.ensureRun, {
daemonId: "daemon-1", daemonId: "daemon-1",
issueId, issueId,
token: FLUE_DB_TOKEN, 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, { await t.mutation(api.agentWorkspace.setStatus, {
issueId, issueId,
status: "completed", status: "completed",

View File

@@ -143,11 +143,15 @@ export default defineSchema({
.index("by_project_and_number", ["projectId", "number"]) .index("by_project_and_number", ["projectId", "number"])
.index("by_project_and_status", ["projectId", "status"]), .index("by_project_and_status", ["projectId", "status"]),
projectWorkRuns: defineTable({ projectWorkRuns: defineTable({
baseBranch: v.optional(v.string()),
branchName: v.optional(v.string()),
checkoutPath: v.optional(v.string()),
projectId: v.id("projects"), projectId: v.id("projects"),
issueId: v.id("projectIssues"), issueId: v.id("projectIssues"),
agentId: v.string(), agentId: v.string(),
daemonId: v.string(), daemonId: v.string(),
actorKey: v.array(v.string()), actorKey: v.array(v.string()),
sourceUrl: v.optional(v.string()),
status: v.union( status: v.union(
v.literal("ready"), v.literal("ready"),
v.literal("queued"), v.literal("queued"),

View File

@@ -8,6 +8,7 @@
"./agent-os": "./src/agent-os.ts", "./agent-os": "./src/agent-os.ts",
"./project": "./src/project.ts", "./project": "./src/project.ts",
"./project-issue": "./src/project-issue.ts", "./project-issue": "./src/project-issue.ts",
"./project-workspace": "./src/project-workspace.ts",
"./signal": "./src/signal.ts" "./signal": "./src/signal.ts"
}, },
"scripts": { "scripts": {
@@ -16,6 +17,7 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@agentos-software/git": "0.3.3",
"@rivet-dev/agentos": "catalog:", "@rivet-dev/agentos": "catalog:",
"effect": "catalog:" "effect": "catalog:"
}, },

View File

@@ -1,9 +1,16 @@
import { /* eslint-disable max-classes-per-file -- the AgentOS service and its tagged error form one adapter contract. */
agentOS as createAgentOsActor, import git from "@agentos-software/git";
type AgentOSConfigInput, import { agentOS as createAgentOsActor } from "@rivet-dev/agentos";
} from "@rivet-dev/agentos"; import type { AgentOSConfigInput } from "@rivet-dev/agentos";
import { Context, Effect, Layer, Schema } from "effect"; import { Context, Effect, Layer, Schema } from "effect";
const withGitSoftware = (
config: AgentOSConfigInput<undefined>
): AgentOSConfigInput<undefined> => ({
...config,
software: [git, ...(config.software ?? [])],
});
export class AgentOsError extends Schema.TaggedErrorClass<AgentOsError>()( export class AgentOsError extends Schema.TaggedErrorClass<AgentOsError>()(
"AgentOsError", "AgentOsError",
{ {
@@ -29,12 +36,12 @@ export class AgentOs extends Context.Service<
static readonly layer = Layer.succeed( static readonly layer = Layer.succeed(
AgentOs, AgentOs,
AgentOs.of({ AgentOs.of({
createActor: Effect.fn("AgentOs.createActor")(function* ( createActor: Effect.fn("AgentOs.createActor")(function* createActor(
config: AgentOSConfigInput<undefined> = {} config: AgentOSConfigInput<undefined> = {}
) { ) {
return yield* Effect.try({ return yield* Effect.try({
try: () => createAgentOsActor<undefined>(config),
catch: (cause) => new AgentOsError({ cause }), catch: (cause) => new AgentOsError({ cause }),
try: () => createAgentOsActor<undefined>(withGitSoftware(config)),
}); });
}), }),
}) })
@@ -45,19 +52,19 @@ export const makeAgentOsLayer = (options: AgentOsOptions = {}) =>
Layer.succeed( Layer.succeed(
AgentOs, AgentOs,
AgentOs.of({ AgentOs.of({
createActor: Effect.fn("AgentOs.createActor")(function* ( createActor: Effect.fn("AgentOs.createActor")(function* createActor(
config: AgentOSConfigInput<undefined> = options.config ?? {} config: AgentOSConfigInput<undefined> = options.config ?? {}
) { ) {
return yield* Effect.try({ return yield* Effect.try({
try: () => createAgentOsActor<undefined>(config),
catch: (cause) => new AgentOsError({ cause }), catch: (cause) => new AgentOsError({ cause }),
try: () => createAgentOsActor<undefined>(withGitSoftware(config)),
}); });
}), }),
}) })
); );
export const createAgentOsActorEffect = Effect.fn("createAgentOsActorEffect")( export const createAgentOsActorEffect = Effect.fn("createAgentOsActorEffect")(
function* (config?: AgentOSConfigInput<undefined>) { function* createAgentOsActorEffect(config?: AgentOSConfigInput<undefined>) {
const agentOs = yield* AgentOs; const agentOs = yield* AgentOs;
return yield* agentOs.createActor(config); return yield* agentOs.createActor(config);
} }

View File

@@ -2,4 +2,5 @@
export * from "./agent-os"; export * from "./agent-os";
export * from "./project"; export * from "./project";
export * from "./project-issue"; export * from "./project-issue";
export * from "./project-workspace";
export * from "./signal"; export * from "./signal";

View File

@@ -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);
});
});

View File

@@ -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>()(
"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>()(
"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>()(
"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<void, WorkspaceValidationError> =>
Effect.gen(function* validateWorkspaceFiles() {
const paths = new Set<string>();
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<WorkspaceRunStatus, readonly WorkspaceRunStatus[]>
> = {
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,
})
);
});