436 lines
14 KiB
TypeScript
436 lines
14 KiB
TypeScript
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<typeof requireCurrentOrganization>[0],
|
|
project: Doc<"projects">
|
|
): Promise<ProjectView> => {
|
|
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<ProjectImportOutcome> => {
|
|
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<ProjectView[]> => {
|
|
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<ProjectView | null> => {
|
|
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<Id<"events">> => {
|
|
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<ProjectImportOutcome> => {
|
|
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 ?? "";
|
|
},
|
|
});
|