import { ConvexError, v } from "convex/values"; import type { Id } from "./_generated/dataModel"; import { mutation, query } from "./_generated/server"; import type { MutationCtx } from "./_generated/server"; import { requireOwnerId } from "./authz"; const requireProjectOwner = async ( ctx: MutationCtx, projectId: Id<"projects">, ownerId: string ) => { const project = await ctx.db.get("projects", projectId); if (!project || project.ownerId !== ownerId) { throw new ConvexError("Project not found"); } return project; }; export const create = mutation({ args: { body: v.string(), projectId: v.id("projects"), title: v.string(), }, handler: async (ctx, args) => { const ownerId = await requireOwnerId(ctx); await requireProjectOwner(ctx, args.projectId, ownerId); const title = args.title.trim(); const body = args.body.trim(); if (title.length < 3 || title.length > 160) { throw new ConvexError("Issue title must be between 3 and 160 characters"); } if (body.length < 10 || body.length > 10_000) { throw new ConvexError( "Issue description must be between 10 and 10000 characters" ); } const latest = await ctx.db .query("projectIssues") .withIndex("by_project_and_number", (q) => q.eq("projectId", args.projectId) ) .order("desc") .first(); const timestamp = Date.now(); const number = (latest?.number ?? 0) + 1; const issueId = await ctx.db.insert("projectIssues", { body, createdAt: timestamp, number, projectId: args.projectId, status: "open", title, updatedAt: timestamp, }); const workArtifact = await ctx.db .query("projectArtifacts") .withIndex("by_project_and_path", (q) => q.eq("projectId", args.projectId).eq("path", "work.md") ) .unique(); if (workArtifact) { await ctx.db.patch("projectArtifacts", workArtifact._id, { content: `${workArtifact.content}\n## Issue ${number}: ${title}\n\nStatus: open\n\n${body}\n`, revision: workArtifact.revision + 1, updatedAt: timestamp, }); } await ctx.db.insert("projectSignals", { createdAt: timestamp, data: { number, title }, issueId, kind: "issue.created", projectId: args.projectId, }); return issueId; }, }); export const begin = mutation({ args: { issueId: v.id("projectIssues") }, handler: async (ctx, args) => { const ownerId = await requireOwnerId(ctx); const issue = await ctx.db.get("projectIssues", args.issueId); if (!issue) { throw new ConvexError("Issue not found"); } await requireProjectOwner(ctx, issue.projectId, ownerId); if (issue.status === "queued" || issue.status === "working") { return issue.status; } const timestamp = Date.now(); await ctx.db.patch("projectIssues", issue._id, { status: "queued", updatedAt: timestamp, }); await ctx.db.insert("projectSignals", { 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 ownerId = await requireOwnerId(ctx); const issue = await ctx.db.get("projectIssues", args.issueId); if (!issue) { throw new ConvexError("Issue not found"); } await requireProjectOwner(ctx, issue.projectId, ownerId); const timestamp = Date.now(); await ctx.db.patch("projectIssues", issue._id, { status: "failed", updatedAt: timestamp, }); await ctx.db.insert("projectSignals", { 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) => { const ownerId = await requireOwnerId(ctx); const project = await ctx.db.get("projects", args.projectId); if (!project || project.ownerId !== ownerId) { return []; } return await ctx.db .query("projectIssues") .withIndex("by_project_and_number", (q) => q.eq("projectId", args.projectId) ) .order("desc") .take(100); }, });