# Conflicts: # bun.lock # packages/agents/package.json # packages/agents/src/agents/project-manager.ts # packages/backend/convex/agentWorkspace.ts
286 lines
8.5 KiB
TypeScript
286 lines
8.5 KiB
TypeScript
import { env } from "@code/env/convex";
|
|
import { type TestConvex, convexTest } from "convex-test";
|
|
import { anyApi } from "convex/server";
|
|
import { describe, expect, test } from "vitest";
|
|
|
|
import { internal } from "./_generated/api";
|
|
import type { Id } from "./_generated/dataModel";
|
|
import schema from "./schema";
|
|
|
|
declare global {
|
|
interface ImportMeta {
|
|
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
|
}
|
|
}
|
|
|
|
const modules = import.meta.glob("./**/*.ts");
|
|
const api = anyApi;
|
|
|
|
const FLUE_DB_TOKEN = env.FLUE_DB_TOKEN;
|
|
|
|
const ID_A = "https://convex.test|user-a";
|
|
const identityA = { tokenIdentifier: ID_A };
|
|
|
|
const SOURCE = {
|
|
host: "github.com",
|
|
projectName: "zopu",
|
|
repositoryPath: "puter/zopu",
|
|
normalizedUrl: "https://github.com/puter/zopu",
|
|
url: "https://github.com/puter/zopu",
|
|
};
|
|
|
|
const REMOTE = {
|
|
defaultBranch: "main",
|
|
documents: [
|
|
{
|
|
kind: "readme" as const,
|
|
path: "README.md",
|
|
content: "# zopu\n\nRepository: https://github.com/puter/zopu\n",
|
|
},
|
|
],
|
|
warnings: [],
|
|
};
|
|
|
|
const createTestProject = async (
|
|
t: TestConvex<typeof schema>
|
|
): Promise<Id<"projects">> => {
|
|
// Ensure personal organization for the user
|
|
await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.organizations.ensurePersonalOrganization);
|
|
// Persist the public Git import
|
|
const outcome = await t
|
|
.withIdentity(identityA)
|
|
.mutation(internal.projects.persistPublicGitImport, {
|
|
userId: ID_A,
|
|
source: SOURCE,
|
|
remote: REMOTE,
|
|
});
|
|
return outcome.id as unknown as Id<"projects">;
|
|
};
|
|
|
|
const eventsForProject = async (
|
|
t: TestConvex<typeof schema>,
|
|
projectId: Id<"projects">
|
|
) => {
|
|
return await t.query((ctx) =>
|
|
ctx.db
|
|
.query("projectEvents")
|
|
.withIndex("by_project_and_createdAt", (q) =>
|
|
q.eq("projectId", projectId)
|
|
)
|
|
.collect()
|
|
);
|
|
};
|
|
|
|
describe("projectEvents", () => {
|
|
test("project connect emits a project.connected event", async () => {
|
|
const t = convexTest({ schema, modules });
|
|
const projectId = await createTestProject(t);
|
|
|
|
const events = await eventsForProject(t, projectId);
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0].kind).toBe("project.connected");
|
|
expect(events[0].projectId).toBe(projectId);
|
|
expect(events[0].issueId).toBeUndefined();
|
|
expect(events[0].runId).toBeUndefined();
|
|
});
|
|
|
|
test("issue create and begin emit issue.created then issue.queued events", async () => {
|
|
const t = convexTest({ schema, modules });
|
|
const projectId = await createTestProject(t);
|
|
|
|
const issueId = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.projectIssues.create, {
|
|
body: "A representative issue body long enough to pass validation",
|
|
projectId,
|
|
title: "Representative issue",
|
|
});
|
|
|
|
const afterCreate = await eventsForProject(t, projectId);
|
|
expect(afterCreate.map((e) => e.kind)).toEqual([
|
|
"project.connected",
|
|
"issue.created",
|
|
]);
|
|
const created = afterCreate[1];
|
|
expect(created.issueId).toBe(issueId);
|
|
expect(created.data).toMatchObject({
|
|
number: 1,
|
|
title: "Representative issue",
|
|
});
|
|
|
|
const status = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.projectIssues.begin, {
|
|
issueId,
|
|
});
|
|
expect(status).toBe("queued");
|
|
|
|
const afterBegin = await eventsForProject(t, projectId);
|
|
expect(afterBegin.map((e) => e.kind)).toEqual([
|
|
"project.connected",
|
|
"issue.created",
|
|
"issue.queued",
|
|
]);
|
|
expect(afterBegin[2].issueId).toBe(issueId);
|
|
expect(afterBegin[2].data).toMatchObject({ number: 1 });
|
|
});
|
|
|
|
test("artifact update emits an artifact.updated event", async () => {
|
|
const t = convexTest({ schema, modules });
|
|
const projectId = await createTestProject(t);
|
|
const issueId = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.projectIssues.create, {
|
|
body: "A representative issue body long enough to pass validation",
|
|
projectId,
|
|
title: "Representative issue",
|
|
});
|
|
|
|
const revision = await t.mutation(api.agentWorkspace.updateArtifact, {
|
|
content: "# Updated work\n\nRevised content.",
|
|
issueId,
|
|
path: "work.md",
|
|
token: FLUE_DB_TOKEN,
|
|
});
|
|
|
|
expect(revision).toBe(3);
|
|
|
|
const events = await eventsForProject(t, projectId);
|
|
const artifactEvent = events.find((e) => e.kind === "artifact.updated");
|
|
expect(artifactEvent).toBeDefined();
|
|
expect(artifactEvent?.issueId).toBe(issueId);
|
|
expect(artifactEvent?.data).toMatchObject({ path: "work.md", revision: 3 });
|
|
});
|
|
|
|
test("agent status emits an agent.<status> event linked to the run", async () => {
|
|
const t = convexTest({ schema, modules });
|
|
const projectId = await createTestProject(t);
|
|
const issueId = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.projectIssues.create, {
|
|
body: "A representative issue body long enough to pass validation",
|
|
projectId,
|
|
title: "Representative issue",
|
|
});
|
|
await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.projectIssues.begin, { issueId });
|
|
|
|
const run = await t.mutation(api.agentWorkspace.ensureRun, {
|
|
daemonId: "daemon-1",
|
|
issueId,
|
|
token: FLUE_DB_TOKEN,
|
|
});
|
|
expect(run).toMatchObject({
|
|
baseBranch: "main",
|
|
checkoutPath: "/workspace/repository",
|
|
sourceUrl: SOURCE.url,
|
|
});
|
|
expect(run?.branchName).toMatch(/^work\/issue-1-/u);
|
|
await t.mutation(api.agentWorkspace.setStatus, {
|
|
issueId,
|
|
status: "completed",
|
|
summary: "Done.",
|
|
token: FLUE_DB_TOKEN,
|
|
});
|
|
|
|
const events = await eventsForProject(t, projectId);
|
|
const workspaceEvent = events.find(
|
|
(e) => e.kind === "agent.workspace.created"
|
|
);
|
|
expect(workspaceEvent).toBeDefined();
|
|
expect(workspaceEvent?.runId).toBeDefined();
|
|
|
|
const statusEvent = events.find((e) => e.kind === "agent.completed");
|
|
expect(statusEvent).toBeDefined();
|
|
expect(statusEvent?.issueId).toBe(issueId);
|
|
expect(statusEvent?.runId).toBe(workspaceEvent?.runId);
|
|
expect(statusEvent?.data).toMatchObject({ summary: "Done." });
|
|
});
|
|
|
|
test("wrong agent token is rejected before any event is written", async () => {
|
|
const t = convexTest({ schema, modules });
|
|
const projectId = await createTestProject(t);
|
|
const issueId = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.projectIssues.create, {
|
|
body: "A representative issue body long enough to pass validation",
|
|
projectId,
|
|
title: "Representative issue",
|
|
});
|
|
|
|
await expect(
|
|
t.mutation(api.agentWorkspace.setStatus, {
|
|
issueId,
|
|
status: "completed",
|
|
summary: "Done.",
|
|
token: "wrong-token",
|
|
})
|
|
).rejects.toThrow(/Invalid agent control token/u);
|
|
|
|
const events = await eventsForProject(t, projectId);
|
|
expect(events.some((e) => e.kind === "agent.completed")).toBe(false);
|
|
});
|
|
|
|
test("Gitea lifecycle persists PR metadata in an event and artifact", async () => {
|
|
const t = convexTest({ schema, modules });
|
|
const projectId = await createTestProject(t);
|
|
const issueId = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.projectIssues.create, {
|
|
body: "A representative issue body long enough to pass validation",
|
|
projectId,
|
|
title: "Representative issue",
|
|
});
|
|
await t.mutation(api.agentWorkspace.ensureRun, {
|
|
daemonId: "daemon-1",
|
|
issueId,
|
|
token: FLUE_DB_TOKEN,
|
|
});
|
|
|
|
await t.mutation(api.agentWorkspace.recordGiteaLifecycle, {
|
|
baseBranch: "main",
|
|
branch: "work/issue-1",
|
|
commitSha: "abc123",
|
|
issueId,
|
|
pullRequest: {
|
|
baseBranch: "main",
|
|
branch: "work/issue-1",
|
|
number: 5,
|
|
status: "open",
|
|
url: "https://git.openputer.com/puter/zopu-code/pulls/5",
|
|
},
|
|
status: "pull_request_open",
|
|
token: FLUE_DB_TOKEN,
|
|
});
|
|
|
|
const events = await eventsForProject(t, projectId);
|
|
const event = events.find(
|
|
(item) => item.kind === "gitea.pull_request.created"
|
|
);
|
|
expect(event?.data).toMatchObject({
|
|
branch: "work/issue-1",
|
|
commitSha: "abc123",
|
|
pullRequest: {
|
|
number: 5,
|
|
status: "open",
|
|
},
|
|
status: "pull_request_open",
|
|
});
|
|
|
|
const artifacts = await t.query((ctx) =>
|
|
ctx.db
|
|
.query("projectArtifacts")
|
|
.withIndex("by_project_and_path", (q) =>
|
|
q.eq("projectId", projectId).eq("path", "artifacts.md")
|
|
)
|
|
.unique()
|
|
);
|
|
expect(artifacts?.content).toContain(
|
|
"https://git.openputer.com/puter/zopu-code/pulls/5"
|
|
);
|
|
});
|
|
});
|