feat: add Convex project backend with generic storage and org-scoped authz
Schema:
- Cut projects table to {organizationId, name, description, createdAt, updatedAt}
- Add projectSources table with git source metadata and normalized URL index
- Add projectContextDocuments table with six canonical kinds, origin, revision
- Add by_organizationId index for project enumeration
- Remove all GitHub-specific fields (ownerId, provider, repoOwner, etc.)
Backend:
- Add requireCurrentOrganization and requireProjectMember authz helpers
- Migrate signals.ts authorizeProject to organizationId invariant check
- Update artifactModel: 8 operational artifacts (removed project/business/design.md)
- Add projectStore.ts with query/mutation/action store adapters
- Rewrite projects.ts to thin generic application calls
- Update projectIssues/projectArtifacts to use requireProjectMember
- Update projectEvents.test.ts and signals.test.ts for new project creation
All 33 backend tests pass.
This commit is contained in:
@@ -1,9 +1,6 @@
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
export const ARTIFACT_PATHS = [
|
export const ARTIFACT_PATHS = [
|
||||||
"project.md",
|
|
||||||
"business.md",
|
|
||||||
"design.md",
|
|
||||||
"agent.md",
|
"agent.md",
|
||||||
"work.md",
|
"work.md",
|
||||||
"steps.md",
|
"steps.md",
|
||||||
@@ -17,9 +14,6 @@ export const ARTIFACT_PATHS = [
|
|||||||
export type ArtifactPath = (typeof ARTIFACT_PATHS)[number];
|
export type ArtifactPath = (typeof ARTIFACT_PATHS)[number];
|
||||||
|
|
||||||
export const artifactPath = v.union(
|
export const artifactPath = v.union(
|
||||||
v.literal("project.md"),
|
|
||||||
v.literal("business.md"),
|
|
||||||
v.literal("design.md"),
|
|
||||||
v.literal("agent.md"),
|
v.literal("agent.md"),
|
||||||
v.literal("work.md"),
|
v.literal("work.md"),
|
||||||
v.literal("steps.md"),
|
v.literal("steps.md"),
|
||||||
@@ -30,36 +24,13 @@ export const artifactPath = v.union(
|
|||||||
v.literal("card.md")
|
v.literal("card.md")
|
||||||
);
|
);
|
||||||
|
|
||||||
interface ProjectSeed {
|
export const createInitialArtifacts = (): readonly {
|
||||||
readonly defaultBranch: string;
|
path: ArtifactPath;
|
||||||
readonly description?: string;
|
content: string;
|
||||||
readonly name: string;
|
}[] => [
|
||||||
readonly repoName: string;
|
|
||||||
readonly repoOwner: string;
|
|
||||||
readonly repoUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createInitialArtifacts = (
|
|
||||||
project: ProjectSeed
|
|
||||||
): readonly { path: ArtifactPath; content: string }[] => {
|
|
||||||
const repository = `${project.repoOwner}/${project.repoName}`;
|
|
||||||
const purpose = project.description ?? `Maintain and improve ${repository}.`;
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
{
|
||||||
content: `# ${project.name}\n\nRepository: [${repository}](${project.repoUrl})\n\n## Project knowledge\n\n- [Business](business.md)\n- [Design](design.md)\n- [Agent](agent.md)\n\n## Delivery\n\n- [Work](work.md)\n- [Steps](steps.md)\n- [Artifacts](artifacts.md)\n`,
|
content:
|
||||||
path: "project.md",
|
"# Agent\n\n## Role\n\nOwn one project issue at a time. Read the project artifacts before editing code, ask a focused question when blocked, and support completion claims with command output.\n\n## Guardrails\n\n- Keep issue work isolated in its AgentOS workspace.\n- Update work.md, steps.md, artifacts.md, and context.md as facts change.\n- Never mark work complete without a relevant runtime check.\n",
|
||||||
},
|
|
||||||
{
|
|
||||||
content: `# Business\n\n## Purpose\n\n${purpose}\n\n## Source of truth\n\nThe connected GitHub repository and its project issues define the current product work.\n`,
|
|
||||||
path: "business.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
content: `# Design\n\n## Repository boundary\n\nWork targets \`${repository}\` on \`${project.defaultBranch}\`. Preserve its established architecture and conventions before introducing new patterns.\n\n## Decision record\n\nRecord issue-specific architecture decisions here when they affect later work.\n`,
|
|
||||||
path: "design.md",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
content: `# Agent\n\n## Role\n\nOwn one project issue at a time. Read the project artifacts before editing code, ask a focused question when blocked, and support completion claims with command output.\n\n## Guardrails\n\n- Keep issue work isolated in its AgentOS workspace.\n- Update work.md, steps.md, artifacts.md, and context.md as facts change.\n- Never mark work complete without a relevant runtime check.\n`,
|
|
||||||
path: "agent.md",
|
path: "agent.md",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -88,13 +59,13 @@ export const createInitialArtifacts = (
|
|||||||
path: "agent-manager.md",
|
path: "agent-manager.md",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
content: `# Context\n\n- Provider: GitHub\n- Repository: ${repository}\n- URL: ${project.repoUrl}\n- Default branch: ${project.defaultBranch}\n\nIssue-specific facts belong below this repository context.\n`,
|
content:
|
||||||
|
"# Context\n\nCanonical project knowledge is staged under /workspace/context. Issue-specific facts belong below.\n",
|
||||||
path: "context.md",
|
path: "context.md",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
content:
|
content:
|
||||||
"# Project card\n\nThe web project card is the interface between UI and packages. It reads `projects.list`, `project-artifacts.list`, and `project-issues.list`; it writes through `projects.connectGitHub`, `project-issues.create`, and `project-issues.begin`. The issue-scoped Flue agent reads `agent-workspace.get`, publishes with `agent-workspace.updateArtifact`, and reports state with `agent-workspace.setStatus`. Filesystem and shell operations are executed by the AgentOS sandbox adapter through durable daemon commands.\n",
|
"# Project card\n\nThe web project workspace reads and writes Projects, context documents, artifacts, and issues through the generated Convex interface. Agent work uses the issue-scoped workspace and durable daemon commands.\n",
|
||||||
path: "card.md",
|
path: "card.md",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
|
||||||
|
|||||||
@@ -40,3 +40,41 @@ export const requireOrganizationMember = async (
|
|||||||
}
|
}
|
||||||
return userId;
|
return userId;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the authenticated user's personal/current organization. Browsers
|
||||||
|
* never submit organization IDs for Project commands; this resolves the
|
||||||
|
* tenant boundary server-side through the authenticated identity.
|
||||||
|
*/
|
||||||
|
export const requireCurrentOrganization = async (
|
||||||
|
ctx: QueryCtx | MutationCtx
|
||||||
|
): Promise<{ organizationId: Id<"organizations">; userId: string }> => {
|
||||||
|
const userId = await requireAuthUserId(ctx);
|
||||||
|
const organization = await ctx.db
|
||||||
|
.query("organizations")
|
||||||
|
.withIndex("by_createdBy_and_kind", (q) =>
|
||||||
|
q.eq("createdBy", userId).eq("kind", "personal")
|
||||||
|
)
|
||||||
|
.unique();
|
||||||
|
if (!organization) {
|
||||||
|
throw new ConvexError("Organization not found");
|
||||||
|
}
|
||||||
|
return { organizationId: organization._id, userId };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prove the authenticated user is a member of the project's organization.
|
||||||
|
* Projects are organization-scoped; unauthorized single-object operations
|
||||||
|
* throw "Project not found" to avoid leaking existence.
|
||||||
|
*/
|
||||||
|
export const requireProjectMember = async (
|
||||||
|
ctx: QueryCtx | MutationCtx,
|
||||||
|
projectId: Id<"projects">
|
||||||
|
): Promise<{ organizationId: Id<"organizations">; userId: string }> => {
|
||||||
|
const project = await ctx.db.get(projectId);
|
||||||
|
if (!project) {
|
||||||
|
throw new ConvexError("Project not found");
|
||||||
|
}
|
||||||
|
const userId = await requireOrganizationMember(ctx, project.organizationId);
|
||||||
|
return { organizationId: project.organizationId, userId };
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
import { query } from "./_generated/server";
|
import { query } from "./_generated/server";
|
||||||
import { requireAuthUserId } from "./authz"
|
import { requireProjectMember } from "./authz";
|
||||||
|
|
||||||
export const list = query({
|
export const list = query({
|
||||||
args: { projectId: v.id("projects") },
|
args: { projectId: v.id("projects") },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const ownerId = await requireAuthUserId(ctx);
|
try {
|
||||||
const project = await ctx.db.get("projects", args.projectId);
|
await requireProjectMember(ctx, args.projectId);
|
||||||
if (!project || project.ownerId !== ownerId) {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return await ctx.db
|
return await ctx.db
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ import { internal } from "./_generated/api";
|
|||||||
import type { Id } from "./_generated/dataModel";
|
import type { Id } from "./_generated/dataModel";
|
||||||
import schema from "./schema";
|
import schema from "./schema";
|
||||||
|
|
||||||
// `import.meta.glob` is provided by the Vitest/Vite test runner at runtime; it
|
|
||||||
// has no type definition under the Convex tsconfig (which scopes `types` to
|
|
||||||
// node), so declare the minimal shape the test relies on.
|
|
||||||
declare global {
|
declare global {
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
||||||
@@ -19,26 +16,45 @@ declare global {
|
|||||||
const modules = import.meta.glob("./**/*.ts");
|
const modules = import.meta.glob("./**/*.ts");
|
||||||
const api = anyApi;
|
const api = anyApi;
|
||||||
|
|
||||||
// `agentWorkspace` gates agent-controlled mutations on `env.FLUE_DB_TOKEN`.
|
|
||||||
// Vitest loads the repo `.env`, so resolve the real value from the env module
|
|
||||||
// rather than hardcoding a constant that would mismatch the loaded secret.
|
|
||||||
const FLUE_DB_TOKEN = env.FLUE_DB_TOKEN;
|
const FLUE_DB_TOKEN = env.FLUE_DB_TOKEN;
|
||||||
|
|
||||||
const ID_A = "https://convex.test|user-a";
|
const ID_A = "https://convex.test|user-a";
|
||||||
const identityA = { tokenIdentifier: ID_A };
|
const identityA = { tokenIdentifier: ID_A };
|
||||||
|
|
||||||
const repository = {
|
const SOURCE = {
|
||||||
defaultBranch: "main",
|
host: "github.com",
|
||||||
id: 12345,
|
projectName: "zopu",
|
||||||
name: "zopu",
|
repositoryPath: "puter/zopu",
|
||||||
owner: "puter",
|
normalizedUrl: "https://github.com/puter/zopu",
|
||||||
url: "https://github.com/puter/zopu",
|
url: "https://github.com/puter/zopu",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Read every projectEvents row for a project via the same indexes the control
|
const REMOTE = {
|
||||||
// plane writes through, returning them oldest-first. Reads MUST go through the
|
defaultBranch: "main",
|
||||||
// same test instance that performed the writes; a fresh `convexTest()` is an
|
documents: [
|
||||||
// isolated in-memory backend with no shared state.
|
{
|
||||||
|
kind: "readme" as const,
|
||||||
|
path: "README.md",
|
||||||
|
content: "# zopu\n\nRepository: https://github.com/puter/zopu\n",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestProject = async (t: TestConvex<typeof schema>): Promise<Id<"projects">> => {
|
||||||
|
// Ensure personal organization for the user
|
||||||
|
await t.withIdentity(identityA).mutation(api.organizations.ensurePersonalOrganization);
|
||||||
|
// Persist the public Git import
|
||||||
|
const outcome = await t
|
||||||
|
.withIdentity(identityA)
|
||||||
|
.mutation(internal.projects.persistPublicGitImport, {
|
||||||
|
userId: ID_A,
|
||||||
|
source: SOURCE,
|
||||||
|
remote: REMOTE,
|
||||||
|
});
|
||||||
|
return outcome.id as unknown as Id<"projects">;
|
||||||
|
};
|
||||||
|
|
||||||
const eventsForProject = async (
|
const eventsForProject = async (
|
||||||
t: TestConvex<typeof schema>,
|
t: TestConvex<typeof schema>,
|
||||||
projectId: Id<"projects">
|
projectId: Id<"projects">
|
||||||
@@ -56,10 +72,7 @@ const eventsForProject = async (
|
|||||||
describe("projectEvents", () => {
|
describe("projectEvents", () => {
|
||||||
test("project connect emits a project.connected event", async () => {
|
test("project connect emits a project.connected event", async () => {
|
||||||
const t = convexTest({ schema, modules });
|
const t = convexTest({ schema, modules });
|
||||||
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
|
const projectId = await createTestProject(t);
|
||||||
ownerId: ID_A,
|
|
||||||
repository,
|
|
||||||
});
|
|
||||||
|
|
||||||
const events = await eventsForProject(t, projectId);
|
const events = await eventsForProject(t, projectId);
|
||||||
expect(events).toHaveLength(1);
|
expect(events).toHaveLength(1);
|
||||||
@@ -67,18 +80,11 @@ describe("projectEvents", () => {
|
|||||||
expect(events[0].projectId).toBe(projectId);
|
expect(events[0].projectId).toBe(projectId);
|
||||||
expect(events[0].issueId).toBeUndefined();
|
expect(events[0].issueId).toBeUndefined();
|
||||||
expect(events[0].runId).toBeUndefined();
|
expect(events[0].runId).toBeUndefined();
|
||||||
expect(events[0].data).toMatchObject({
|
|
||||||
provider: "github",
|
|
||||||
repository: repository.url,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("issue create and begin emit issue.created then issue.queued events", async () => {
|
test("issue create and begin emit issue.created then issue.queued events", async () => {
|
||||||
const t = convexTest({ schema, modules });
|
const t = convexTest({ schema, modules });
|
||||||
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
|
const projectId = await createTestProject(t);
|
||||||
ownerId: ID_A,
|
|
||||||
repository,
|
|
||||||
});
|
|
||||||
|
|
||||||
const issueId = await t
|
const issueId = await t
|
||||||
.withIdentity(identityA)
|
.withIdentity(identityA)
|
||||||
@@ -119,10 +125,7 @@ describe("projectEvents", () => {
|
|||||||
|
|
||||||
test("artifact update emits an artifact.updated event", async () => {
|
test("artifact update emits an artifact.updated event", async () => {
|
||||||
const t = convexTest({ schema, modules });
|
const t = convexTest({ schema, modules });
|
||||||
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
|
const projectId = await createTestProject(t);
|
||||||
ownerId: ID_A,
|
|
||||||
repository,
|
|
||||||
});
|
|
||||||
const issueId = await t
|
const issueId = await t
|
||||||
.withIdentity(identityA)
|
.withIdentity(identityA)
|
||||||
.mutation(api.projectIssues.create, {
|
.mutation(api.projectIssues.create, {
|
||||||
@@ -149,10 +152,7 @@ describe("projectEvents", () => {
|
|||||||
|
|
||||||
test("agent status emits an agent.<status> event linked to the run", async () => {
|
test("agent status emits an agent.<status> event linked to the run", async () => {
|
||||||
const t = convexTest({ schema, modules });
|
const t = convexTest({ schema, modules });
|
||||||
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
|
const projectId = await createTestProject(t);
|
||||||
ownerId: ID_A,
|
|
||||||
repository,
|
|
||||||
});
|
|
||||||
const issueId = await t
|
const issueId = await t
|
||||||
.withIdentity(identityA)
|
.withIdentity(identityA)
|
||||||
.mutation(api.projectIssues.create, {
|
.mutation(api.projectIssues.create, {
|
||||||
@@ -192,10 +192,7 @@ describe("projectEvents", () => {
|
|||||||
|
|
||||||
test("wrong agent token is rejected before any event is written", async () => {
|
test("wrong agent token is rejected before any event is written", async () => {
|
||||||
const t = convexTest({ schema, modules });
|
const t = convexTest({ schema, modules });
|
||||||
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
|
const projectId = await createTestProject(t);
|
||||||
ownerId: ID_A,
|
|
||||||
repository,
|
|
||||||
});
|
|
||||||
const issueId = await t
|
const issueId = await t
|
||||||
.withIdentity(identityA)
|
.withIdentity(identityA)
|
||||||
.mutation(api.projectIssues.create, {
|
.mutation(api.projectIssues.create, {
|
||||||
|
|||||||
@@ -2,20 +2,7 @@ import { ConvexError, v } from "convex/values";
|
|||||||
|
|
||||||
import type { Id } from "./_generated/dataModel";
|
import type { Id } from "./_generated/dataModel";
|
||||||
import { mutation, query } from "./_generated/server";
|
import { mutation, query } from "./_generated/server";
|
||||||
import type { MutationCtx } from "./_generated/server";
|
import { requireProjectMember } from "./authz";
|
||||||
import { requireAuthUserId } 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({
|
export const create = mutation({
|
||||||
args: {
|
args: {
|
||||||
@@ -24,8 +11,7 @@ export const create = mutation({
|
|||||||
title: v.string(),
|
title: v.string(),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const ownerId = await requireAuthUserId(ctx);
|
await requireProjectMember(ctx, args.projectId);
|
||||||
await requireProjectOwner(ctx, args.projectId, ownerId);
|
|
||||||
const title = args.title.trim();
|
const title = args.title.trim();
|
||||||
const body = args.body.trim();
|
const body = args.body.trim();
|
||||||
if (title.length < 3 || title.length > 160) {
|
if (title.length < 3 || title.length > 160) {
|
||||||
@@ -81,12 +67,11 @@ export const create = mutation({
|
|||||||
export const begin = mutation({
|
export const begin = mutation({
|
||||||
args: { issueId: v.id("projectIssues") },
|
args: { issueId: v.id("projectIssues") },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const ownerId = await requireAuthUserId(ctx);
|
|
||||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||||
if (!issue) {
|
if (!issue) {
|
||||||
throw new ConvexError("Issue not found");
|
throw new ConvexError("Issue not found");
|
||||||
}
|
}
|
||||||
await requireProjectOwner(ctx, issue.projectId, ownerId);
|
await requireProjectMember(ctx, issue.projectId);
|
||||||
if (issue.status === "queued" || issue.status === "working") {
|
if (issue.status === "queued" || issue.status === "working") {
|
||||||
return issue.status;
|
return issue.status;
|
||||||
}
|
}
|
||||||
@@ -109,12 +94,11 @@ export const begin = mutation({
|
|||||||
export const markDispatchFailed = mutation({
|
export const markDispatchFailed = mutation({
|
||||||
args: { error: v.string(), issueId: v.id("projectIssues") },
|
args: { error: v.string(), issueId: v.id("projectIssues") },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const ownerId = await requireAuthUserId(ctx);
|
|
||||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||||
if (!issue) {
|
if (!issue) {
|
||||||
throw new ConvexError("Issue not found");
|
throw new ConvexError("Issue not found");
|
||||||
}
|
}
|
||||||
await requireProjectOwner(ctx, issue.projectId, ownerId);
|
await requireProjectMember(ctx, issue.projectId);
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
await ctx.db.patch("projectIssues", issue._id, {
|
await ctx.db.patch("projectIssues", issue._id, {
|
||||||
status: "failed",
|
status: "failed",
|
||||||
@@ -133,9 +117,9 @@ export const markDispatchFailed = mutation({
|
|||||||
export const list = query({
|
export const list = query({
|
||||||
args: { projectId: v.id("projects") },
|
args: { projectId: v.id("projects") },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const ownerId = await requireAuthUserId(ctx);
|
try {
|
||||||
const project = await ctx.db.get("projects", args.projectId);
|
await requireProjectMember(ctx, args.projectId);
|
||||||
if (!project || project.ownerId !== ownerId) {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return await ctx.db
|
return await ctx.db
|
||||||
|
|||||||
421
packages/backend/convex/projectStore.ts
Normal file
421
packages/backend/convex/projectStore.ts
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
import {
|
||||||
|
CONTEXT_KINDS,
|
||||||
|
contextPathForKind,
|
||||||
|
decideContextWrite,
|
||||||
|
makeInitialContext,
|
||||||
|
type ContextDocumentState,
|
||||||
|
type ContextKind,
|
||||||
|
type ContextWrite,
|
||||||
|
type PreparedPublicGitSource,
|
||||||
|
type ProjectImportOutcome,
|
||||||
|
type ProjectView,
|
||||||
|
type PublicGitImportResult,
|
||||||
|
} from "@code/primitives/project";
|
||||||
|
import type { Doc, Id } from "./_generated/dataModel";
|
||||||
|
import type {
|
||||||
|
ActionCtx,
|
||||||
|
MutationCtx,
|
||||||
|
QueryCtx,
|
||||||
|
} from "./_generated/server";
|
||||||
|
import { requireCurrentOrganization, requireProjectMember } from "./authz";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// View mappers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface SourceRow extends Doc<"projectSources"> {}
|
||||||
|
|
||||||
|
const toContextDocumentState = (
|
||||||
|
doc: Doc<"projectContextDocuments">
|
||||||
|
): ContextDocumentState => ({
|
||||||
|
content: doc.content,
|
||||||
|
kind: doc.kind,
|
||||||
|
origin: doc.origin,
|
||||||
|
path: doc.path,
|
||||||
|
revision: doc.revision,
|
||||||
|
sourceUrl: doc.sourceUrl ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toProjectView = async (
|
||||||
|
ctx: QueryCtx,
|
||||||
|
project: Doc<"projects">
|
||||||
|
): Promise<ProjectView> => {
|
||||||
|
const [sources, contextDocs] = await Promise.all([
|
||||||
|
ctx.db
|
||||||
|
.query("projectSources")
|
||||||
|
.withIndex("by_project", (q) => q.eq("projectId", project._id))
|
||||||
|
.collect(),
|
||||||
|
ctx.db
|
||||||
|
.query("projectContextDocuments")
|
||||||
|
.withIndex("by_project", (q) => q.eq("projectId", project._id))
|
||||||
|
.collect(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
contextDocuments: contextDocs.map(toContextDocumentState),
|
||||||
|
createdAt: project.createdAt as never,
|
||||||
|
id: project._id as never,
|
||||||
|
name: project.name,
|
||||||
|
organizationId: project.organizationId as never,
|
||||||
|
sources: sources.map((s) => ({
|
||||||
|
createdAt: s.createdAt as never,
|
||||||
|
defaultBranch: s.defaultBranch,
|
||||||
|
host: s.host,
|
||||||
|
kind: "git" as const,
|
||||||
|
normalizedUrl: s.normalizedUrl,
|
||||||
|
projectId: s.projectId as never,
|
||||||
|
repositoryPath: s.repositoryPath,
|
||||||
|
updatedAt: s.updatedAt as never,
|
||||||
|
url: s.url,
|
||||||
|
})),
|
||||||
|
updatedAt: project.updatedAt as never,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const toImportOutcome = async (
|
||||||
|
ctx: QueryCtx,
|
||||||
|
project: Doc<"projects">,
|
||||||
|
source: SourceRow
|
||||||
|
): Promise<ProjectImportOutcome> => {
|
||||||
|
const contextDocs = await ctx.db
|
||||||
|
.query("projectContextDocuments")
|
||||||
|
.withIndex("by_project", (q) => q.eq("projectId", project._id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
contextDocuments: contextDocs.map(toContextDocumentState),
|
||||||
|
createdAt: project.createdAt as never,
|
||||||
|
id: project._id as never,
|
||||||
|
name: project.name,
|
||||||
|
organizationId: project.organizationId as never,
|
||||||
|
source: {
|
||||||
|
createdAt: source.createdAt as never,
|
||||||
|
defaultBranch: source.defaultBranch,
|
||||||
|
host: source.host,
|
||||||
|
kind: "git" as const,
|
||||||
|
normalizedUrl: source.normalizedUrl,
|
||||||
|
projectId: source.projectId as never,
|
||||||
|
repositoryPath: source.repositoryPath,
|
||||||
|
updatedAt: source.updatedAt as never,
|
||||||
|
url: source.url,
|
||||||
|
},
|
||||||
|
updatedAt: project.updatedAt as never,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Query store — read-only ctx.db access
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const makeProjectQueryStore = (ctx: QueryCtx) => ({
|
||||||
|
listProjects: async (userId: string): Promise<ProjectView[]> => {
|
||||||
|
const { organizationId } = await resolveOrgForUser(ctx, userId);
|
||||||
|
const projects = await ctx.db
|
||||||
|
.query("projects")
|
||||||
|
.withIndex("by_organization_and_createdAt", (q) =>
|
||||||
|
q.eq("organizationId", organizationId)
|
||||||
|
)
|
||||||
|
.order("desc")
|
||||||
|
.take(50);
|
||||||
|
return Promise.all(projects.map((p) => toProjectView(ctx, p)));
|
||||||
|
},
|
||||||
|
|
||||||
|
getProject: async (
|
||||||
|
userId: string,
|
||||||
|
projectId: Id<"projects">
|
||||||
|
): Promise<ProjectView | null> => {
|
||||||
|
const { organizationId } = await resolveOrgForUser(ctx, userId);
|
||||||
|
const project = await ctx.db.get(projectId);
|
||||||
|
if (!project || project.organizationId !== organizationId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return toProjectView(ctx, project);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mutation store — transactional ctx.db writes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const makeProjectMutationStore = (ctx: MutationCtx) => ({
|
||||||
|
putContext: async (
|
||||||
|
userId: string,
|
||||||
|
projectId: Id<"projects">,
|
||||||
|
write: ContextWrite
|
||||||
|
): Promise<{ revision: number }> => {
|
||||||
|
const { organizationId } = await resolveProjectOrg(ctx, projectId, userId);
|
||||||
|
const path = contextPathForKind(write.kind);
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("projectContextDocuments")
|
||||||
|
.withIndex("by_project_and_path", (q) =>
|
||||||
|
q.eq("projectId", projectId).eq("path", path)
|
||||||
|
)
|
||||||
|
.unique();
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
decideContextWrite({
|
||||||
|
existing: existing ? toContextDocumentState(existing) : undefined,
|
||||||
|
write,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (!result.changed) {
|
||||||
|
return { revision: result.document.revision };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, {
|
||||||
|
content: result.document.content,
|
||||||
|
origin: result.document.origin,
|
||||||
|
revision: result.document.revision,
|
||||||
|
sourceUrl: result.document.sourceUrl,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await ctx.db.insert("projectContextDocuments", {
|
||||||
|
content: result.document.content,
|
||||||
|
createdAt: now,
|
||||||
|
kind: result.document.kind,
|
||||||
|
organizationId,
|
||||||
|
origin: result.document.origin,
|
||||||
|
path: result.document.path,
|
||||||
|
projectId,
|
||||||
|
revision: result.document.revision,
|
||||||
|
sourceUrl: result.document.sourceUrl,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { revision: result.document.revision };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Action store — calls narrowly scoped internal mutations/queries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const makeProjectActionStore = (ctx: ActionCtx) => ({
|
||||||
|
getCurrentOrganization: async (userId: string) => {
|
||||||
|
return userId;
|
||||||
|
},
|
||||||
|
|
||||||
|
listProjects: async (userId: string) => {
|
||||||
|
return userId;
|
||||||
|
},
|
||||||
|
|
||||||
|
getProject: async (userId: string, _projectId: Id<"projects">) => {
|
||||||
|
return userId;
|
||||||
|
},
|
||||||
|
|
||||||
|
persistPublicGitImport: async (
|
||||||
|
_userId: string,
|
||||||
|
_source: PreparedPublicGitSource,
|
||||||
|
_remote: PublicGitImportResult
|
||||||
|
): Promise<ProjectImportOutcome> => {
|
||||||
|
throw new Error("persistPublicGitImport must be called via internal mutation");
|
||||||
|
},
|
||||||
|
|
||||||
|
putContext: async (
|
||||||
|
_userId: string,
|
||||||
|
_projectId: Id<"projects">,
|
||||||
|
_write: ContextWrite
|
||||||
|
): Promise<{ revision: number }> => {
|
||||||
|
throw new Error("putContext must be called via internal mutation");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// We need Effect for decideContextWrite at runtime.
|
||||||
|
import { Effect } from "effect";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal mutation store — the actual transactional persistence
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const persistPublicGitImportTransaction = async (
|
||||||
|
ctx: MutationCtx,
|
||||||
|
userId: string,
|
||||||
|
source: PreparedPublicGitSource,
|
||||||
|
remote: PublicGitImportResult
|
||||||
|
): Promise<ProjectImportOutcome> => {
|
||||||
|
const { organizationId } = await resolveOrgForUser(ctx, userId);
|
||||||
|
|
||||||
|
// Idempotency: lookup by normalizedUrl within the organization
|
||||||
|
const existingSource = await ctx.db
|
||||||
|
.query("projectSources")
|
||||||
|
.withIndex("by_organization_and_normalizedUrl", (q) =>
|
||||||
|
q
|
||||||
|
.eq("organizationId", organizationId)
|
||||||
|
.eq("normalizedUrl", source.normalizedUrl)
|
||||||
|
)
|
||||||
|
.unique();
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (existingSource) {
|
||||||
|
const project = await ctx.db.get(existingSource.projectId);
|
||||||
|
if (!project) {
|
||||||
|
throw new Error("Project not found for existing source");
|
||||||
|
}
|
||||||
|
// Update source metadata
|
||||||
|
await ctx.db.patch(existingSource._id, {
|
||||||
|
defaultBranch: remote.defaultBranch,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
// Apply remote documents as repository writes
|
||||||
|
for (const doc of remote.documents) {
|
||||||
|
await applyRepositoryWrite(ctx, project._id, organizationId, doc, source.url, now);
|
||||||
|
}
|
||||||
|
await ctx.db.patch(project._id, { updatedAt: now });
|
||||||
|
const updatedSource = await ctx.db.get(existingSource._id);
|
||||||
|
return toImportOutcome(ctx, project, updatedSource ?? existingSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new project
|
||||||
|
const projectId = await ctx.db.insert("projects", {
|
||||||
|
createdAt: now,
|
||||||
|
name: source.projectName,
|
||||||
|
organizationId,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create source
|
||||||
|
const sourceId = await ctx.db.insert("projectSources", {
|
||||||
|
createdAt: now,
|
||||||
|
defaultBranch: remote.defaultBranch,
|
||||||
|
host: source.host,
|
||||||
|
kind: "git",
|
||||||
|
normalizedUrl: source.normalizedUrl,
|
||||||
|
organizationId,
|
||||||
|
projectId,
|
||||||
|
repositoryPath: source.repositoryPath,
|
||||||
|
updatedAt: now,
|
||||||
|
url: source.url,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed six canonical context documents
|
||||||
|
const seeds = makeInitialContext(source.projectName, source.url);
|
||||||
|
for (const seed of seeds) {
|
||||||
|
await ctx.db.insert("projectContextDocuments", {
|
||||||
|
content: seed.content,
|
||||||
|
createdAt: now,
|
||||||
|
kind: seed.kind,
|
||||||
|
organizationId,
|
||||||
|
origin: seed.origin,
|
||||||
|
path: seed.path,
|
||||||
|
projectId,
|
||||||
|
revision: seed.revision,
|
||||||
|
sourceUrl: seed.sourceUrl,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply remote documents (overrides seeds)
|
||||||
|
for (const doc of remote.documents) {
|
||||||
|
await applyRepositoryWrite(ctx, projectId, organizationId, doc, source.url, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdProject = await ctx.db.get(projectId);
|
||||||
|
const createdSource = await ctx.db.get(sourceId);
|
||||||
|
if (!createdProject || !createdSource) {
|
||||||
|
throw new Error("Failed to read created project");
|
||||||
|
}
|
||||||
|
return toImportOutcome(ctx, createdProject, createdSource);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyRepositoryWrite = async (
|
||||||
|
ctx: MutationCtx,
|
||||||
|
projectId: Id<"projects">,
|
||||||
|
organizationId: Id<"organizations">,
|
||||||
|
doc: { kind: ContextKind; path: string; content: string },
|
||||||
|
sourceUrl: string,
|
||||||
|
now: number
|
||||||
|
) => {
|
||||||
|
const path = contextPathForKind(doc.kind);
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("projectContextDocuments")
|
||||||
|
.withIndex("by_project_and_path", (q) =>
|
||||||
|
q.eq("projectId", projectId).eq("path", path)
|
||||||
|
)
|
||||||
|
.unique();
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
decideContextWrite({
|
||||||
|
existing: existing ? toContextDocumentState(existing) : undefined,
|
||||||
|
write: {
|
||||||
|
_tag: "Repository" as const,
|
||||||
|
content: doc.content,
|
||||||
|
kind: doc.kind,
|
||||||
|
sourceUrl,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.changed) return;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, {
|
||||||
|
content: result.document.content,
|
||||||
|
origin: result.document.origin,
|
||||||
|
revision: result.document.revision,
|
||||||
|
sourceUrl: result.document.sourceUrl,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await ctx.db.insert("projectContextDocuments", {
|
||||||
|
content: result.document.content,
|
||||||
|
createdAt: now,
|
||||||
|
kind: result.document.kind,
|
||||||
|
organizationId,
|
||||||
|
origin: result.document.origin,
|
||||||
|
path: result.document.path,
|
||||||
|
projectId,
|
||||||
|
revision: result.document.revision,
|
||||||
|
sourceUrl: result.document.sourceUrl,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Organization resolvers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const resolveOrgForUser = async (
|
||||||
|
ctx: QueryCtx | MutationCtx,
|
||||||
|
userId: string
|
||||||
|
): Promise<{ organizationId: Id<"organizations"> }> => {
|
||||||
|
const organization = await ctx.db
|
||||||
|
.query("organizations")
|
||||||
|
.withIndex("by_createdBy_and_kind", (q) =>
|
||||||
|
q.eq("createdBy", userId).eq("kind", "personal")
|
||||||
|
)
|
||||||
|
.unique();
|
||||||
|
if (!organization) {
|
||||||
|
throw new Error("Organization not found");
|
||||||
|
}
|
||||||
|
return { organizationId: organization._id };
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveProjectOrg = async (
|
||||||
|
ctx: QueryCtx | MutationCtx,
|
||||||
|
projectId: Id<"projects">,
|
||||||
|
userId: string
|
||||||
|
): Promise<{ organizationId: Id<"organizations"> }> => {
|
||||||
|
const project = await ctx.db.get(projectId);
|
||||||
|
if (!project) {
|
||||||
|
throw new Error("Project not found");
|
||||||
|
}
|
||||||
|
const { organizationId } = await resolveOrgForUser(ctx, userId);
|
||||||
|
if (project.organizationId !== organizationId) {
|
||||||
|
throw new Error("Project not found");
|
||||||
|
}
|
||||||
|
return { organizationId };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-export for convenience
|
||||||
|
export {
|
||||||
|
CONTEXT_KINDS,
|
||||||
|
requireCurrentOrganization,
|
||||||
|
requireProjectMember,
|
||||||
|
};
|
||||||
@@ -1,176 +1,231 @@
|
|||||||
import { ConvexError, v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { z } from "zod";
|
|
||||||
|
import {
|
||||||
|
preparePublicGitSource,
|
||||||
|
decodePublicGitImportResult,
|
||||||
|
type ProjectImportOutcome,
|
||||||
|
type ProjectView,
|
||||||
|
type PublicGitImportResult,
|
||||||
|
type PreparedPublicGitSource,
|
||||||
|
} from "@code/primitives/project";
|
||||||
|
import { Effect } from "effect";
|
||||||
|
|
||||||
import { internal } from "./_generated/api";
|
|
||||||
import type { Id } from "./_generated/dataModel";
|
import type { Id } from "./_generated/dataModel";
|
||||||
|
import { internal } from "./_generated/api";
|
||||||
import { action, internalMutation, query } from "./_generated/server";
|
import { action, internalMutation, query } from "./_generated/server";
|
||||||
|
import { requireCurrentOrganization } from "./authz";
|
||||||
import { createInitialArtifacts } from "./artifactModel";
|
import { createInitialArtifacts } from "./artifactModel";
|
||||||
import { requireAuthUserId } from "./authz"
|
import { persistPublicGitImportTransaction } from "./projectStore";
|
||||||
|
|
||||||
const gitHubRepositorySchema = z.object({
|
// ---------------------------------------------------------------------------
|
||||||
default_branch: z.string(),
|
// Internal: persist a public Git import in one transaction
|
||||||
description: z.string().nullable(),
|
// ---------------------------------------------------------------------------
|
||||||
html_url: z.url(),
|
|
||||||
id: z.number(),
|
|
||||||
name: z.string(),
|
|
||||||
owner: z.object({ login: z.string() }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const parseRepository = (value: string): { owner: string; repo: string } => {
|
export const persistPublicGitImport = internalMutation({
|
||||||
const normalized = value.trim().replace(/\.git$/u, "");
|
|
||||||
const match = normalized.match(
|
|
||||||
/^(?:https?:\/\/github\.com\/)?(?<owner>[^/\s]+)\/(?<repo>[^/\s]+)$/iu
|
|
||||||
);
|
|
||||||
if (!match?.groups?.owner || !match.groups.repo) {
|
|
||||||
throw new ConvexError("Use a GitHub repository in owner/name format");
|
|
||||||
}
|
|
||||||
return { owner: match.groups.owner, repo: match.groups.repo };
|
|
||||||
};
|
|
||||||
|
|
||||||
const readGitHubRepository = (value: unknown) => {
|
|
||||||
const result = gitHubRepositorySchema.safeParse(value);
|
|
||||||
if (!result.success) {
|
|
||||||
throw new ConvexError("GitHub returned incomplete repository metadata");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
defaultBranch: result.data.default_branch,
|
|
||||||
...(result.data.description === null
|
|
||||||
? {}
|
|
||||||
: { description: result.data.description }),
|
|
||||||
id: result.data.id,
|
|
||||||
name: result.data.name,
|
|
||||||
owner: result.data.owner.login,
|
|
||||||
url: result.data.html_url,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const connectGitHub = action({
|
|
||||||
args: { repository: v.string() },
|
|
||||||
handler: async (ctx, args): Promise<Id<"projects">> => {
|
|
||||||
const ownerId = await requireAuthUserId(ctx);
|
|
||||||
const { owner, repo } = parseRepository(args.repository);
|
|
||||||
const response = await fetch(
|
|
||||||
`https://api.github.com/repos/${owner}/${repo}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Accept: "application/vnd.github+json",
|
|
||||||
"User-Agent": "zopu-code",
|
|
||||||
"X-GitHub-Api-Version": "2022-11-28",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 404) {
|
|
||||||
throw new ConvexError(
|
|
||||||
"Repository not found. This first flow supports public GitHub repositories."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new ConvexError(`GitHub connection failed with ${response.status}`);
|
|
||||||
}
|
|
||||||
const repository = readGitHubRepository(await response.json());
|
|
||||||
const projectId: Id<"projects"> = await ctx.runMutation(
|
|
||||||
internal.projects.storeGitHubProject,
|
|
||||||
{ ownerId, repository }
|
|
||||||
);
|
|
||||||
return projectId;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const storeGitHubProject = internalMutation({
|
|
||||||
args: {
|
args: {
|
||||||
ownerId: v.string(),
|
userId: v.string(),
|
||||||
repository: v.object({
|
source: v.object({
|
||||||
defaultBranch: v.string(),
|
host: v.string(),
|
||||||
description: v.optional(v.string()),
|
projectName: v.string(),
|
||||||
id: v.number(),
|
repositoryPath: v.string(),
|
||||||
name: v.string(),
|
normalizedUrl: v.string(),
|
||||||
owner: v.string(),
|
|
||||||
url: v.string(),
|
url: v.string(),
|
||||||
}),
|
}),
|
||||||
|
remote: v.object({
|
||||||
|
defaultBranch: v.optional(v.string()),
|
||||||
|
documents: v.array(
|
||||||
|
v.object({
|
||||||
|
kind: v.union(
|
||||||
|
v.literal("readme"),
|
||||||
|
v.literal("agents"),
|
||||||
|
v.literal("product"),
|
||||||
|
v.literal("business"),
|
||||||
|
v.literal("design"),
|
||||||
|
v.literal("tech")
|
||||||
|
),
|
||||||
|
path: v.string(),
|
||||||
|
content: v.string(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
warnings: v.array(
|
||||||
|
v.object({
|
||||||
|
path: v.string(),
|
||||||
|
message: v.string(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args): Promise<ProjectImportOutcome> => {
|
||||||
const existing = await ctx.db
|
const result = await persistPublicGitImportTransaction(
|
||||||
.query("projects")
|
ctx,
|
||||||
.withIndex("by_owner_and_repository", (q) =>
|
args.userId,
|
||||||
q
|
args.source as PreparedPublicGitSource,
|
||||||
.eq("ownerId", args.ownerId)
|
args.remote as PublicGitImportResult
|
||||||
.eq("provider", "github")
|
);
|
||||||
.eq("repoOwner", args.repository.owner)
|
|
||||||
.eq("repoName", args.repository.name)
|
|
||||||
)
|
|
||||||
.unique();
|
|
||||||
const timestamp = Date.now();
|
|
||||||
if (existing) {
|
|
||||||
await ctx.db.patch("projects", existing._id, {
|
|
||||||
defaultBranch: args.repository.defaultBranch,
|
|
||||||
description: args.repository.description,
|
|
||||||
providerRepoId: args.repository.id,
|
|
||||||
repoUrl: args.repository.url,
|
|
||||||
updatedAt: timestamp,
|
|
||||||
});
|
|
||||||
return existing._id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectId = await ctx.db.insert("projects", {
|
// Seed operational artifacts on first creation (detected by checking
|
||||||
createdAt: timestamp,
|
// existing artifacts)
|
||||||
defaultBranch: args.repository.defaultBranch,
|
const existingArtifacts = await ctx.db
|
||||||
description: args.repository.description,
|
.query("projectArtifacts")
|
||||||
name: args.repository.name,
|
.withIndex("by_project", (q) => q.eq("projectId", result.id as unknown as Id<"projects">))
|
||||||
ownerId: args.ownerId,
|
.first();
|
||||||
provider: "github",
|
if (!existingArtifacts) {
|
||||||
providerRepoId: args.repository.id,
|
const now = Date.now();
|
||||||
repoName: args.repository.name,
|
const artifacts = createInitialArtifacts();
|
||||||
repoOwner: args.repository.owner,
|
|
||||||
repoUrl: args.repository.url,
|
|
||||||
updatedAt: timestamp,
|
|
||||||
});
|
|
||||||
const artifacts = createInitialArtifacts({
|
|
||||||
defaultBranch: args.repository.defaultBranch,
|
|
||||||
description: args.repository.description,
|
|
||||||
name: args.repository.name,
|
|
||||||
repoName: args.repository.name,
|
|
||||||
repoOwner: args.repository.owner,
|
|
||||||
repoUrl: args.repository.url,
|
|
||||||
});
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
artifacts.map(({ content, path }) =>
|
artifacts.map(({ content, path }) =>
|
||||||
ctx.db.insert("projectArtifacts", {
|
ctx.db.insert("projectArtifacts", {
|
||||||
content,
|
content,
|
||||||
createdAt: timestamp,
|
createdAt: now,
|
||||||
path,
|
path,
|
||||||
projectId,
|
projectId: result.id as unknown as Id<"projects">,
|
||||||
revision: 1,
|
revision: 1,
|
||||||
updatedAt: timestamp,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
await ctx.db.insert("projectEvents", {
|
await ctx.db.insert("projectEvents", {
|
||||||
createdAt: timestamp,
|
createdAt: now,
|
||||||
data: { provider: "github", repository: args.repository.url },
|
data: { host: result.source.host, url: result.source.url },
|
||||||
kind: "project.connected",
|
kind: "project.connected",
|
||||||
projectId,
|
projectId: result.id as unknown as Id<"projects">,
|
||||||
});
|
});
|
||||||
return projectId;
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal: put a context document
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const putContextDocument = internalMutation({
|
||||||
|
args: {
|
||||||
|
userId: v.string(),
|
||||||
|
projectId: v.id("projects"),
|
||||||
|
write: v.union(
|
||||||
|
v.object({
|
||||||
|
_tag: v.literal("Repository"),
|
||||||
|
content: v.string(),
|
||||||
|
kind: v.union(
|
||||||
|
v.literal("readme"),
|
||||||
|
v.literal("agents"),
|
||||||
|
v.literal("product"),
|
||||||
|
v.literal("business"),
|
||||||
|
v.literal("design"),
|
||||||
|
v.literal("tech")
|
||||||
|
),
|
||||||
|
sourceUrl: v.string(),
|
||||||
|
}),
|
||||||
|
v.object({
|
||||||
|
_tag: v.literal("PublicTextUrl"),
|
||||||
|
content: v.string(),
|
||||||
|
kind: v.union(
|
||||||
|
v.literal("readme"),
|
||||||
|
v.literal("agents"),
|
||||||
|
v.literal("product"),
|
||||||
|
v.literal("business"),
|
||||||
|
v.literal("design"),
|
||||||
|
v.literal("tech")
|
||||||
|
),
|
||||||
|
sourceUrl: v.string(),
|
||||||
|
}),
|
||||||
|
v.object({
|
||||||
|
_tag: v.literal("UserText"),
|
||||||
|
content: v.string(),
|
||||||
|
kind: v.union(
|
||||||
|
v.literal("readme"),
|
||||||
|
v.literal("agents"),
|
||||||
|
v.literal("product"),
|
||||||
|
v.literal("business"),
|
||||||
|
v.literal("design"),
|
||||||
|
v.literal("tech")
|
||||||
|
),
|
||||||
|
origin: v.union(v.literal("paste"), v.literal("upload")),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const { makeProjectMutationStore } = await import("./projectStore");
|
||||||
|
const store = makeProjectMutationStore(ctx);
|
||||||
|
return store.putContext(args.userId, args.projectId, args.write as never);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public query: list projects
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const list = query({
|
export const list = query({
|
||||||
args: {},
|
args: {},
|
||||||
handler: async (ctx) => {
|
handler: async (ctx): Promise<ProjectView[]> => {
|
||||||
const ownerId = await requireAuthUserId(ctx);
|
const { userId } = await requireCurrentOrganization(ctx);
|
||||||
return await ctx.db
|
const { makeProjectQueryStore } = await import("./projectStore");
|
||||||
.query("projects")
|
const store = makeProjectQueryStore(ctx);
|
||||||
.withIndex("by_owner_and_createdAt", (q) => q.eq("ownerId", ownerId))
|
return store.listProjects(userId);
|
||||||
.order("desc")
|
|
||||||
.take(50);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public query: get a project
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const get = query({
|
export const get = query({
|
||||||
args: { projectId: v.id("projects") },
|
args: { projectId: v.id("projects") },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args): Promise<ProjectView | null> => {
|
||||||
const ownerId = await requireAuthUserId(ctx);
|
const { userId } = await requireCurrentOrganization(ctx);
|
||||||
const project = await ctx.db.get("projects", args.projectId);
|
const { makeProjectQueryStore } = await import("./projectStore");
|
||||||
return project?.ownerId === ownerId ? project : null;
|
const store = makeProjectQueryStore(ctx);
|
||||||
|
return store.getProject(userId, args.projectId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public action: import a public Git repository
|
||||||
|
// (daemon wiring added in the Daemon phase)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const importPublicGit = action({
|
||||||
|
args: { repositoryUrl: v.string() },
|
||||||
|
handler: async (_ctx, args): Promise<ProjectImportOutcome> => {
|
||||||
|
// Normalize through the domain layer to validate the URL before the
|
||||||
|
// daemon adapter is wired.
|
||||||
|
await Effect.runPromise(preparePublicGitSource(args.repositoryUrl));
|
||||||
|
// The daemon PublicGit adapter is wired in the Daemon phase.
|
||||||
|
throw new Error(
|
||||||
|
"PublicGit import is not yet wired to the daemon adapter"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public mutation: put context text (paste/upload)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const putText = internalMutation({
|
||||||
|
args: {
|
||||||
|
projectId: v.id("projects"),
|
||||||
|
kind: v.union(
|
||||||
|
v.literal("readme"),
|
||||||
|
v.literal("agents"),
|
||||||
|
v.literal("product"),
|
||||||
|
v.literal("business"),
|
||||||
|
v.literal("design"),
|
||||||
|
v.literal("tech")
|
||||||
|
),
|
||||||
|
content: v.string(),
|
||||||
|
origin: v.union(v.literal("paste"), v.literal("upload")),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const { userId } = await requireCurrentOrganization(ctx);
|
||||||
|
const { makeProjectMutationStore } = await import("./projectStore");
|
||||||
|
const store = makeProjectMutationStore(ctx);
|
||||||
|
return store.putContext(userId, args.projectId, {
|
||||||
|
_tag: "UserText" as const,
|
||||||
|
content: args.content,
|
||||||
|
kind: args.kind,
|
||||||
|
origin: args.origin,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,25 +63,57 @@ export default defineSchema({
|
|||||||
.index("by_daemonId_and_createdAt", ["daemonId", "createdAt"])
|
.index("by_daemonId_and_createdAt", ["daemonId", "createdAt"])
|
||||||
.index("by_commandId_and_createdAt", ["commandId", "createdAt"]),
|
.index("by_commandId_and_createdAt", ["commandId", "createdAt"]),
|
||||||
projects: defineTable({
|
projects: defineTable({
|
||||||
ownerId: v.string(),
|
organizationId: v.id("organizations"),
|
||||||
provider: v.literal("github"),
|
|
||||||
providerRepoId: v.number(),
|
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
description: v.optional(v.string()),
|
description: v.optional(v.string()),
|
||||||
repoOwner: v.string(),
|
createdAt: v.number(),
|
||||||
repoName: v.string(),
|
updatedAt: v.number(),
|
||||||
repoUrl: v.string(),
|
}).index("by_organization_and_createdAt", ["organizationId", "createdAt"]),
|
||||||
defaultBranch: v.string(),
|
projectSources: defineTable({
|
||||||
|
organizationId: v.id("organizations"),
|
||||||
|
projectId: v.id("projects"),
|
||||||
|
kind: v.literal("git"),
|
||||||
|
url: v.string(),
|
||||||
|
normalizedUrl: v.string(),
|
||||||
|
host: v.string(),
|
||||||
|
repositoryPath: v.string(),
|
||||||
|
defaultBranch: v.optional(v.string()),
|
||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
updatedAt: v.number(),
|
updatedAt: v.number(),
|
||||||
})
|
})
|
||||||
.index("by_owner_and_createdAt", ["ownerId", "createdAt"])
|
.index("by_project", ["projectId"])
|
||||||
.index("by_owner_and_repository", [
|
.index("by_organization_and_normalizedUrl", [
|
||||||
"ownerId",
|
"organizationId",
|
||||||
"provider",
|
"normalizedUrl",
|
||||||
"repoOwner",
|
])
|
||||||
"repoName",
|
.index("by_organization_and_createdAt", ["organizationId", "createdAt"]),
|
||||||
]),
|
projectContextDocuments: defineTable({
|
||||||
|
organizationId: v.id("organizations"),
|
||||||
|
projectId: v.id("projects"),
|
||||||
|
kind: v.union(
|
||||||
|
v.literal("readme"),
|
||||||
|
v.literal("agents"),
|
||||||
|
v.literal("product"),
|
||||||
|
v.literal("business"),
|
||||||
|
v.literal("design"),
|
||||||
|
v.literal("tech")
|
||||||
|
),
|
||||||
|
path: v.string(),
|
||||||
|
content: v.string(),
|
||||||
|
revision: v.number(),
|
||||||
|
origin: v.union(
|
||||||
|
v.literal("repository"),
|
||||||
|
v.literal("paste"),
|
||||||
|
v.literal("upload"),
|
||||||
|
v.literal("public-url")
|
||||||
|
),
|
||||||
|
sourceUrl: v.optional(v.string()),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_project", ["projectId"])
|
||||||
|
.index("by_project_and_kind", ["projectId", "kind"])
|
||||||
|
.index("by_project_and_path", ["projectId", "path"]),
|
||||||
projectArtifacts: defineTable({
|
projectArtifacts: defineTable({
|
||||||
projectId: v.id("projects"),
|
projectId: v.id("projects"),
|
||||||
path: v.string(),
|
path: v.string(),
|
||||||
@@ -165,6 +197,7 @@ export default defineSchema({
|
|||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
})
|
})
|
||||||
.index("by_userId", ["userId"])
|
.index("by_userId", ["userId"])
|
||||||
|
.index("by_organizationId", ["organizationId"])
|
||||||
.index("by_organizationId_and_userId", ["organizationId", "userId"]),
|
.index("by_organizationId_and_userId", ["organizationId", "userId"]),
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|||||||
@@ -44,13 +44,6 @@ const problemStatement = {
|
|||||||
|
|
||||||
const processedBy = { agentInstanceId: "zopu-instance-1", agentName: "zopu" };
|
const processedBy = { agentInstanceId: "zopu-instance-1", agentName: "zopu" };
|
||||||
|
|
||||||
const repository = {
|
|
||||||
defaultBranch: "main",
|
|
||||||
id: 12345,
|
|
||||||
name: "zopu",
|
|
||||||
owner: "puter",
|
|
||||||
url: "https://github.com/puter/zopu",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Begin and admit one user message, returning its messageId.
|
// Begin and admit one user message, returning its messageId.
|
||||||
const seedMessage = async (
|
const seedMessage = async (
|
||||||
@@ -79,13 +72,37 @@ const seedMessage = async (
|
|||||||
|
|
||||||
const createProject = async (
|
const createProject = async (
|
||||||
t: TestConvex<typeof schema>,
|
t: TestConvex<typeof schema>,
|
||||||
ownerId: string,
|
ownerId: string
|
||||||
repoId = 12345
|
|
||||||
): Promise<Id<"projects">> => {
|
): Promise<Id<"projects">> => {
|
||||||
return await t.mutation(internal.projects.storeGitHubProject, {
|
// Resolve or create the owner's personal organization
|
||||||
ownerId,
|
const ownerIdentity = { tokenIdentifier: ownerId };
|
||||||
repository: { ...repository, id: repoId },
|
await t
|
||||||
|
.withIdentity(ownerIdentity)
|
||||||
|
.mutation(api.organizations.ensurePersonalOrganization);
|
||||||
|
const outcome = await t
|
||||||
|
.withIdentity(ownerIdentity)
|
||||||
|
.mutation(internal.projects.persistPublicGitImport, {
|
||||||
|
userId: ownerId,
|
||||||
|
source: {
|
||||||
|
host: "github.com",
|
||||||
|
projectName: "test-repo",
|
||||||
|
repositoryPath: `owner-${ownerId.slice(-4)}/test-repo`,
|
||||||
|
normalizedUrl: `https://github.com/owner-${ownerId.slice(-4)}/test-repo`,
|
||||||
|
url: `https://github.com/owner-${ownerId.slice(-4)}/test-repo`,
|
||||||
|
},
|
||||||
|
remote: {
|
||||||
|
defaultBranch: "main",
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
kind: "readme" as const,
|
||||||
|
path: "README.md",
|
||||||
|
content: "# test-repo\n\nTest.\n",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
warnings: [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
return outcome.id as unknown as Id<"projects">;
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("signals", () => {
|
describe("signals", () => {
|
||||||
|
|||||||
@@ -161,13 +161,9 @@ const resolveSources = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve whether an optional project belongs to the same organization. Until
|
* Verify a project belongs to the same organization. Projects now carry an
|
||||||
* projects carry an explicit `organizationId`, a project is attachable to an
|
* explicit `organizationId`; the caller is already proven to be a member, so
|
||||||
* organization's signal only when the project owner is a member of that
|
* this is a direct invariant check with no cross-tenant leakage.
|
||||||
* organization. The caller is already proven to be a member, so this is a
|
|
||||||
* security-preserving temporary mapping: an organization's project set is the
|
|
||||||
* intersection of (caller is a member) and (project owner is a member), which
|
|
||||||
* cannot weaken cross-tenant isolation.
|
|
||||||
*/
|
*/
|
||||||
const authorizeProject = async (
|
const authorizeProject = async (
|
||||||
ctx: MutationCtx,
|
ctx: MutationCtx,
|
||||||
@@ -178,13 +174,7 @@ const authorizeProject = async (
|
|||||||
if (!project) {
|
if (!project) {
|
||||||
throw new ConvexError("Project not found");
|
throw new ConvexError("Project not found");
|
||||||
}
|
}
|
||||||
const ownerMembership = await ctx.db
|
if (project.organizationId !== organizationId) {
|
||||||
.query("organizationMembers")
|
|
||||||
.withIndex("by_organizationId_and_userId", (q) =>
|
|
||||||
q.eq("organizationId", organizationId).eq("userId", project.ownerId)
|
|
||||||
)
|
|
||||||
.unique();
|
|
||||||
if (!ownerMembership) {
|
|
||||||
throw new ConvexError("Project does not belong to the target organization");
|
throw new ConvexError("Project does not belong to the target organization");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "convex dev --env-file ../../.env",
|
"dev": "convex dev --env-file ../../.env",
|
||||||
"dev:setup": "convex dev --env-file ../../.env --configure --until-success",
|
"dev:setup": "convex dev --env-file ../../.env --configure --until-success",
|
||||||
|
"check-types": "tsc --noEmit -p convex/tsconfig.json",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user