feat: prepare issue workspaces from connected repositories

This commit is contained in:
-Puter
2026-07-24 02:19:35 +05:30
parent 7974bd5f3e
commit 7caad057db
12 changed files with 794 additions and 25 deletions

View File

@@ -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:"
},

View File

@@ -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<undefined>
): AgentOSConfigInput<undefined> => ({
...config,
software: [git, ...(config.software ?? [])],
});
export class AgentOsError extends Schema.TaggedErrorClass<AgentOsError>()(
"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<undefined> = {}
) {
return yield* Effect.try({
try: () => createAgentOsActor<undefined>(config),
catch: (cause) => new AgentOsError({ cause }),
try: () => createAgentOsActor<undefined>(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<undefined> = options.config ?? {}
) {
return yield* Effect.try({
try: () => createAgentOsActor<undefined>(config),
catch: (cause) => new AgentOsError({ cause }),
try: () => createAgentOsActor<undefined>(withGitSoftware(config)),
});
}),
})
);
export const createAgentOsActorEffect = Effect.fn("createAgentOsActorEffect")(
function* (config?: AgentOSConfigInput<undefined>) {
function* createAgentOsActorEffect(config?: AgentOSConfigInput<undefined>) {
const agentOs = yield* AgentOs;
return yield* agentOs.createActor(config);
}

View File

@@ -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";

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