feat: Projects backend slice — auth, primitives, backend, UI (#3)

This commit is contained in:
2026-07-23 14:09:25 +00:00
parent ab9426b8fc
commit 609badc4ed
32 changed files with 1926 additions and 664 deletions

View File

@@ -2,6 +2,7 @@ import path from "node:path";
import { parseAgentEnv } from "@code/env/agent";
import { defineAgent } from "@flue/runtime";
// import { agentos } from "../sandboxes/agentos";
import { local } from "@flue/runtime/node";

View File

@@ -5,7 +5,6 @@
"type": "module",
"exports": {
"./native": "./src/native/index.ts",
"./server": "./src/server/index.ts",
"./web": "./src/web/index.ts"
},
"scripts": {

View File

@@ -1 +0,0 @@
export { authComponent, createAuth } from "@code/backend/convex/auth";

View File

@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
import { authClient } from "./auth-client";
const convex = new ConvexReactClient(env.VITE_CONVEX_URL);
const convex = new ConvexReactClient(env.VITE_CONVEX_URL, { expectAuth: true });
export const WebAuthProvider = ({ children }: { children: ReactNode }) => (
<ConvexBetterAuthProvider authClient={authClient} client={convex}>

View File

@@ -1,5 +1,7 @@
export { authClient } from "./auth-client";
export { WebAuthProvider } from "./auth-provider";
export { useConvexAccessToken } from "./hooks";
export { signOutWeb, useWebAuth } from "./use-web-auth";
export type { WebAuthState, WebAuthUser } from "./use-web-auth";
export { LoginForm } from "./login-form";
export { SignupForm } from "./signup-form";

View File

@@ -0,0 +1,81 @@
import { api } from "@code/backend/convex/_generated/api";
import { useConvexAuth, useQuery } from "convex/react";
import { useEffect, useRef } from "react";
import { authClient } from "./auth-client";
export interface WebAuthUser {
readonly id: string;
readonly name: string;
readonly email: string;
}
export type WebAuthState =
| { readonly status: "loading" }
| { readonly status: "unauthenticated" }
| { readonly status: "authenticated"; readonly user: WebAuthUser }
| { readonly status: "inconsistent" };
/**
* Sign out of the web session. Clears the Better Auth session, which in turn
* invalidates the cached Convex JWT (the session-token resolver keys on the
* session id). Protected Convex queries after sign-out are denied.
*/
export const signOutWeb = async (): Promise<void> => {
await authClient.signOut({ fetchOptions: { throw: false } });
};
/**
* The single web-facing auth interface. Combines Convex auth state with the
* normalized {@link api.auth.getCurrentUser} projection, skipping the user
* query until Convex has authenticated. Reports `inconsistent` only when a JWT
* is accepted but the Better Auth user row cannot be resolved.
*/
export const useWebAuth = (): WebAuthState => {
const { isAuthenticated, isLoading } = useConvexAuth();
// Skip the user query until Convex has accepted the JWT. This keeps the
// status `loading` during token restoration and `unauthenticated` when no
// session exists, without firing a query that would return null.
const userQuery = useQuery(
api.auth.getCurrentUser,
isAuthenticated ? {} : "skip"
);
const signOutWebRef = useRef(signOutWeb);
const inconsistent =
isAuthenticated && userQuery !== undefined && userQuery === null;
// If Convex accepted the JWT but the Better Auth user row cannot be resolved,
// the session is inconsistent: sign out so the next sign-in starts clean.
useEffect(() => {
if (inconsistent) {
void signOutWebRef.current();
}
}, [inconsistent]);
if (isLoading) {
return { status: "loading" };
}
if (!isAuthenticated) {
return { status: "unauthenticated" };
}
if (userQuery === undefined) {
return { status: "loading" };
}
if (userQuery === null) {
return { status: "inconsistent" };
}
return {
status: "authenticated",
user: {
email: userQuery.email,
id: userQuery.id,
name: userQuery.name,
},
};
};

View File

@@ -1,9 +1,6 @@
import { v } from "convex/values";
export const ARTIFACT_PATHS = [
"project.md",
"business.md",
"design.md",
"agent.md",
"work.md",
"steps.md",
@@ -17,9 +14,6 @@ export const ARTIFACT_PATHS = [
export type ArtifactPath = (typeof ARTIFACT_PATHS)[number];
export const artifactPath = v.union(
v.literal("project.md"),
v.literal("business.md"),
v.literal("design.md"),
v.literal("agent.md"),
v.literal("work.md"),
v.literal("steps.md"),
@@ -30,71 +24,48 @@ export const artifactPath = v.union(
v.literal("card.md")
);
interface ProjectSeed {
readonly defaultBranch: string;
readonly description?: 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`,
path: "project.md",
},
{
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",
},
{
content:
"# Work\n\nNo issue has entered the agent queue yet. New issues are appended here by the control plane.\n",
path: "work.md",
},
{
content:
"# Steps\n\n1. Read the issue and project artifacts.\n2. Inspect the repository context.\n3. Ask for missing decisions only when work cannot continue safely.\n4. Implement the smallest complete change.\n5. Run the relevant command or scenario.\n6. Publish changed artifacts and the final signal.\n",
path: "steps.md",
},
{
content:
"# Artifacts\n\nThis file indexes concrete outputs from issue runs, including changed paths, command results, and preview links.\n",
path: "artifacts.md",
},
{
content:
"# Project events\n\nThe agent manager emits `issue.queued`, `agent.started`, `agent.needs-input`, `agent.completed`, and `agent.failed`. These are durable project events and drive the web status.\n",
path: "signals.md",
},
{
content:
"# Agent manager\n\n## State machine\n\n`open -> queued -> working -> completed`\n\nA blocked run moves from `working` to `needs-input`; a resumed run returns to `queued`. An unrecoverable run moves to `failed`.\n\nEach issue owns one Flue agent identity and one AgentOS actor key, so conversation and workspace state remain isolated.\n",
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`,
path: "context.md",
},
{
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",
path: "card.md",
},
];
};
export const createInitialArtifacts = (): readonly {
path: ArtifactPath;
content: string;
}[] => [
{
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",
},
{
content:
"# Work\n\nNo issue has entered the agent queue yet. New issues are appended here by the control plane.\n",
path: "work.md",
},
{
content:
"# Steps\n\n1. Read the issue and project artifacts.\n2. Inspect the repository context.\n3. Ask for missing decisions only when work cannot continue safely.\n4. Implement the smallest complete change.\n5. Run the relevant command or scenario.\n6. Publish changed artifacts and the final signal.\n",
path: "steps.md",
},
{
content:
"# Artifacts\n\nThis file indexes concrete outputs from issue runs, including changed paths, command results, and preview links.\n",
path: "artifacts.md",
},
{
content:
"# Project events\n\nThe agent manager emits `issue.queued`, `agent.started`, `agent.needs-input`, `agent.completed`, and `agent.failed`. These are durable project events and drive the web status.\n",
path: "signals.md",
},
{
content:
"# Agent manager\n\n## State machine\n\n`open -> queued -> working -> completed`\n\nA blocked run moves from `working` to `needs-input`; a resumed run returns to `queued`. An unrecoverable run moves to `failed`.\n\nEach issue owns one Flue agent identity and one AgentOS actor key, so conversation and workspace state remain isolated.\n",
path: "agent-manager.md",
},
{
content:
"# Context\n\nCanonical project knowledge is staged under /workspace/context. Issue-specific facts belong below.\n",
path: "context.md",
},
{
content:
"# 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",
},
];

View File

@@ -36,7 +36,33 @@ const createAuth = (ctx: GenericCtx<DataModel>) =>
export { createAuth };
export interface AuthUserView {
readonly id: string;
readonly name: string;
readonly email: string;
}
/**
* Resolve the authenticated user to the canonical web view. The returned `id`
* is the Convex `identity.tokenIdentifier`, the stable authenticated identity
* used across organization/conversation/Signal ownership. Returns null when no
* identity is present or the Better Auth user row cannot be resolved.
*/
export const getCurrentUser = query({
args: {},
handler: (ctx) => authComponent.safeGetAuthUser(ctx),
handler: async (ctx): Promise<AuthUserView | null> => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
return null;
}
const authUser = await authComponent.safeGetAuthUser(ctx);
if (!authUser) {
return null;
}
return {
email: authUser.email,
id: identity.tokenIdentifier,
name: authUser.name,
};
},
});

View File

@@ -11,7 +11,7 @@ export interface AuthContext {
};
}
export const requireOwnerId = async (ctx: AuthContext): Promise<string> => {
export const requireAuthUserId = async (ctx: AuthContext): Promise<string> => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new ConvexError("Authentication required");
@@ -28,7 +28,7 @@ export const requireOrganizationMember = async (
ctx: QueryCtx | MutationCtx,
organizationId: Id<"organizations">
): Promise<string> => {
const userId = await requireOwnerId(ctx);
const userId = await requireAuthUserId(ctx);
const membership = await ctx.db
.query("organizationMembers")
.withIndex("by_organizationId_and_userId", (q) =>
@@ -40,3 +40,41 @@ export const requireOrganizationMember = async (
}
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 };
};

View File

@@ -1,6 +1,6 @@
import type { Doc, Id } from "./_generated/dataModel";
import { mutation, query } from "./_generated/server";
import { requireOwnerId } from "./authz";
import { requireAuthUserId } from "./authz";
interface OrganizationView {
readonly _id: Id<"organizations">;
@@ -31,7 +31,7 @@ const toView = (org: Doc<"organizations">): OrganizationView => ({
export const ensurePersonalOrganization = mutation({
args: {},
handler: async (ctx): Promise<OrganizationView> => {
const ownerId = await requireOwnerId(ctx);
const ownerId = await requireAuthUserId(ctx);
const existing = await ctx.db
.query("organizations")
.withIndex("by_createdBy_and_kind", (q) =>
@@ -70,7 +70,7 @@ export const ensurePersonalOrganization = mutation({
export const getCurrent = query({
args: {},
handler: async (ctx): Promise<OrganizationView | null> => {
const ownerId = await requireOwnerId(ctx);
const ownerId = await requireAuthUserId(ctx);
const existing = await ctx.db
.query("organizations")
.withIndex("by_createdBy_and_kind", (q) =>

View File

@@ -1,14 +1,14 @@
import { v } from "convex/values";
import { query } from "./_generated/server";
import { requireOwnerId } from "./authz";
import { requireProjectMember } from "./authz";
export const list = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
const ownerId = await requireOwnerId(ctx);
const project = await ctx.db.get("projects", args.projectId);
if (!project || project.ownerId !== ownerId) {
try {
await requireProjectMember(ctx, args.projectId);
} catch {
return [];
}
return await ctx.db

View File

@@ -7,9 +7,6 @@ import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
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 {
interface ImportMeta {
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
@@ -19,26 +16,49 @@ declare global {
const modules = import.meta.glob("./**/*.ts");
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 ID_A = "https://convex.test|user-a";
const identityA = { tokenIdentifier: ID_A };
const repository = {
defaultBranch: "main",
id: 12345,
name: "zopu",
owner: "puter",
const SOURCE = {
host: "github.com",
projectName: "zopu",
repositoryPath: "puter/zopu",
normalizedUrl: "https://github.com/puter/zopu",
url: "https://github.com/puter/zopu",
};
// Read every projectEvents row for a project via the same indexes the control
// plane writes through, returning them oldest-first. Reads MUST go through the
// same test instance that performed the writes; a fresh `convexTest()` is an
// isolated in-memory backend with no shared state.
const REMOTE = {
defaultBranch: "main",
documents: [
{
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 (
t: TestConvex<typeof schema>,
projectId: Id<"projects">
@@ -56,10 +76,7 @@ const eventsForProject = async (
describe("projectEvents", () => {
test("project connect emits a project.connected event", async () => {
const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
ownerId: ID_A,
repository,
});
const projectId = await createTestProject(t);
const events = await eventsForProject(t, projectId);
expect(events).toHaveLength(1);
@@ -67,18 +84,11 @@ describe("projectEvents", () => {
expect(events[0].projectId).toBe(projectId);
expect(events[0].issueId).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 () => {
const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
ownerId: ID_A,
repository,
});
const projectId = await createTestProject(t);
const issueId = await t
.withIdentity(identityA)
@@ -119,10 +129,7 @@ describe("projectEvents", () => {
test("artifact update emits an artifact.updated event", async () => {
const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
ownerId: ID_A,
repository,
});
const projectId = await createTestProject(t);
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {
@@ -149,10 +156,7 @@ describe("projectEvents", () => {
test("agent status emits an agent.<status> event linked to the run", async () => {
const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
ownerId: ID_A,
repository,
});
const projectId = await createTestProject(t);
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {
@@ -192,10 +196,7 @@ describe("projectEvents", () => {
test("wrong agent token is rejected before any event is written", async () => {
const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
ownerId: ID_A,
repository,
});
const projectId = await createTestProject(t);
const issueId = await t
.withIdentity(identityA)
.mutation(api.projectIssues.create, {

View File

@@ -1,21 +1,7 @@
import { ConvexError, v } from "convex/values";
import type { Id } from "./_generated/dataModel";
import { mutation, query } from "./_generated/server";
import type { MutationCtx } from "./_generated/server";
import { requireOwnerId } from "./authz";
const requireProjectOwner = async (
ctx: MutationCtx,
projectId: Id<"projects">,
ownerId: string
) => {
const project = await ctx.db.get("projects", projectId);
if (!project || project.ownerId !== ownerId) {
throw new ConvexError("Project not found");
}
return project;
};
import { requireProjectMember } from "./authz";
export const create = mutation({
args: {
@@ -24,8 +10,7 @@ export const create = mutation({
title: v.string(),
},
handler: async (ctx, args) => {
const ownerId = await requireOwnerId(ctx);
await requireProjectOwner(ctx, args.projectId, ownerId);
await requireProjectMember(ctx, args.projectId);
const title = args.title.trim();
const body = args.body.trim();
if (title.length < 3 || title.length > 160) {
@@ -81,12 +66,11 @@ export const create = mutation({
export const begin = mutation({
args: { issueId: v.id("projectIssues") },
handler: async (ctx, args) => {
const ownerId = await requireOwnerId(ctx);
const issue = await ctx.db.get("projectIssues", args.issueId);
if (!issue) {
throw new ConvexError("Issue not found");
}
await requireProjectOwner(ctx, issue.projectId, ownerId);
await requireProjectMember(ctx, issue.projectId);
if (issue.status === "queued" || issue.status === "working") {
return issue.status;
}
@@ -109,12 +93,11 @@ export const begin = mutation({
export const markDispatchFailed = mutation({
args: { error: v.string(), issueId: v.id("projectIssues") },
handler: async (ctx, args) => {
const ownerId = await requireOwnerId(ctx);
const issue = await ctx.db.get("projectIssues", args.issueId);
if (!issue) {
throw new ConvexError("Issue not found");
}
await requireProjectOwner(ctx, issue.projectId, ownerId);
await requireProjectMember(ctx, issue.projectId);
const timestamp = Date.now();
await ctx.db.patch("projectIssues", issue._id, {
status: "failed",
@@ -133,9 +116,9 @@ export const markDispatchFailed = mutation({
export const list = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
const ownerId = await requireOwnerId(ctx);
const project = await ctx.db.get("projects", args.projectId);
if (!project || project.ownerId !== ownerId) {
try {
await requireProjectMember(ctx, args.projectId);
} catch {
return [];
}
return await ctx.db

View File

@@ -0,0 +1,430 @@
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 };

View File

@@ -1,176 +1,228 @@
import { ConvexError, v } from "convex/values";
import { z } from "zod";
import {
preparePublicGitSource,
type ProjectImportOutcome,
type ProjectView,
type PublicGitImportResult,
type PreparedPublicGitSource,
} from "@code/primitives/project";
import { v } from "convex/values";
import { Effect } from "effect";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { action, internalMutation, query } from "./_generated/server";
import { createInitialArtifacts } from "./artifactModel";
import { requireOwnerId } from "./authz";
import { requireCurrentOrganization } from "./authz";
import { persistPublicGitImportTransaction } from "./projectStore";
const gitHubRepositorySchema = z.object({
default_branch: z.string(),
description: z.string().nullable(),
html_url: z.url(),
id: z.number(),
name: z.string(),
owner: z.object({ login: z.string() }),
});
// ---------------------------------------------------------------------------
// Internal: persist a public Git import in one transaction
// ---------------------------------------------------------------------------
const parseRepository = (value: string): { owner: string; repo: string } => {
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 requireOwnerId(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({
export const persistPublicGitImport = internalMutation({
args: {
ownerId: v.string(),
repository: v.object({
defaultBranch: v.string(),
description: v.optional(v.string()),
id: v.number(),
name: v.string(),
owner: v.string(),
userId: v.string(),
source: v.object({
host: v.string(),
projectName: v.string(),
repositoryPath: v.string(),
normalizedUrl: 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) => {
const existing = await ctx.db
.query("projects")
.withIndex("by_owner_and_repository", (q) =>
q
.eq("ownerId", args.ownerId)
.eq("provider", "github")
.eq("repoOwner", args.repository.owner)
.eq("repoName", args.repository.name)
handler: async (ctx, args): Promise<ProjectImportOutcome> => {
const result = await persistPublicGitImportTransaction(
ctx,
args.userId,
args.source as PreparedPublicGitSource,
args.remote as PublicGitImportResult
);
// Seed operational artifacts on first creation (detected by checking
// existing artifacts)
const existingArtifacts = await ctx.db
.query("projectArtifacts")
.withIndex("by_project", (q) =>
q.eq("projectId", result.id as unknown as Id<"projects">)
)
.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,
.first();
if (!existingArtifacts) {
const now = Date.now();
const artifacts = createInitialArtifacts();
await Promise.all(
artifacts.map(({ content, path }) =>
ctx.db.insert("projectArtifacts", {
content,
createdAt: now,
path,
projectId: result.id as unknown as Id<"projects">,
revision: 1,
updatedAt: now,
})
)
);
await ctx.db.insert("projectEvents", {
createdAt: now,
data: { host: result.source.host, url: result.source.url },
kind: "project.connected",
projectId: result.id as unknown as Id<"projects">,
});
return existing._id;
}
const projectId = await ctx.db.insert("projects", {
createdAt: timestamp,
defaultBranch: args.repository.defaultBranch,
description: args.repository.description,
name: args.repository.name,
ownerId: args.ownerId,
provider: "github",
providerRepoId: args.repository.id,
repoName: args.repository.name,
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(
artifacts.map(({ content, path }) =>
ctx.db.insert("projectArtifacts", {
content,
createdAt: timestamp,
path,
projectId,
revision: 1,
updatedAt: timestamp,
})
)
);
await ctx.db.insert("projectEvents", {
createdAt: timestamp,
data: { provider: "github", repository: args.repository.url },
kind: "project.connected",
projectId,
});
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({
args: {},
handler: async (ctx) => {
const ownerId = await requireOwnerId(ctx);
return await ctx.db
.query("projects")
.withIndex("by_owner_and_createdAt", (q) => q.eq("ownerId", ownerId))
.order("desc")
.take(50);
handler: async (ctx): Promise<ProjectView[]> => {
const { userId } = await requireCurrentOrganization(ctx);
const { makeProjectQueryStore } = await import("./projectStore");
const store = makeProjectQueryStore(ctx);
return store.listProjects(userId);
},
});
// ---------------------------------------------------------------------------
// Public query: get a project
// ---------------------------------------------------------------------------
export const get = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
const ownerId = await requireOwnerId(ctx);
const project = await ctx.db.get("projects", args.projectId);
return project?.ownerId === ownerId ? project : null;
handler: async (ctx, args): Promise<ProjectView | null> => {
const { userId } = await requireCurrentOrganization(ctx);
const { makeProjectQueryStore } = await import("./projectStore");
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,
});
},
});

View File

@@ -63,25 +63,57 @@ export default defineSchema({
.index("by_daemonId_and_createdAt", ["daemonId", "createdAt"])
.index("by_commandId_and_createdAt", ["commandId", "createdAt"]),
projects: defineTable({
ownerId: v.string(),
provider: v.literal("github"),
providerRepoId: v.number(),
organizationId: v.id("organizations"),
name: v.string(),
description: v.optional(v.string()),
repoOwner: v.string(),
repoName: v.string(),
repoUrl: v.string(),
defaultBranch: v.string(),
createdAt: v.number(),
updatedAt: v.number(),
}).index("by_organization_and_createdAt", ["organizationId", "createdAt"]),
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(),
updatedAt: v.number(),
})
.index("by_owner_and_createdAt", ["ownerId", "createdAt"])
.index("by_owner_and_repository", [
"ownerId",
"provider",
"repoOwner",
"repoName",
]),
.index("by_project", ["projectId"])
.index("by_organization_and_normalizedUrl", [
"organizationId",
"normalizedUrl",
])
.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({
projectId: v.id("projects"),
path: v.string(),
@@ -165,6 +197,7 @@ export default defineSchema({
createdAt: v.number(),
})
.index("by_userId", ["userId"])
.index("by_organizationId", ["organizationId"])
.index("by_organizationId_and_userId", ["organizationId", "userId"]),
// -----------------------------------------------------------------

View File

@@ -44,14 +44,6 @@ const problemStatement = {
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.
const seedMessage = async (
t: TestConvex<typeof schema>,
@@ -79,13 +71,37 @@ const seedMessage = async (
const createProject = async (
t: TestConvex<typeof schema>,
ownerId: string,
repoId = 12345
ownerId: string
): Promise<Id<"projects">> => {
return await t.mutation(internal.projects.storeGitHubProject, {
ownerId,
repository: { ...repository, id: repoId },
});
// Resolve or create the owner's personal organization
const ownerIdentity = { tokenIdentifier: ownerId };
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", () => {

View File

@@ -161,13 +161,9 @@ const resolveSources = async (
};
/**
* Resolve whether an optional project belongs to the same organization. Until
* projects carry an explicit `organizationId`, a project is attachable to an
* organization's signal only when the project owner is a member of that
* 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.
* Verify a project belongs to the same organization. Projects now carry an
* explicit `organizationId`; the caller is already proven to be a member, so
* this is a direct invariant check with no cross-tenant leakage.
*/
const authorizeProject = async (
ctx: MutationCtx,
@@ -178,13 +174,7 @@ const authorizeProject = async (
if (!project) {
throw new ConvexError("Project not found");
}
const ownerMembership = await ctx.db
.query("organizationMembers")
.withIndex("by_organizationId_and_userId", (q) =>
q.eq("organizationId", organizationId).eq("userId", project.ownerId)
)
.unique();
if (!ownerMembership) {
if (project.organizationId !== organizationId) {
throw new ConvexError("Project does not belong to the target organization");
}
};

View File

@@ -7,6 +7,7 @@
"scripts": {
"dev": "convex dev --env-file ../../.env",
"dev:setup": "convex dev --env-file ../../.env --configure --until-success",
"check-types": "tsc --noEmit -p convex/tsconfig.json",
"test": "vitest run",
"test:watch": "vitest"
},

View File

@@ -6,6 +6,7 @@
"exports": {
".": "./src/index.ts",
"./agent-os": "./src/agent-os.ts",
"./project": "./src/project.ts",
"./signal": "./src/signal.ts"
},
"scripts": {

View File

@@ -1,3 +1,4 @@
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
export * from "./agent-os";
export * from "./project";
export * from "./signal";

View File

@@ -0,0 +1,762 @@
/* eslint-disable max-classes-per-file -- deep Effect v4 domain module: branded schemas, tagged errors, and context services */
import { Context, Effect, Layer, Schema } from "effect";
import { OrganizationId, ProjectId, TimestampMs } from "./signal.js";
export type { OrganizationId, ProjectId } from "./signal.js";
// ---------------------------------------------------------------------------
// Domain constants
// ---------------------------------------------------------------------------
export const MAX_CONTEXT_DOCUMENT_CHARACTERS = 200_000;
export const MAX_CONTEXT_BATCH_CHARACTERS = 600_000;
export const CONTEXT_KINDS = [
"readme",
"agents",
"product",
"business",
"design",
"tech",
] as const;
export type ContextKind = (typeof CONTEXT_KINDS)[number];
const CONTEXT_KIND_TO_PATH: Readonly<Record<ContextKind, string>> = {
agents: "AGENTS.md",
business: "business.md",
design: "design.md",
product: "product.md",
readme: "README.md",
tech: "tech.md",
};
const PATH_TO_CONTEXT_KIND: Readonly<Record<string, ContextKind>> =
Object.fromEntries(
Object.entries(CONTEXT_KIND_TO_PATH).map(([kind, path]) => [
path,
kind as ContextKind,
])
);
export const contextPathForKind = (kind: ContextKind): string =>
CONTEXT_KIND_TO_PATH[kind];
export const contextKindForPath = (path: string): ContextKind | null =>
PATH_TO_CONTEXT_KIND[path] ?? null;
const ContextKindSchema = Schema.Literals([...CONTEXT_KINDS]);
const MeaningfulString = Schema.String.check(
Schema.makeFilter((value) => value.trim().length > 0, {
expected: "a non-empty string",
})
);
// ---------------------------------------------------------------------------
// Shared data shapes
// ---------------------------------------------------------------------------
export const PreparedPublicGitSource = Schema.Struct({
host: Schema.String,
normalizedUrl: Schema.String,
projectName: MeaningfulString,
repositoryPath: MeaningfulString,
url: Schema.String,
});
export type PreparedPublicGitSource = typeof PreparedPublicGitSource.Type;
export const RepositoryContextDocument = Schema.Struct({
content: Schema.String,
kind: ContextKindSchema,
path: Schema.String,
});
export type RepositoryContextDocument = typeof RepositoryContextDocument.Type;
export const ProjectImportWarning = Schema.Struct({
message: Schema.String,
path: Schema.String,
});
export type ProjectImportWarning = typeof ProjectImportWarning.Type;
export const PublicGitImportResult = Schema.Struct({
defaultBranch: Schema.UndefinedOr(Schema.String),
documents: Schema.Array(RepositoryContextDocument),
warnings: Schema.Array(ProjectImportWarning),
});
export type PublicGitImportResult = typeof PublicGitImportResult.Type;
export const PublicTextResult = Schema.Struct({
content: Schema.String,
finalUrl: Schema.String,
});
export type PublicTextResult = typeof PublicTextResult.Type;
export const ContextOrigin = Schema.Literals([
"repository",
"paste",
"upload",
"public-url",
]);
export type ContextOrigin = typeof ContextOrigin.Type;
export const ContextDocumentState = Schema.Struct({
content: Schema.String,
kind: ContextKindSchema,
origin: ContextOrigin,
path: Schema.String,
revision: Schema.Number,
sourceUrl: Schema.UndefinedOr(Schema.String),
});
export type ContextDocumentState = typeof ContextDocumentState.Type;
const RepositoryWrite = Schema.Struct({
_tag: Schema.Literal("Repository"),
content: Schema.String,
kind: ContextKindSchema,
sourceUrl: Schema.String,
});
const PublicTextUrlWrite = Schema.Struct({
_tag: Schema.Literal("PublicTextUrl"),
content: Schema.String,
kind: ContextKindSchema,
sourceUrl: Schema.String,
});
const UserTextWrite = Schema.Struct({
_tag: Schema.Literal("UserText"),
content: Schema.String,
kind: ContextKindSchema,
origin: Schema.Literals(["paste", "upload"]),
});
export const ContextWrite = Schema.Union([
RepositoryWrite,
PublicTextUrlWrite,
UserTextWrite,
]);
export type ContextWrite = typeof ContextWrite.Type;
export const ProjectSourceView = Schema.Struct({
createdAt: TimestampMs,
defaultBranch: Schema.UndefinedOr(Schema.String),
host: Schema.String,
kind: Schema.Literal("git"),
normalizedUrl: Schema.String,
projectId: ProjectId,
repositoryPath: Schema.String,
updatedAt: TimestampMs,
url: Schema.String,
});
export type ProjectSourceView = typeof ProjectSourceView.Type;
export const ProjectView = Schema.Struct({
contextDocuments: Schema.Array(ContextDocumentState),
createdAt: TimestampMs,
id: ProjectId,
name: Schema.String,
organizationId: OrganizationId,
sources: Schema.Array(ProjectSourceView),
updatedAt: TimestampMs,
});
export type ProjectView = typeof ProjectView.Type;
export const ProjectImportOutcome = Schema.Struct({
contextDocuments: Schema.Array(ContextDocumentState),
createdAt: TimestampMs,
id: ProjectId,
name: Schema.String,
organizationId: OrganizationId,
source: ProjectSourceView,
updatedAt: TimestampMs,
});
export type ProjectImportOutcome = typeof ProjectImportOutcome.Type;
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
export const PublicGitErrorReason = Schema.Literals([
"InvalidUrl",
"Unreachable",
"InvalidResponse",
"Cancelled",
]);
export type PublicGitErrorReason = typeof PublicGitErrorReason.Type;
export class PublicGitError extends Schema.TaggedErrorClass<PublicGitError>()(
"PublicGitError",
{
message: Schema.String,
reason: PublicGitErrorReason,
}
) {}
export const ProjectStoreErrorReason = Schema.Literals([
"NotFound",
"Forbidden",
"Conflict",
"Persistence",
]);
export type ProjectStoreErrorReason = typeof ProjectStoreErrorReason.Type;
export class ProjectStoreError extends Schema.TaggedErrorClass<ProjectStoreError>()(
"ProjectStoreError",
{
message: Schema.String,
reason: ProjectStoreErrorReason,
}
) {}
export const ProjectApplicationErrorReason = Schema.Literals([
"Authentication",
"InvalidPublicGitUrl",
"PublicGitUnreachable",
"InvalidRemoteResult",
"InvalidContextInput",
"ContextDocumentTooLarge",
"ContextBatchTooLarge",
"ProjectNotFound",
"Persistence",
]);
export type ProjectApplicationErrorReason =
typeof ProjectApplicationErrorReason.Type;
export class ProjectApplicationError extends Schema.TaggedErrorClass<ProjectApplicationError>()(
"ProjectApplicationError",
{
message: Schema.String,
reason: ProjectApplicationErrorReason,
}
) {}
// ---------------------------------------------------------------------------
// Pure domain functions
// ---------------------------------------------------------------------------
const isWhitespaceOnly = (value: string): boolean => value.trim().length === 0;
const INVALID_GIT_URL = new ProjectApplicationError({
message: "Use a public http(s) Git repository URL",
reason: "InvalidPublicGitUrl",
});
const INVALID_REMOTE = new ProjectApplicationError({
message: "Invalid remote result",
reason: "InvalidRemoteResult",
});
export const preparePublicGitSource = (
rawUrl: string
): Effect.Effect<PreparedPublicGitSource, ProjectApplicationError> =>
Effect.gen(function* prepareSource() {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
return yield* Effect.fail(INVALID_GIT_URL);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return yield* Effect.fail(INVALID_GIT_URL);
}
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
return yield* Effect.fail(INVALID_GIT_URL);
}
const host = parsed.hostname.toLowerCase();
let repositoryPath = parsed.pathname;
if (repositoryPath.startsWith("/")) {
repositoryPath = repositoryPath.slice(1);
}
if (repositoryPath.endsWith("/")) {
repositoryPath = repositoryPath.slice(0, -1);
}
if (repositoryPath.endsWith(".git")) {
repositoryPath = repositoryPath.slice(0, -4);
}
if (repositoryPath.length === 0) {
return yield* Effect.fail(INVALID_GIT_URL);
}
const segments = repositoryPath.split("/").filter((s) => s.length > 0);
// eslint-disable-next-line unicorn/prefer-at -- backend Convex tsconfig targets pre-ES2022
const lastSegment = segments[segments.length - 1];
if (!lastSegment || isWhitespaceOnly(lastSegment)) {
return yield* Effect.fail(INVALID_GIT_URL);
}
const projectName = lastSegment;
const normalizedUrl = `${parsed.protocol}//${host}/${repositoryPath}`;
return {
host,
normalizedUrl,
projectName,
repositoryPath,
url: normalizedUrl,
};
});
export const decodePublicGitImportResult = (
raw: unknown
): Effect.Effect<PublicGitImportResult, ProjectApplicationError> =>
Effect.gen(function* decodeImport() {
const result = yield* Schema.decodeUnknownEffect(PublicGitImportResult)(
raw
).pipe(Effect.mapError(() => INVALID_REMOTE));
const seenKinds = new Set<string>();
const seenPaths = new Set<string>();
let totalChars = 0;
for (const doc of result.documents) {
const canonicalPath = CONTEXT_KIND_TO_PATH[doc.kind];
if (doc.path !== canonicalPath) {
return yield* Effect.fail(INVALID_REMOTE);
}
if (seenKinds.has(doc.kind) || seenPaths.has(doc.path)) {
return yield* Effect.fail(INVALID_REMOTE);
}
if (isWhitespaceOnly(doc.content)) {
return yield* Effect.fail(INVALID_REMOTE);
}
if (doc.content.length > MAX_CONTEXT_DOCUMENT_CHARACTERS) {
return yield* Effect.fail(
new ProjectApplicationError({
message: "Context document exceeds 200000 characters",
reason: "ContextDocumentTooLarge",
})
);
}
seenKinds.add(doc.kind);
seenPaths.add(doc.path);
totalChars += doc.content.length;
}
if (totalChars > MAX_CONTEXT_BATCH_CHARACTERS) {
return yield* Effect.fail(
new ProjectApplicationError({
message: "Context batch exceeds 600000 characters",
reason: "ContextBatchTooLarge",
})
);
}
return result;
});
export const decodePublicTextResult = (
raw: unknown
): Effect.Effect<PublicTextResult, ProjectApplicationError> =>
Effect.gen(function* decodeText() {
const result = yield* Schema.decodeUnknownEffect(PublicTextResult)(
raw
).pipe(Effect.mapError(() => INVALID_REMOTE));
if (isWhitespaceOnly(result.content)) {
return yield* Effect.fail(
new ProjectApplicationError({
message: "Context document cannot be empty",
reason: "InvalidContextInput",
})
);
}
if (result.content.length > MAX_CONTEXT_DOCUMENT_CHARACTERS) {
return yield* Effect.fail(
new ProjectApplicationError({
message: "Context document exceeds 200000 characters",
reason: "ContextDocumentTooLarge",
})
);
}
if (isWhitespaceOnly(result.finalUrl)) {
return yield* Effect.fail(INVALID_REMOTE);
}
return result;
});
const resolveKindFromPath = (path: string): ContextKind => {
const kind = contextKindForPath(path);
if (kind === null) {
throw new Error(`Unknown canonical context path: ${path}`);
}
return kind;
};
export const makeInitialContext = (
projectName: string,
repositoryUrl: string
): readonly ContextDocumentState[] => {
const placeholder = (path: string): ContextDocumentState => ({
content: `# ${path}\n\nContext not imported yet.\n`,
kind: resolveKindFromPath(path),
origin: "repository",
path,
revision: 1,
sourceUrl: repositoryUrl,
});
return [
{
content: `# ${projectName}\n\nRepository: ${repositoryUrl}\n`,
kind: "readme",
origin: "repository",
path: "README.md",
revision: 1,
sourceUrl: repositoryUrl,
},
placeholder("AGENTS.md"),
placeholder("product.md"),
placeholder("business.md"),
placeholder("design.md"),
placeholder("tech.md"),
];
};
const resolveWriteOrigin = (write: ContextWrite): ContextOrigin => {
if (write._tag === "Repository") {
return "repository";
}
if (write._tag === "PublicTextUrl") {
return "public-url";
}
return write.origin;
};
export interface DecideContextWriteInput {
readonly existing: ContextDocumentState | undefined;
readonly write: ContextWrite;
}
export interface DecideContextWriteResult {
readonly document: ContextDocumentState;
readonly changed: boolean;
}
export const decideContextWrite = (
input: DecideContextWriteInput
): Effect.Effect<DecideContextWriteResult, ProjectApplicationError> =>
Effect.gen(function* decideWrite() {
const { write } = input;
const { content } = write;
const { kind } = write;
const path = contextPathForKind(kind);
if (isWhitespaceOnly(content)) {
return yield* Effect.fail(
new ProjectApplicationError({
message: "Context document cannot be empty",
reason: "InvalidContextInput",
})
);
}
if (content.length > MAX_CONTEXT_DOCUMENT_CHARACTERS) {
return yield* Effect.fail(
new ProjectApplicationError({
message: "Context document exceeds 200000 characters",
reason: "ContextDocumentTooLarge",
})
);
}
const origin: ContextOrigin = resolveWriteOrigin(write);
const sourceUrl: string | undefined =
write._tag === "UserText" ? undefined : write.sourceUrl;
const { existing } = input;
if (
existing &&
existing.content === content &&
existing.origin === origin &&
existing.sourceUrl === sourceUrl
) {
return { changed: false, document: existing };
}
const nextRevision = (existing?.revision ?? 0) + 1;
return {
changed: true,
document: {
content,
kind,
origin,
path,
revision: nextRevision,
sourceUrl,
},
};
});
// ---------------------------------------------------------------------------
// ProjectDomain service
// ---------------------------------------------------------------------------
interface ProjectDomainShape {
readonly preparePublicGitSource: typeof preparePublicGitSource;
readonly decodePublicGitImportResult: typeof decodePublicGitImportResult;
readonly decodePublicTextResult: typeof decodePublicTextResult;
readonly makeInitialContext: typeof makeInitialContext;
readonly decideContextWrite: typeof decideContextWrite;
}
export class ProjectDomain extends Context.Service<
ProjectDomain,
ProjectDomainShape
>()("@code/primitives/project/ProjectDomain") {
static readonly layer = Layer.succeed(
ProjectDomain,
ProjectDomain.of({
decideContextWrite,
decodePublicGitImportResult,
decodePublicTextResult,
makeInitialContext,
preparePublicGitSource,
})
);
}
// ---------------------------------------------------------------------------
// PublicGit port
// ---------------------------------------------------------------------------
interface PublicGitShape {
readonly inspect: (
source: PreparedPublicGitSource
) => Effect.Effect<PublicGitImportResult, PublicGitError>;
readonly fetchText: (
url: string
) => Effect.Effect<PublicTextResult, PublicGitError>;
}
export class PublicGit extends Context.Service<PublicGit, PublicGitShape>()(
"@code/primitives/project/PublicGit"
) {}
// ---------------------------------------------------------------------------
// ProjectStore port
// ---------------------------------------------------------------------------
interface ProjectStoreShape {
readonly getCurrentOrganization: (
userId: string
) => Effect.Effect<OrganizationId, ProjectStoreError>;
readonly listProjects: (
userId: string
) => Effect.Effect<readonly ProjectView[], ProjectStoreError>;
readonly getProject: (
userId: string,
projectId: ProjectId
) => Effect.Effect<ProjectView | null, ProjectStoreError>;
readonly persistPublicGitImport: (input: {
readonly userId: string;
readonly source: PreparedPublicGitSource;
readonly remote: PublicGitImportResult;
}) => Effect.Effect<ProjectImportOutcome, ProjectStoreError>;
readonly putContext: (input: {
readonly userId: string;
readonly projectId: ProjectId;
readonly write: ContextWrite;
}) => Effect.Effect<{ readonly revision: number }, ProjectStoreError>;
}
export class ProjectStore extends Context.Service<
ProjectStore,
ProjectStoreShape
>()("@code/primitives/project/ProjectStore") {}
// ---------------------------------------------------------------------------
// Error mapping
// ---------------------------------------------------------------------------
const mapStoreError = (error: ProjectStoreError): ProjectApplicationError => {
if (error.reason === "NotFound" || error.reason === "Forbidden") {
return new ProjectApplicationError({
message: "Project not found",
reason: "ProjectNotFound",
});
}
return new ProjectApplicationError({
message: error.message,
reason: "Persistence",
});
};
const mapPublicGitError = (error: PublicGitError): ProjectApplicationError => {
if (error.reason === "InvalidUrl") {
return new ProjectApplicationError({
message: error.message,
reason: "InvalidPublicGitUrl",
});
}
if (error.reason === "Unreachable" || error.reason === "Cancelled") {
return new ProjectApplicationError({
message: error.message,
reason: "PublicGitUnreachable",
});
}
return new ProjectApplicationError({
message: error.message,
reason: "InvalidRemoteResult",
});
};
// ---------------------------------------------------------------------------
// ProjectApplication service
// ---------------------------------------------------------------------------
interface ProjectApplicationShape {
readonly importPublicGit: (input: {
readonly userId: string;
readonly repositoryUrl: unknown;
}) => Effect.Effect<ProjectImportOutcome, ProjectApplicationError>;
readonly importPublicText: (input: {
readonly userId: string;
readonly projectId: ProjectId;
readonly kind: ContextKind;
readonly url: string;
}) => Effect.Effect<{ readonly revision: number }, ProjectApplicationError>;
readonly putContext: (input: {
readonly userId: string;
readonly projectId: ProjectId;
readonly kind: ContextKind;
readonly content: string;
readonly origin: "paste" | "upload";
}) => Effect.Effect<{ readonly revision: number }, ProjectApplicationError>;
readonly listProjects: (input: {
readonly userId: string;
}) => Effect.Effect<readonly ProjectView[], ProjectApplicationError>;
readonly getProject: (input: {
readonly userId: string;
readonly projectId: ProjectId;
}) => Effect.Effect<ProjectView | null, ProjectApplicationError>;
}
export class ProjectApplication extends Context.Service<
ProjectApplication,
ProjectApplicationShape
>()("@code/primitives/project/ProjectApplication") {
static readonly layer = Layer.effect(
ProjectApplication,
Effect.gen(function* layer() {
const domain = yield* ProjectDomain;
const publicGit = yield* PublicGit;
const store = yield* ProjectStore;
return ProjectApplication.of({
getProject: Effect.fn("ProjectApplication.getProject")(function* getProject({
userId,
projectId,
}: {
readonly userId: string;
readonly projectId: ProjectId;
}) {
return yield* store
.getProject(userId, projectId)
.pipe(Effect.mapError(mapStoreError));
}),
importPublicGit: Effect.fn("ProjectApplication.importPublicGit")(
function* importPublicGit({
userId,
repositoryUrl,
}: {
readonly userId: string;
readonly repositoryUrl: unknown;
}) {
if (typeof repositoryUrl !== "string") {
return yield* Effect.fail(INVALID_GIT_URL);
}
const source = yield* domain.preparePublicGitSource(repositoryUrl);
const rawRemote = yield* publicGit
.inspect(source)
.pipe(Effect.mapError(mapPublicGitError));
const remote = yield* domain.decodePublicGitImportResult(rawRemote);
return yield* store
.persistPublicGitImport({ remote, source, userId })
.pipe(Effect.mapError(mapStoreError));
}
),
importPublicText: Effect.fn("ProjectApplication.importPublicText")(
function* importPublicText({
userId,
projectId,
kind,
url,
}: {
readonly userId: string;
readonly projectId: ProjectId;
readonly kind: ContextKind;
readonly url: string;
}) {
if (isWhitespaceOnly(url)) {
return yield* Effect.fail(
new ProjectApplicationError({
message: "Use a public text URL",
reason: "InvalidContextInput",
})
);
}
const rawText = yield* publicGit
.fetchText(url)
.pipe(Effect.mapError(mapPublicGitError));
const text = yield* domain.decodePublicTextResult(rawText);
const write: ContextWrite = {
_tag: "PublicTextUrl",
content: text.content,
kind,
sourceUrl: text.finalUrl,
};
return yield* store
.putContext({ projectId, userId, write })
.pipe(Effect.mapError(mapStoreError));
}
),
listProjects: Effect.fn("ProjectApplication.listProjects")(function* listProjects({
userId,
}: {
readonly userId: string;
}) {
return yield* store
.listProjects(userId)
.pipe(Effect.mapError(mapStoreError));
}),
putContext: Effect.fn("ProjectApplication.putContext")(function* putContext({
userId,
projectId,
kind,
content,
origin,
}: {
readonly userId: string;
readonly projectId: ProjectId;
readonly kind: ContextKind;
readonly content: string;
readonly origin: "paste" | "upload";
}) {
const write: ContextWrite = {
_tag: "UserText",
content,
kind,
origin,
};
return yield* store
.putContext({ projectId, userId, write })
.pipe(Effect.mapError(mapStoreError));
}),
});
})
);
}