# Conflicts: # bun.lock # packages/agents/package.json # packages/agents/src/agents/project-manager.ts # packages/backend/convex/agentWorkspace.ts
212 lines
5.8 KiB
TypeScript
212 lines
5.8 KiB
TypeScript
import {
|
|
projectIssueDraftFromSignal,
|
|
queueProjectIssue,
|
|
validateProjectIssueDraft,
|
|
type ProjectIssueDraft,
|
|
} from "@code/primitives/project-issue";
|
|
import { ConvexError, v } from "convex/values";
|
|
import { Effect } from "effect";
|
|
|
|
import type { Id } from "./_generated/dataModel";
|
|
import { mutation, type MutationCtx, query } from "./_generated/server";
|
|
import { requireProjectMember } from "./authz";
|
|
|
|
const toDomainError = (error: unknown) => {
|
|
return new ConvexError(
|
|
error instanceof Error ? error.message : "Invalid project issue"
|
|
);
|
|
};
|
|
|
|
const decodeDraft = async (input: unknown): Promise<ProjectIssueDraft> => {
|
|
try {
|
|
return await Effect.runPromise(validateProjectIssueDraft(input));
|
|
} catch (error) {
|
|
throw toDomainError(error);
|
|
}
|
|
};
|
|
|
|
const draftFromSignal = async (input: unknown): Promise<ProjectIssueDraft> => {
|
|
try {
|
|
return await Effect.runPromise(projectIssueDraftFromSignal(input));
|
|
} catch (error) {
|
|
throw toDomainError(error);
|
|
}
|
|
};
|
|
|
|
const insertIssue = async (
|
|
ctx: MutationCtx,
|
|
projectId: Id<"projects">,
|
|
draft: ProjectIssueDraft
|
|
): Promise<Id<"projectIssues">> => {
|
|
const latest = await ctx.db
|
|
.query("projectIssues")
|
|
.withIndex("by_project_and_number", (q) => q.eq("projectId", projectId))
|
|
.order("desc")
|
|
.first();
|
|
const timestamp = Date.now();
|
|
const number = (latest?.number ?? 0) + 1;
|
|
const issueId = await ctx.db.insert("projectIssues", {
|
|
body: draft.body,
|
|
createdAt: timestamp,
|
|
number,
|
|
projectId,
|
|
status: "open",
|
|
title: draft.title,
|
|
updatedAt: timestamp,
|
|
});
|
|
const workArtifact = await ctx.db
|
|
.query("projectArtifacts")
|
|
.withIndex("by_project_and_path", (q) =>
|
|
q.eq("projectId", projectId).eq("path", "work.md")
|
|
)
|
|
.unique();
|
|
if (workArtifact) {
|
|
await ctx.db.patch("projectArtifacts", workArtifact._id, {
|
|
content: `${workArtifact.content}\n## Issue ${number}: ${draft.title}\n\nStatus: open\n\n${draft.body}\n`,
|
|
revision: workArtifact.revision + 1,
|
|
updatedAt: timestamp,
|
|
});
|
|
}
|
|
await ctx.db.insert("projectEvents", {
|
|
createdAt: timestamp,
|
|
data: { number, title: draft.title },
|
|
issueId,
|
|
kind: "issue.created",
|
|
projectId,
|
|
});
|
|
return issueId;
|
|
};
|
|
|
|
export const create = mutation({
|
|
args: {
|
|
body: v.string(),
|
|
projectId: v.id("projects"),
|
|
title: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await requireProjectMember(ctx, args.projectId);
|
|
const draft = await decodeDraft({ body: args.body, title: args.title });
|
|
return insertIssue(ctx, args.projectId, draft);
|
|
},
|
|
});
|
|
|
|
export const createFromSignal = mutation({
|
|
args: { signalId: v.id("signals") },
|
|
handler: async (ctx, args) => {
|
|
const signal = await ctx.db.get("signals", args.signalId);
|
|
if (!signal) {
|
|
throw new ConvexError("Signal not found");
|
|
}
|
|
if (!signal.projectId) {
|
|
throw new ConvexError("Signal is not project-scoped");
|
|
}
|
|
const { organizationId } = await requireProjectMember(
|
|
ctx,
|
|
signal.projectId
|
|
);
|
|
if (signal.organizationId !== organizationId) {
|
|
throw new ConvexError(
|
|
"Signal does not belong to the project organization"
|
|
);
|
|
}
|
|
const draft = await draftFromSignal({
|
|
problemStatement: signal.problemStatement,
|
|
signalId: String(signal._id),
|
|
});
|
|
const issueId = await insertIssue(ctx, signal.projectId, draft);
|
|
return { issueId, projectId: signal.projectId };
|
|
},
|
|
});
|
|
|
|
export const begin = mutation({
|
|
args: { issueId: v.id("projectIssues") },
|
|
handler: async (ctx, args) => {
|
|
const issue = await ctx.db.get("projectIssues", args.issueId);
|
|
if (!issue) {
|
|
throw new ConvexError("Issue not found");
|
|
}
|
|
await requireProjectMember(ctx, issue.projectId);
|
|
let status: "queued" | "working";
|
|
try {
|
|
status = await Effect.runPromise(queueProjectIssue(issue.status));
|
|
} catch (error) {
|
|
throw toDomainError(error);
|
|
}
|
|
if (status === issue.status) {
|
|
return status;
|
|
}
|
|
const timestamp = Date.now();
|
|
await ctx.db.patch("projectIssues", issue._id, {
|
|
status,
|
|
updatedAt: timestamp,
|
|
});
|
|
await ctx.db.insert("projectEvents", {
|
|
createdAt: timestamp,
|
|
data: { number: issue.number },
|
|
issueId: issue._id,
|
|
kind: "issue.queued",
|
|
projectId: issue.projectId,
|
|
});
|
|
return "queued" as const;
|
|
},
|
|
});
|
|
|
|
export const markDispatchFailed = mutation({
|
|
args: { error: v.string(), issueId: v.id("projectIssues") },
|
|
handler: async (ctx, args) => {
|
|
const issue = await ctx.db.get("projectIssues", args.issueId);
|
|
if (!issue) {
|
|
throw new ConvexError("Issue not found");
|
|
}
|
|
await requireProjectMember(ctx, issue.projectId);
|
|
const timestamp = Date.now();
|
|
await ctx.db.patch("projectIssues", issue._id, {
|
|
status: "failed",
|
|
updatedAt: timestamp,
|
|
});
|
|
await ctx.db.insert("projectEvents", {
|
|
createdAt: timestamp,
|
|
data: { error: args.error.slice(0, 1000), phase: "dispatch" },
|
|
issueId: issue._id,
|
|
kind: "agent.failed",
|
|
projectId: issue.projectId,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const list = query({
|
|
args: { projectId: v.id("projects") },
|
|
handler: async (ctx, args) => {
|
|
try {
|
|
await requireProjectMember(ctx, args.projectId);
|
|
} catch {
|
|
return [];
|
|
}
|
|
return await ctx.db
|
|
.query("projectIssues")
|
|
.withIndex("by_project_and_number", (q) =>
|
|
q.eq("projectId", args.projectId)
|
|
)
|
|
.order("desc")
|
|
.take(100);
|
|
},
|
|
});
|
|
|
|
export const events = query({
|
|
args: { projectId: v.id("projects") },
|
|
handler: async (ctx, args) => {
|
|
try {
|
|
await requireProjectMember(ctx, args.projectId);
|
|
} catch {
|
|
return [];
|
|
}
|
|
return await ctx.db
|
|
.query("projectEvents")
|
|
.withIndex("by_project_and_createdAt", (q) =>
|
|
q.eq("projectId", args.projectId)
|
|
)
|
|
.order("desc")
|
|
.take(100);
|
|
},
|
|
});
|