import { env } from "@code/env/convex"; import { projectIssueDraftFromSignal, queueProjectIssue, type ProjectIssueDraft, } from "@code/primitives/project-issue"; import { composeSignal, SignalValidationError } from "@code/primitives/signal"; import { ConvexError, v } from "convex/values"; import { Effect } from "effect"; import type { Doc, Id } from "./_generated/dataModel"; import { mutation, type MutationCtx, query, type QueryCtx, } from "./_generated/server"; // --------------------------------------------------------------------------- // Agent token guard (service-level auth for Zopu tools). // --------------------------------------------------------------------------- const requireAgent = (token: string) => { if (token !== env.FLUE_DB_TOKEN) { throw new ConvexError("Invalid agent control token"); } }; const PROBLEM_STATEMENT = v.object({ title: v.string(), summary: v.string(), desiredOutcome: v.string(), constraints: v.array(v.string()), }); // --------------------------------------------------------------------------- // View helpers // --------------------------------------------------------------------------- interface EvidenceMessage { readonly messageId: string; readonly rawText: string; readonly createdAt: number; readonly submissionId: string | null; } interface ActiveIssueView { readonly _id: Id<"projectIssues">; readonly number: number; readonly title: string; readonly body: string; readonly status: string; readonly projectId: Id<"projects">; readonly updatedAt: number; } interface ProjectSummaryView { readonly _id: Id<"projects">; readonly name: string; readonly description: string | null; readonly organizationId: Id<"organizations">; } interface ContextDocumentView { readonly kind: string; readonly path: string; readonly content: string; readonly revision: number; } interface SignalSummaryView { readonly _id: Id<"signals">; readonly problemStatement: { readonly title: string; readonly summary: string; readonly desiredOutcome: string; readonly constraints: readonly string[]; }; readonly projectId: Id<"projects"> | null; readonly createdAt: number; } // --------------------------------------------------------------------------- // Organization + project authorization (agent-gated, no user identity). // The agent instance id IS the organization id; the tool never supplies // tenancy. We verify the organization exists and the project belongs to it. // --------------------------------------------------------------------------- const authorizeProjectInOrg = async ( ctx: MutationCtx | QueryCtx, organizationId: Id<"organizations">, projectId: Id<"projects"> ): Promise> => { const project = await ctx.db.get(projectId); if (!project) { throw new ConvexError("Project not found"); } if (project.organizationId !== organizationId) { throw new ConvexError("Project does not belong to the target organization"); } return project; }; const authorizeSignalInOrg = async ( ctx: MutationCtx | QueryCtx, organizationId: Id<"organizations">, signalId: Id<"signals"> ): Promise> => { const signal = await ctx.db.get(signalId); if (!signal) { throw new ConvexError("Signal not found"); } if (signal.organizationId !== organizationId) { throw new ConvexError("Signal does not belong to the target organization"); } return signal; }; // --------------------------------------------------------------------------- // 1. List admitted user messages not yet consumed by a Signal. // --------------------------------------------------------------------------- export const listEvidence = query({ args: { organizationId: v.id("organizations"), token: v.string(), }, handler: async (ctx, args): Promise => { requireAgent(args.token); const conversationId = args.organizationId; const admitted = await ctx.db .query("conversationMessages") .withIndex("by_organization_and_message", (q) => q .eq("organizationId", args.organizationId) .eq("conversationId", conversationId) ) .order("desc") .take(100); const candidates = admitted.filter( (message) => message.role === "user" && message.status === "admitted" ); const unused: EvidenceMessage[] = []; for (const message of candidates) { const consumed = await ctx.db .query("signalSources") .withIndex("by_organization_and_message", (q) => q .eq("organizationId", args.organizationId) .eq("conversationId", conversationId) .eq("messageId", message.messageId) ) .first(); if (!consumed) { unused.push({ createdAt: message.createdAt, messageId: message.messageId, rawText: message.rawText, submissionId: message.submissionId ?? null, }); } } return unused; }, }); // --------------------------------------------------------------------------- // 2. Create a Signal from selected messages + structured problem statement. // --------------------------------------------------------------------------- const buildSourceKey = ( organizationId: Id<"organizations">, conversationId: string, messageIds: readonly string[] ): string => JSON.stringify([organizationId, conversationId, ...messageIds]); const resolveSources = async ( ctx: MutationCtx, organizationId: Id<"organizations">, conversationId: string, messageIds: readonly string[] ): Promise[]> => { if (messageIds.length === 0) { throw new ConvexError("At least one source message is required"); } if (new Set(messageIds).size !== messageIds.length) { throw new ConvexError("Source message ids must be unique"); } const resolved: Doc<"conversationMessages">[] = []; for (const messageId of messageIds) { const doc = await ctx.db .query("conversationMessages") .withIndex("by_organization_and_message", (q) => q .eq("organizationId", organizationId) .eq("conversationId", conversationId) .eq("messageId", messageId) ) .unique(); if (!doc) { throw new ConvexError(`Source message not found: ${messageId}`); } if (doc.role !== "user") { throw new ConvexError( `Source message is not a user message: ${messageId}` ); } if (doc.status !== "admitted") { throw new ConvexError(`Source message is not admitted: ${messageId}`); } resolved.push(doc); } return resolved; }; export const createSignal = mutation({ args: { organizationId: v.id("organizations"), projectId: v.optional(v.id("projects")), messageIds: v.array(v.string()), problemStatement: PROBLEM_STATEMENT, processedByAgentInstanceId: v.string(), token: v.string(), }, handler: async (ctx, args): Promise<{ signalId: Id<"signals"> }> => { requireAgent(args.token); if (args.projectId) { await authorizeProjectInOrg(ctx, args.organizationId, args.projectId); } const conversationId = args.organizationId; if (conversationId !== args.organizationId) { throw new ConvexError("Conversation does not belong to organization"); } const sourceKey = buildSourceKey( args.organizationId, conversationId, args.messageIds ); const existing = await ctx.db .query("signals") .withIndex("by_organization_and_sourceKey", (q) => q.eq("organizationId", args.organizationId).eq("sourceKey", sourceKey) ) .unique(); if (existing) { return { signalId: existing._id }; } const sources = await resolveSources( ctx, args.organizationId, conversationId, args.messageIds ); const now = Date.now(); const prospectiveId = crypto.randomUUID(); const scope = args.projectId === undefined ? { _tag: "Organization" as const, organizationId: args.organizationId } : { _tag: "Project" as const, organizationId: args.organizationId, projectId: args.projectId, }; const signal = await Effect.runPromise( composeSignal({ createdAt: now, id: prospectiveId, problemStatement: args.problemStatement, processedBy: { agentInstanceId: args.processedByAgentInstanceId, agentName: "zopu", }, scope, sourceMessages: sources.map((doc) => ({ conversationId: doc.conversationId, createdAt: doc.createdAt, messageId: doc.messageId, rawText: doc.rawText, })), }) ).catch((error: unknown) => { if (error instanceof SignalValidationError) { throw new ConvexError(`Invalid signal: ${error.message}`); } throw new ConvexError( `Invalid signal: ${error instanceof Error ? error.message : "unknown"}` ); }); const signalId = await ctx.db.insert("signals", { conversationId, createdAt: now, organizationId: args.organizationId, problemStatement: { constraints: [...signal.problemStatement.constraints], desiredOutcome: signal.problemStatement.desiredOutcome, summary: signal.problemStatement.summary, title: signal.problemStatement.title, }, processedByAgentInstanceId: args.processedByAgentInstanceId, processedByAgentName: "zopu", ...(args.projectId === undefined ? {} : { projectId: args.projectId }), sourceKey, }); await Promise.all( sources.map((doc, ordinal) => ctx.db.insert("signalSources", { conversationId: doc.conversationId, messageId: doc.messageId, ordinal, organizationId: args.organizationId, rawTextSnapshot: doc.rawText, signalId, ...(doc.submissionId === undefined ? {} : { submissionId: doc.submissionId }), sourceCreatedAt: doc.createdAt, }) ) ); return { signalId }; }, }); // --------------------------------------------------------------------------- // 3. List recent Signals for a project (or organization-wide when no project). // --------------------------------------------------------------------------- export const listSignals = query({ args: { organizationId: v.id("organizations"), projectId: v.optional(v.id("projects")), token: v.string(), }, handler: async (ctx, args): Promise => { requireAgent(args.token); if (args.projectId) { await authorizeProjectInOrg(ctx, args.organizationId, args.projectId); const signals = await ctx.db .query("signals") .withIndex("by_project_and_createdAt", (q) => q.eq("projectId", args.projectId as Id<"projects">) ) .order("desc") .take(20); return signals.map((s) => ({ _id: s._id, createdAt: s.createdAt, problemStatement: s.problemStatement, projectId: s.projectId ?? null, })); } const signals = await ctx.db .query("signals") .withIndex("by_organization_and_createdAt", (q) => q.eq("organizationId", args.organizationId) ) .order("desc") .take(20); return signals.map((s) => ({ _id: s._id, createdAt: s.createdAt, problemStatement: s.problemStatement, projectId: s.projectId ?? null, })); }, }); // --------------------------------------------------------------------------- // 4. List active (open/queued/working/needs-input) ProjectIssues for a project. // --------------------------------------------------------------------------- export const listActiveIssues = query({ args: { organizationId: v.id("organizations"), projectId: v.id("projects"), token: v.string(), }, handler: async (ctx, args): Promise => { requireAgent(args.token); await authorizeProjectInOrg(ctx, args.organizationId, args.projectId); const issues = await ctx.db .query("projectIssues") .withIndex("by_project_and_status", (q) => q.eq("projectId", args.projectId) ) .take(100); return issues .filter( (issue) => issue.status === "open" || issue.status === "queued" || issue.status === "working" || issue.status === "needs-input" ) .map((issue) => ({ _id: issue._id, body: issue.body, number: issue.number, projectId: issue.projectId, status: issue.status, title: issue.title, updatedAt: issue.updatedAt, })) .sort((a, b) => b.updatedAt - a.updatedAt); }, }); // --------------------------------------------------------------------------- // 5. Attach a Signal to an existing ProjectIssue (idempotent). // --------------------------------------------------------------------------- export const attachSignalToIssue = mutation({ args: { organizationId: v.id("organizations"), signalId: v.id("signals"), issueId: v.id("projectIssues"), token: v.string(), }, handler: async ( ctx, args ): Promise<{ attachmentId: Id<"signalIssueAttachments">; alreadyAttached: boolean; }> => { requireAgent(args.token); const signal = await authorizeSignalInOrg( ctx, args.organizationId, args.signalId ); const issue = await ctx.db.get(args.issueId); if (!issue) { throw new ConvexError("Issue not found"); } await authorizeProjectInOrg(ctx, args.organizationId, issue.projectId); // Cross-project rejection: the signal must be project-scoped and its // project must match the issue's project. Organization equality alone is // insufficient — it would permit attaching across different projects in // the same org. if (!signal.projectId) { throw new ConvexError("Signal is not project-scoped"); } if (signal.projectId !== issue.projectId) { throw new ConvexError("Signal and issue must belong to the same project"); } // Idempotent: if the attachment already exists, return it without // emitting a duplicate event. const existing = await ctx.db .query("signalIssueAttachments") .withIndex("by_signal_and_issue", (q) => q.eq("signalId", args.signalId).eq("issueId", args.issueId) ) .unique(); if (existing) { return { alreadyAttached: true, attachmentId: existing._id, }; } const now = Date.now(); const attachmentId = await ctx.db.insert("signalIssueAttachments", { createdAt: now, issueId: args.issueId, organizationId: args.organizationId, projectId: issue.projectId, signalId: args.signalId, }); await ctx.db.insert("projectEvents", { createdAt: now, data: { signalProblemTitle: signal.problemStatement.title, signalId: String(signal._id), }, issueId: args.issueId, kind: "signal.attached", projectId: issue.projectId, }); return { alreadyAttached: false, attachmentId }; }, }); // --------------------------------------------------------------------------- // 6. Create a new ProjectIssue from a Signal. // --------------------------------------------------------------------------- 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, }); await ctx.db.insert("projectEvents", { createdAt: timestamp, data: { number, source: "signal", title: draft.title }, issueId, kind: "issue.created", projectId, }); return issueId; }; export const createIssueFromSignal = mutation({ args: { organizationId: v.id("organizations"), signalId: v.id("signals"), token: v.string(), }, handler: async ( ctx, args ): Promise<{ created: boolean; issueId: Id<"projectIssues">; projectId: Id<"projects">; }> => { requireAgent(args.token); const signal = await authorizeSignalInOrg( ctx, args.organizationId, args.signalId ); if (!signal.projectId) { throw new ConvexError("Signal is not project-scoped"); } await authorizeProjectInOrg(ctx, args.organizationId, signal.projectId); // Idempotent: if any attachment already exists for this signal, return // the existing issue without creating a duplicate. This prevents Flue // retries from producing duplicate issues/attachments. const existingAttachment = await ctx.db .query("signalIssueAttachments") .withIndex("by_signal", (q) => q.eq("signalId", args.signalId)) .first(); if (existingAttachment) { return { created: false, issueId: existingAttachment.issueId, projectId: existingAttachment.projectId, }; } const draft = await Effect.runPromise( projectIssueDraftFromSignal({ problemStatement: signal.problemStatement, signalId: String(signal._id), }) ).catch((error: unknown) => { throw new ConvexError( error instanceof Error ? error.message : "Invalid project issue" ); }); const issueId = await insertIssue(ctx, signal.projectId, draft); // Auto-attach the signal to the new issue. const now = Date.now(); await ctx.db.insert("signalIssueAttachments", { createdAt: now, issueId, organizationId: args.organizationId, projectId: signal.projectId, signalId: signal._id, }); return { created: true, issueId, projectId: signal.projectId }; }, }); // --------------------------------------------------------------------------- // 7. Begin a ProjectIssue (transition to queued/working). // --------------------------------------------------------------------------- export const beginIssue = mutation({ args: { organizationId: v.id("organizations"), issueId: v.id("projectIssues"), token: v.string(), }, handler: async (ctx, args): Promise<"queued" | "working"> => { requireAgent(args.token); const issue = await ctx.db.get(args.issueId); if (!issue) { throw new ConvexError("Issue not found"); } await authorizeProjectInOrg(ctx, args.organizationId, issue.projectId); const status = await Effect.runPromise( queueProjectIssue(issue.status) ).catch((error: unknown) => { throw new ConvexError( error instanceof Error ? error.message : "Invalid issue status" ); }); if (status === issue.status) { return status; } const timestamp = Date.now(); await ctx.db.patch(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 status; }, }); // --------------------------------------------------------------------------- // 8. Get project summary + context documents for routing decisions. // --------------------------------------------------------------------------- export const getProjectContext = query({ args: { organizationId: v.id("organizations"), projectId: v.id("projects"), token: v.string(), }, handler: async ( ctx, args ): Promise<{ project: ProjectSummaryView; contextDocuments: ContextDocumentView[]; }> => { requireAgent(args.token); const project = await authorizeProjectInOrg( ctx, args.organizationId, args.projectId ); const docs = await ctx.db .query("projectContextDocuments") .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) .take(25); return { contextDocuments: docs.map((doc) => ({ content: doc.content, kind: doc.kind, path: doc.path, revision: doc.revision, })), project: { _id: project._id, description: project.description ?? null, name: project.name, organizationId: project.organizationId, }, }; }, }); // --------------------------------------------------------------------------- // 9. List projects in the organization (for selecting a project context). // --------------------------------------------------------------------------- export const listProjects = query({ args: { organizationId: v.id("organizations"), token: v.string(), }, handler: async (ctx, args): Promise => { requireAgent(args.token); const projects = await ctx.db .query("projects") .withIndex("by_organization_and_createdAt", (q) => q.eq("organizationId", args.organizationId) ) .take(50); return projects.map((p) => ({ _id: p._id, description: p.description ?? null, name: p.name, organizationId: p.organizationId, })); }, });