import { validateProjectInstructions } from "@code/primitives/git-provisioning"; import { decodePublicGitImportResult, preparePublicGitSource, } from "@code/primitives/project"; import type { ProjectImportOutcome, ProjectView, } from "@code/primitives/project"; import { makeFunctionReference } from "convex/server"; import { ConvexError, v } from "convex/values"; import { Effect } from "effect"; import { internal } from "./_generated/api"; import type { Doc, Id } from "./_generated/dataModel"; import { action, internalMutation, mutation, query } from "./_generated/server"; import { requireAuthUserId, requireCurrentOrganization, requireProjectMember, } from "./authz"; import { conversationAgentId } from "./conversationAgents"; import { inspectPublicGit } from "./publicGit"; const runSetupRef = makeFunctionReference< "action", { agentId: string; correlationId: string; organizationId: Id<"organizations">; projectId: Id<"projects">; }, unknown >("projectSetup:runSetup"); const retryRuntimeRef = makeFunctionReference< "mutation", { runtimeRowId: Id<"projectRuntimes"> }, { retried: boolean; runtime: Doc<"projectRuntimes"> } >("projectRuntimes:retry"); const getRetryableRuntimeRef = makeFunctionReference< "query", { projectId: Id<"projects">; userId: string }, { organizationId: Id<"organizations">; runtime: Doc<"projectRuntimes"> | null; } >("projectSetupQueries:getRetryableRuntime"); const toProjectView = async ( ctx: Parameters[0], project: Doc<"projects"> ): Promise => { const documents = await ctx.db .query("projectContextDocuments") .withIndex("by_projectId_and_path", (q) => q.eq("projectId", project._id)) .collect(); const view = { contextDocuments: documents.map((document) => ({ content: document.content, kind: document.kind, origin: document.origin, path: document.path, revision: 1, sourceUrl: document.sourceUrl, })), createdAt: project.createdAt, id: String(project._id), name: project.name, organizationId: String(project.organizationId), sources: [ { createdAt: project.createdAt, defaultBranch: project.defaultBranch, host: project.sourceHost, kind: "git", normalizedUrl: project.normalizedSourceUrl, projectId: String(project._id), repositoryPath: project.repositoryPath, updatedAt: project.updatedAt, url: project.sourceUrl, }, ], updatedAt: project.updatedAt, }; return view as unknown as ProjectView; }; export const persistPublicGitImport = internalMutation({ args: { remote: v.object({ defaultBranch: v.optional(v.string()), documents: v.array( v.object({ content: v.string(), kind: v.union( v.literal("readme"), v.literal("agents"), v.literal("product"), v.literal("business"), v.literal("design"), v.literal("tech") ), path: v.string(), }) ), warnings: v.array(v.object({ message: v.string(), path: v.string() })), }), source: v.object({ host: v.string(), normalizedUrl: v.string(), projectName: v.string(), repositoryPath: v.string(), url: v.string(), }), userId: v.string(), }, handler: async (ctx, args): Promise => { const organization = await ctx.db .query("organizations") .withIndex("by_createdBy_and_kind", (q) => q.eq("createdBy", args.userId).eq("kind", "personal") ) .unique(); if (!organization) { throw new ConvexError("Organization not found"); } const timestamp = Date.now(); const existing = await ctx.db .query("projects") .withIndex("by_organizationId_and_normalizedSourceUrl", (q) => q .eq("organizationId", organization._id) .eq("normalizedSourceUrl", args.source.normalizedUrl) ) .unique(); let projectId: Id<"projects">; if (existing) { projectId = existing._id; await ctx.db.patch(projectId, { defaultBranch: args.remote.defaultBranch, name: args.source.projectName, repositoryPath: args.source.repositoryPath, sourceHost: args.source.host, sourceUrl: args.source.url, updatedAt: timestamp, }); const oldDocuments = await ctx.db .query("projectContextDocuments") .withIndex("by_projectId_and_path", (q) => q.eq("projectId", projectId)) .collect(); for (const document of oldDocuments) { await ctx.db.delete(document._id); } } else { projectId = await ctx.db.insert("projects", { createdAt: timestamp, defaultBranch: args.remote.defaultBranch, name: args.source.projectName, normalizedSourceUrl: args.source.normalizedUrl, organizationId: organization._id, repositoryPath: args.source.repositoryPath, sourceHost: args.source.host, sourceUrl: args.source.url, updatedAt: timestamp, }); } for (const document of args.remote.documents) { await ctx.db.insert("projectContextDocuments", { ...document, createdAt: timestamp, origin: "repository", projectId, sourceUrl: args.source.url, updatedAt: timestamp, }); } const project = await ctx.db.get(projectId); if (!project) { throw new Error("Project could not be read after import"); } const view = await toProjectView(ctx, project); return { ...view, source: view.sources[0]! }; }, }); export const list = query({ args: {}, handler: async (ctx): Promise => { const { organizationId } = await requireCurrentOrganization(ctx); const projects = await ctx.db .query("projects") .withIndex("by_organizationId_and_createdAt", (q) => q.eq("organizationId", organizationId) ) .order("desc") .take(50); return await Promise.all( projects.map((project) => toProjectView(ctx, project)) ); }, }); export const get = query({ args: { projectId: v.id("projects") }, handler: async (ctx, args): Promise => { const { organizationId } = await requireCurrentOrganization(ctx); const project = await ctx.db.get(args.projectId); return project?.organizationId === organizationId ? await toProjectView(ctx, project) : null; }, }); export const getSetup = query({ args: { projectId: v.id("projects") }, handler: async (ctx, args) => { const { organizationId } = await requireProjectMember(ctx, args.projectId); const events = await ctx.db .query("events") .withIndex("by_organizationId_and_projectId_and_recordedAt", (q) => q.eq("organizationId", organizationId).eq("projectId", args.projectId) ) .order("desc") .filter((q) => q.or( // Lifecycle event types emitted by the project-setup coordinator. q.eq(q.field("type"), "project.setup.creating_vm"), q.eq(q.field("type"), "project.setup.cloning"), q.eq(q.field("type"), "project.setup.checking_repository"), q.eq(q.field("type"), "project.ready"), q.eq(q.field("type"), "project.setup.failed"), // Legacy / environment / preview event types (compatible). q.eq(q.field("type"), "project.setup.started"), q.eq(q.field("type"), "project.setup.environment_required"), q.eq(q.field("type"), "project.setup.environment_updated"), q.eq(q.field("type"), "project.setup.ready"), q.eq(q.field("type"), "project.setup.blocked"), q.eq(q.field("type"), "project.preview.ready"), q.eq(q.field("type"), "project.preview.blocked") ) ) .take(50); const artifacts = await ctx.db .query("artifacts") .withIndex("by_projectId_and_createdAt", (q) => q.eq("projectId", args.projectId) ) .order("desc") .filter((q) => q.or( q.eq(q.field("kind"), "project_setup"), q.eq(q.field("kind"), "blocker"), q.eq(q.field("kind"), "preview") ) ) .take(20); const runtime = await ctx.db .query("projectRuntimes") .withIndex("by_projectId", (q) => q.eq("projectId", args.projectId)) .unique(); return { artifacts, events, runtime }; }, }); export const appendSetupEvent = internalMutation({ args: { artifactIds: v.optional(v.array(v.id("artifacts"))), correlationId: v.string(), idempotencyKey: v.string(), payload: v.any(), projectId: v.id("projects"), type: v.union( // Lifecycle event types emitted by the project-setup coordinator. v.literal("project.setup.creating_vm"), v.literal("project.setup.cloning"), v.literal("project.setup.checking_repository"), v.literal("project.ready"), v.literal("project.setup.failed"), // Legacy / environment / preview event types (compatible). v.literal("project.setup.started"), v.literal("project.setup.environment_required"), v.literal("project.setup.environment_updated"), v.literal("project.setup.ready"), v.literal("project.setup.blocked"), v.literal("project.preview.ready"), v.literal("project.preview.blocked") ), visibility: v.union( v.literal("timeline"), v.literal("compact"), v.literal("internal") ), }, handler: async (ctx, args): Promise> => { const project = await ctx.db.get(args.projectId); if (!project) { throw new ConvexError("Project not found"); } const existing = await ctx.db .query("events") .withIndex("by_organizationId_and_idempotencyKey", (q) => q .eq("organizationId", project.organizationId) .eq("idempotencyKey", args.idempotencyKey) ) .unique(); if (existing) { return existing._id; } return await ctx.db.insert("events", { actor: { kind: "system", service: "project-setup" }, artifactIds: args.artifactIds, correlationId: args.correlationId, idempotencyKey: args.idempotencyKey, occurredAt: Date.now(), organizationId: project.organizationId, payload: args.payload, projectId: args.projectId, recordedAt: Date.now(), scopeKind: "global", type: args.type, visibility: args.visibility, }); }, }); export const importPublicGit = action({ args: { repositoryUrl: v.string() }, handler: async (ctx, args): Promise => { const userId = await requireAuthUserId(ctx); const source = await Effect.runPromise( preparePublicGitSource(args.repositoryUrl) ); const remote = await Effect.runPromise( decodePublicGitImportResult(await inspectPublicGit(source)) ); const outcome = await ctx.runMutation( internal.projects.persistPublicGitImport, { remote: { defaultBranch: remote.defaultBranch, documents: remote.documents.map((document) => ({ ...document })), warnings: remote.warnings.map((warning) => ({ ...warning })), }, source, userId, } ); // Kick off the idempotent project setup coordinator (AgentOS VM + clone + // readiness + project.ready + agent dispatch). Scheduled as an internal // action so retries are safe: every step is keyed idempotently. const projectId = outcome.id as unknown as Id<"projects">; const organizationId = outcome.organizationId as unknown as Id<"organizations">; const agentId = conversationAgentId(organizationId, projectId); await ctx.scheduler.runAfter(0, runSetupRef, { agentId, correlationId: `import:${projectId}`, organizationId, projectId, }); return outcome; }, }); /** * Retry a failed project setup. Authenticated and project-member scoped: only a * failed ProjectRuntime is reset to "requested" with its durable attempt * incremented, after which the existing `projectSetup:runSetup` coordinator is * scheduled. A non-failed runtime (including an already-ready one) is left * untouched and re-running is skipped. The new attempt's lifecycle/ready/failed * events use distinct per-attempt idempotency keys, so they never collide with * or are suppressed by prior-attempt events; history is preserved. * * Returns `{ retried, attempt }`: `retried` is true only when a failed runtime * was actually reset; `attempt` is the runtime's current attempt number. */ export const retrySetup = action({ args: { projectId: v.id("projects") }, handler: async ( ctx, args ): Promise<{ retried: boolean; attempt: number }> => { const userId = await requireAuthUserId(ctx); const { organizationId, runtime } = await ctx.runQuery( getRetryableRuntimeRef, { projectId: args.projectId, userId } ); if (!runtime) { throw new ConvexError("Project has no setup runtime to retry"); } const { retried, runtime: updated } = await ctx.runMutation( retryRuntimeRef, { runtimeRowId: runtime._id } ); if (!retried) { return { attempt: updated.attempt ?? 1, retried: false }; } const attempt = updated.attempt ?? 1; const agentId = conversationAgentId(organizationId, args.projectId); await ctx.scheduler.runAfter(0, runSetupRef, { agentId, correlationId: `retry:${args.projectId}:attempt:${attempt}`, organizationId, projectId: args.projectId, }); return { attempt, retried: true }; }, }); export const updateInstructions = mutation({ args: { instructions: v.string(), projectId: v.id("projects"), }, handler: async (ctx, args) => { await requireProjectMember(ctx, args.projectId); const validated = await Effect.runPromise( validateProjectInstructions(args.instructions) ); await ctx.db.patch(args.projectId, { instructions: validated, updatedAt: Date.now(), }); return { updated: true }; }, }); export const getInstructions = query({ args: { projectId: v.id("projects") }, handler: async (ctx, args) => { await requireProjectMember(ctx, args.projectId); const project = await ctx.db.get(args.projectId); return project?.instructions ?? ""; }, });