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 => { try { return await Effect.runPromise(validateProjectIssueDraft(input)); } catch (error) { throw toDomainError(error); } }; const draftFromSignal = async (input: unknown): Promise => { try { return await Effect.runPromise(projectIssueDraftFromSignal(input)); } catch (error) { throw toDomainError(error); } }; const insertIssue = async ( ctx: MutationCtx, projectId: Id<"projects">, draft: ProjectIssueDraft ): Promise> => { 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); }, });