feat: prepare issue workspaces from connected repositories
This commit is contained in:
5
bun.lock
5
bun.lock
@@ -149,9 +149,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@code/backend": "workspace:*",
|
"@code/backend": "workspace:*",
|
||||||
"@code/env": "workspace:*",
|
"@code/env": "workspace:*",
|
||||||
|
"@code/primitives": "workspace:*",
|
||||||
"@flue/runtime": "latest",
|
"@flue/runtime": "latest",
|
||||||
"@rivet-dev/agentos-core": "catalog:",
|
"@rivet-dev/agentos-core": "catalog:",
|
||||||
"convex": "catalog:",
|
"convex": "catalog:",
|
||||||
|
"effect": "catalog:",
|
||||||
"hono": "4.12.30",
|
"hono": "4.12.30",
|
||||||
"valibot": "^1.4.2",
|
"valibot": "^1.4.2",
|
||||||
},
|
},
|
||||||
@@ -232,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:",
|
||||||
},
|
},
|
||||||
@@ -316,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=="],
|
||||||
|
|||||||
@@ -14,9 +14,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@code/backend": "workspace:*",
|
"@code/backend": "workspace:*",
|
||||||
"@code/env": "workspace:*",
|
"@code/env": "workspace:*",
|
||||||
|
"@code/primitives": "workspace:*",
|
||||||
"@flue/runtime": "latest",
|
"@flue/runtime": "latest",
|
||||||
"@rivet-dev/agentos-core": "catalog:",
|
"@rivet-dev/agentos-core": "catalog:",
|
||||||
"convex": "catalog:",
|
"convex": "catalog:",
|
||||||
|
"effect": "catalog:",
|
||||||
"hono": "4.12.30",
|
"hono": "4.12.30",
|
||||||
"valibot": "^1.4.2"
|
"valibot": "^1.4.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
await Promise.all(
|
||||||
context.artifacts.map((artifact) =>
|
stagingOperations
|
||||||
sandbox.writeFile(`/workspace/${artifact.path}`, artifact.content)
|
.filter((operation) => operation._tag === "Mkdir")
|
||||||
|
.map((operation) =>
|
||||||
|
sandbox.mkdir(operation.path, { recursive: true })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
await sandbox.writeFile(
|
await Promise.all(
|
||||||
"/workspace/issue.md",
|
stagingOperations
|
||||||
`# Issue ${context.issue.number}: ${context.issue.title}\n\n${context.issue.body}\n`
|
.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");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./agent-os": "./src/agent-os.ts",
|
"./agent-os": "./src/agent-os.ts",
|
||||||
"./project": "./src/project.ts",
|
"./project": "./src/project.ts",
|
||||||
|
"./project-workspace": "./src/project-workspace.ts",
|
||||||
"./signal": "./src/signal.ts"
|
"./signal": "./src/signal.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -15,6 +16,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:"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
|
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
|
||||||
export * from "./agent-os";
|
export * from "./agent-os";
|
||||||
export * from "./project";
|
export * from "./project";
|
||||||
|
export * from "./project-workspace";
|
||||||
export * from "./signal";
|
export * from "./signal";
|
||||||
|
|||||||
141
packages/primitives/src/project-workspace.test.ts
Normal file
141
packages/primitives/src/project-workspace.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
436
packages/primitives/src/project-workspace.ts
Normal file
436
packages/primitives/src/project-workspace.ts
Normal 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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user