From 477e54240d73b2a84ccebeed567767453dae9987 Mon Sep 17 00:00:00 2001 From: -Puter <22245429+puterhimself@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:04:02 +0530 Subject: [PATCH 1/7] feat: consolidate web auth and add Effect v4 Project primitives Auth: - Add useWebAuth hook as the single web-facing auth interface with four states (loading/unauthenticated/authenticated/inconsistent) - Normalize getCurrentUser to return {id: tokenIdentifier, name, email} - Rename requireOwnerId to requireAuthUserId across all backend callers - Set expectAuth:true on ConvexReactClient singleton - Migrate _app, _auth, user-menu, use-personal-organization to useWebAuth - Remove unused @code/auth/server export (zero callers confirmed) Primitives: - Add packages/primitives/src/project.ts with Effect v4 Project domain: URL normalization, six canonical context kinds, strict remote decoding, context write decisioning with revision tracking, and no-op detection - Define PublicGit and ProjectStore replaceable ports - Define ProjectApplication service orchestrating domain+ports with deterministic error mapping - Add comprehensive test suite (28 tests) with in-memory store and fake PublicGit covering normalization, seeds, idempotency, tenant isolation, last-write-wins, and typed error mapping --- apps/web/src/components/user-menu.tsx | 16 +- .../hooks/use-personal-organization.test.ts | 71 +- .../src/hooks/use-personal-organization.ts | 53 +- apps/web/src/routes/_app.tsx | 16 +- apps/web/src/routes/_auth.tsx | 8 +- packages/auth/package.json | 1 - packages/auth/src/server/index.ts | 1 - packages/auth/src/web/auth-provider.tsx | 2 +- packages/auth/src/web/index.ts | 2 + packages/auth/src/web/use-web-auth.ts | 81 ++ packages/backend/convex/auth.ts | 28 +- packages/backend/convex/authz.ts | 4 +- packages/backend/convex/organizations.ts | 6 +- packages/backend/convex/projectArtifacts.ts | 4 +- packages/backend/convex/projectIssues.ts | 10 +- packages/backend/convex/projects.ts | 8 +- packages/primitives/package.json | 1 + packages/primitives/src/index.ts | 1 + packages/primitives/src/project.test.ts | 857 ++++++++++++++++++ packages/primitives/src/project.ts | 754 +++++++++++++++ 20 files changed, 1827 insertions(+), 97 deletions(-) delete mode 100644 packages/auth/src/server/index.ts create mode 100644 packages/auth/src/web/use-web-auth.ts create mode 100644 packages/primitives/src/project.test.ts create mode 100644 packages/primitives/src/project.ts diff --git a/apps/web/src/components/user-menu.tsx b/apps/web/src/components/user-menu.tsx index 0205141..e6281f5 100644 --- a/apps/web/src/components/user-menu.tsx +++ b/apps/web/src/components/user-menu.tsx @@ -1,5 +1,4 @@ -import { authClient } from "@code/auth/web"; -import { api } from "@code/backend/convex/_generated/api"; +import { signOutWeb, useWebAuth } from "@code/auth/web"; import { Button } from "@code/ui/components/button"; import { DropdownMenu, @@ -10,12 +9,13 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@code/ui/components/dropdown-menu"; -import { useQuery } from "convex/react"; import { useNavigate } from "react-router"; export default function UserMenu() { const navigate = useNavigate(); - const user = useQuery(api.auth.getCurrentUser); + const auth = useWebAuth(); + + const user = auth.status === "authenticated" ? auth.user : null; return ( @@ -30,12 +30,8 @@ export default function UserMenu() { { - authClient.signOut({ - fetchOptions: { - onSuccess: () => { - navigate("/login", { replace: true }); - }, - }, + void signOutWeb().then(() => { + navigate("/login", { replace: true }); }); }} > diff --git a/apps/web/src/hooks/use-personal-organization.test.ts b/apps/web/src/hooks/use-personal-organization.test.ts index 3a1dc23..a601bf4 100644 --- a/apps/web/src/hooks/use-personal-organization.test.ts +++ b/apps/web/src/hooks/use-personal-organization.test.ts @@ -6,18 +6,18 @@ import { usePersonalOrganization } from "./use-personal-organization"; const mocks = vi.hoisted(() => ({ cleanup: undefined as (() => void) | undefined, ensurePersonalOrganization: vi.fn(), - session: { - data: { session: { id: "session-a" } }, - } as { data: { session: { id: string } } | null }, - state: null as { - error?: Error; - organizationId?: string; - sessionId: string; - } | null, + authState: { + status: "unauthenticated", + } as + | { status: "unauthenticated" } + | { status: "authenticated"; user: { id: string } }, + orgId: undefined as string | undefined, + orgError: undefined as Error | undefined, + setterIndex: 0, })); vi.mock("@code/auth/web", () => ({ - authClient: { useSession: () => mocks.session }, + useWebAuth: () => mocks.authState, })); vi.mock("@code/backend/convex/_generated/api", () => ({ api: { organizations: { ensurePersonalOrganization: "ensure-org" } }, @@ -25,18 +25,36 @@ vi.mock("@code/backend/convex/_generated/api", () => ({ vi.mock("convex/react", () => ({ useMutation: () => mocks.ensurePersonalOrganization, })); + +// useState is called twice: first for organizationId, second for error. +// We give each its own state cell so the test can read them independently. +const stateCells = { + error: undefined as Error | undefined, + orgId: undefined as string | undefined, +}; + vi.mock("react", () => ({ useEffect: (effect: EffectCallback) => { mocks.cleanup?.(); const cleanup = effect(); mocks.cleanup = typeof cleanup === "function" ? cleanup : undefined; }, - useState: () => [ - mocks.state, - (next: { error?: Error; organizationId?: string; sessionId: string }) => { - mocks.state = next; - }, - ], + useState: (initial: T) => { + const callIndex = mocks.setterIndex; + mocks.setterIndex = (mocks.setterIndex + 1) % 2; + const cellKey = callIndex === 0 ? "orgId" : "error"; + const current = + cellKey === "orgId" ? stateCells.orgId : (stateCells.error as T); + const value = current === undefined ? initial : current; + const setter = (next: T) => { + if (cellKey === "orgId") { + stateCells.orgId = next as string | undefined; + } else { + stateCells.error = next as Error | undefined; + } + }; + return [value, setter]; + }, })); describe("usePersonalOrganization", () => { @@ -44,34 +62,31 @@ describe("usePersonalOrganization", () => { mocks.cleanup?.(); mocks.cleanup = undefined; mocks.ensurePersonalOrganization.mockReset(); - mocks.session = { data: { session: { id: "session-a" } } }; - mocks.state = null; + mocks.authState = { status: "unauthenticated" }; + mocks.setterIndex = 0; + stateCells.error = undefined; + stateCells.orgId = undefined; }); test("an authenticated session ensures its org before exposing the id", async () => { + mocks.authState = { status: "authenticated", user: { id: "user-a" } }; const ensured = Promise.withResolvers<{ _id: string }>(); mocks.ensurePersonalOrganization.mockReturnValue(ensured.promise); - expect(usePersonalOrganization()).toEqual({}); + usePersonalOrganization(); expect(mocks.ensurePersonalOrganization).toHaveBeenCalledTimes(1); ensured.resolve({ _id: "org-a" }); await ensured.promise; await Promise.resolve(); - expect(usePersonalOrganization()).toEqual({ - error: undefined, - organizationId: "org-a", - }); + expect(stateCells.orgId).toBe("org-a"); }); - test("logout cannot expose the prior session organization", async () => { - mocks.ensurePersonalOrganization.mockResolvedValue({ _id: "org-a" }); - usePersonalOrganization(); - await Promise.resolve(); - await Promise.resolve(); + test("an unauthenticated session never ensures an org", () => { + mocks.authState = { status: "unauthenticated" }; - mocks.session = { data: null }; expect(usePersonalOrganization()).toEqual({}); + expect(mocks.ensurePersonalOrganization).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/hooks/use-personal-organization.ts b/apps/web/src/hooks/use-personal-organization.ts index 67bd811..b25fd1d 100644 --- a/apps/web/src/hooks/use-personal-organization.ts +++ b/apps/web/src/hooks/use-personal-organization.ts @@ -1,33 +1,30 @@ -import { authClient } from "@code/auth/web"; +import { useWebAuth } from "@code/auth/web"; import { api } from "@code/backend/convex/_generated/api"; import type { Id } from "@code/backend/convex/_generated/dataModel"; import { useMutation } from "convex/react"; import { useEffect, useState } from "react"; -interface OrganizationBootstrapState { - readonly error?: Error; - readonly organizationId?: Id<"organizations">; - readonly sessionId: string; -} - export interface PersonalOrganizationState { readonly error?: Error; readonly organizationId?: Id<"organizations">; } -/** Ensure the authenticated session has its personal tenancy boundary. */ +/** Ensure the authenticated user has its personal tenancy boundary. */ export const usePersonalOrganization = (): PersonalOrganizationState => { - const { data: session } = authClient.useSession(); - const sessionId = session?.session.id; + const auth = useWebAuth(); + const userId = auth.status === "authenticated" ? auth.user.id : null; const ensurePersonalOrganization = useMutation( api.organizations.ensurePersonalOrganization ); - const [bootstrap, setBootstrap] = useState( - null - ); + const [organizationId, setOrganizationId] = useState< + Id<"organizations"> | undefined + >(); + const [error, setError] = useState(); useEffect(() => { - if (!sessionId) { + if (!userId) { + setOrganizationId(undefined); + setError(undefined); return; } @@ -36,20 +33,16 @@ export const usePersonalOrganization = (): PersonalOrganizationState => { try { const organization = await ensurePersonalOrganization(); if (active) { - setBootstrap({ - organizationId: organization._id, - sessionId, - }); + setOrganizationId(organization._id); + setError(undefined); } } catch (caughtError: unknown) { if (active) { - setBootstrap({ - error: - caughtError instanceof Error - ? caughtError - : new Error(String(caughtError)), - sessionId, - }); + setError( + caughtError instanceof Error + ? caughtError + : new Error(String(caughtError)) + ); } } }; @@ -58,13 +51,11 @@ export const usePersonalOrganization = (): PersonalOrganizationState => { return () => { active = false; }; - }, [ensurePersonalOrganization, sessionId]); + }, [ensurePersonalOrganization, userId]); - if (!sessionId || bootstrap?.sessionId !== sessionId) { + if (!userId) { return {}; } - return { - error: bootstrap.error, - organizationId: bootstrap.organizationId, - }; + + return { error, organizationId }; }; diff --git a/apps/web/src/routes/_app.tsx b/apps/web/src/routes/_app.tsx index 62cc28c..495893d 100644 --- a/apps/web/src/routes/_app.tsx +++ b/apps/web/src/routes/_app.tsx @@ -1,15 +1,23 @@ -import { useConvexAuth } from "convex/react"; +import { useWebAuth } from "@code/auth/web"; import { Navigate, Outlet, useLocation } from "react-router"; export default function AppLayout() { - const { isAuthenticated, isLoading } = useConvexAuth(); + const auth = useWebAuth(); const location = useLocation(); - if (isLoading) { + if (auth.status === "loading") { return
Loading…
; } - if (!isAuthenticated) { + if (auth.status === "inconsistent") { + return ( +
+ Your session could not be verified +
+ ); + } + + if (auth.status === "unauthenticated") { return ; } diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx index 4aa4173..a3b472b 100644 --- a/apps/web/src/routes/_auth.tsx +++ b/apps/web/src/routes/_auth.tsx @@ -1,14 +1,14 @@ -import { useConvexAuth } from "convex/react"; +import { useWebAuth } from "@code/auth/web"; import { Navigate, Outlet } from "react-router"; export default function AuthLayout() { - const { isAuthenticated, isLoading } = useConvexAuth(); + const auth = useWebAuth(); - if (isLoading) { + if (auth.status === "loading") { return
Loading…
; } - if (isAuthenticated) return ; + if (auth.status === "authenticated") return ; return (
diff --git a/packages/auth/package.json b/packages/auth/package.json index ce61425..6b8b1b5 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -5,7 +5,6 @@ "type": "module", "exports": { "./native": "./src/native/index.ts", - "./server": "./src/server/index.ts", "./web": "./src/web/index.ts" }, "scripts": { diff --git a/packages/auth/src/server/index.ts b/packages/auth/src/server/index.ts deleted file mode 100644 index 177ebdc..0000000 --- a/packages/auth/src/server/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { authComponent, createAuth } from "@code/backend/convex/auth"; diff --git a/packages/auth/src/web/auth-provider.tsx b/packages/auth/src/web/auth-provider.tsx index 86e47d5..457ee24 100644 --- a/packages/auth/src/web/auth-provider.tsx +++ b/packages/auth/src/web/auth-provider.tsx @@ -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 }) => ( diff --git a/packages/auth/src/web/index.ts b/packages/auth/src/web/index.ts index 165cedf..abc2d17 100644 --- a/packages/auth/src/web/index.ts +++ b/packages/auth/src/web/index.ts @@ -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"; diff --git a/packages/auth/src/web/use-web-auth.ts b/packages/auth/src/web/use-web-auth.ts new file mode 100644 index 0000000..31161d3 --- /dev/null +++ b/packages/auth/src/web/use-web-auth.ts @@ -0,0 +1,81 @@ +import { api } from "@code/backend/convex/_generated/api"; +import { useEffect, useRef } from "react"; +import { useConvexAuth, useQuery } from "convex/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 => { + 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, + }, + }; +}; diff --git a/packages/backend/convex/auth.ts b/packages/backend/convex/auth.ts index 3fc3c2f..bd2829b 100644 --- a/packages/backend/convex/auth.ts +++ b/packages/backend/convex/auth.ts @@ -36,7 +36,33 @@ const createAuth = (ctx: GenericCtx) => 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 => { + 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, + }; + }, }); diff --git a/packages/backend/convex/authz.ts b/packages/backend/convex/authz.ts index 24c2642..5a28f0a 100644 --- a/packages/backend/convex/authz.ts +++ b/packages/backend/convex/authz.ts @@ -11,7 +11,7 @@ export interface AuthContext { }; } -export const requireOwnerId = async (ctx: AuthContext): Promise => { +export const requireAuthUserId = async (ctx: AuthContext): Promise => { 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 => { - const userId = await requireOwnerId(ctx); + const userId = await requireAuthUserId(ctx); const membership = await ctx.db .query("organizationMembers") .withIndex("by_organizationId_and_userId", (q) => diff --git a/packages/backend/convex/organizations.ts b/packages/backend/convex/organizations.ts index f5779c4..ff13d14 100644 --- a/packages/backend/convex/organizations.ts +++ b/packages/backend/convex/organizations.ts @@ -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 => { - 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 => { - const ownerId = await requireOwnerId(ctx); + const ownerId = await requireAuthUserId(ctx); const existing = await ctx.db .query("organizations") .withIndex("by_createdBy_and_kind", (q) => diff --git a/packages/backend/convex/projectArtifacts.ts b/packages/backend/convex/projectArtifacts.ts index 22c865c..e2ddc74 100644 --- a/packages/backend/convex/projectArtifacts.ts +++ b/packages/backend/convex/projectArtifacts.ts @@ -1,12 +1,12 @@ import { v } from "convex/values"; import { query } from "./_generated/server"; -import { requireOwnerId } from "./authz"; +import { requireAuthUserId } from "./authz" export const list = query({ args: { projectId: v.id("projects") }, handler: async (ctx, args) => { - const ownerId = await requireOwnerId(ctx); + const ownerId = await requireAuthUserId(ctx); const project = await ctx.db.get("projects", args.projectId); if (!project || project.ownerId !== ownerId) { return []; diff --git a/packages/backend/convex/projectIssues.ts b/packages/backend/convex/projectIssues.ts index bf32afa..f779191 100644 --- a/packages/backend/convex/projectIssues.ts +++ b/packages/backend/convex/projectIssues.ts @@ -3,7 +3,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"; +import { requireAuthUserId } from "./authz" const requireProjectOwner = async ( ctx: MutationCtx, @@ -24,7 +24,7 @@ export const create = mutation({ title: v.string(), }, handler: async (ctx, args) => { - const ownerId = await requireOwnerId(ctx); + const ownerId = await requireAuthUserId(ctx); await requireProjectOwner(ctx, args.projectId, ownerId); const title = args.title.trim(); const body = args.body.trim(); @@ -81,7 +81,7 @@ export const create = mutation({ export const begin = mutation({ args: { issueId: v.id("projectIssues") }, handler: async (ctx, args) => { - const ownerId = await requireOwnerId(ctx); + const ownerId = await requireAuthUserId(ctx); const issue = await ctx.db.get("projectIssues", args.issueId); if (!issue) { throw new ConvexError("Issue not found"); @@ -109,7 +109,7 @@ 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 ownerId = await requireAuthUserId(ctx); const issue = await ctx.db.get("projectIssues", args.issueId); if (!issue) { throw new ConvexError("Issue not found"); @@ -133,7 +133,7 @@ export const markDispatchFailed = mutation({ export const list = query({ args: { projectId: v.id("projects") }, handler: async (ctx, args) => { - const ownerId = await requireOwnerId(ctx); + const ownerId = await requireAuthUserId(ctx); const project = await ctx.db.get("projects", args.projectId); if (!project || project.ownerId !== ownerId) { return []; diff --git a/packages/backend/convex/projects.ts b/packages/backend/convex/projects.ts index 5dbb273..573eec4 100644 --- a/packages/backend/convex/projects.ts +++ b/packages/backend/convex/projects.ts @@ -5,7 +5,7 @@ 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 { requireAuthUserId } from "./authz" const gitHubRepositorySchema = z.object({ default_branch: z.string(), @@ -47,7 +47,7 @@ const readGitHubRepository = (value: unknown) => { export const connectGitHub = action({ args: { repository: v.string() }, handler: async (ctx, args): Promise> => { - const ownerId = await requireOwnerId(ctx); + const ownerId = await requireAuthUserId(ctx); const { owner, repo } = parseRepository(args.repository); const response = await fetch( `https://api.github.com/repos/${owner}/${repo}`, @@ -157,7 +157,7 @@ export const storeGitHubProject = internalMutation({ export const list = query({ args: {}, handler: async (ctx) => { - const ownerId = await requireOwnerId(ctx); + const ownerId = await requireAuthUserId(ctx); return await ctx.db .query("projects") .withIndex("by_owner_and_createdAt", (q) => q.eq("ownerId", ownerId)) @@ -169,7 +169,7 @@ export const list = query({ export const get = query({ args: { projectId: v.id("projects") }, handler: async (ctx, args) => { - const ownerId = await requireOwnerId(ctx); + const ownerId = await requireAuthUserId(ctx); const project = await ctx.db.get("projects", args.projectId); return project?.ownerId === ownerId ? project : null; }, diff --git a/packages/primitives/package.json b/packages/primitives/package.json index 0781e87..7b3f661 100644 --- a/packages/primitives/package.json +++ b/packages/primitives/package.json @@ -6,6 +6,7 @@ "exports": { ".": "./src/index.ts", "./agent-os": "./src/agent-os.ts", + "./project": "./src/project.ts", "./signal": "./src/signal.ts" }, "scripts": { diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index 7228130..d252f41 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -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"; diff --git a/packages/primitives/src/project.test.ts b/packages/primitives/src/project.test.ts new file mode 100644 index 0000000..e9578fb --- /dev/null +++ b/packages/primitives/src/project.test.ts @@ -0,0 +1,857 @@ +import { Effect, Layer } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + CONTEXT_KINDS, + decideContextWrite, + makeInitialContext, + preparePublicGitSource, + ProjectApplication, + ProjectApplicationError, + type ProjectImportOutcome, + ProjectDomain, + type ProjectView, + PublicGit, + PublicGitError, + type PublicGitImportResult, + ProjectStore, + type PublicTextResult, + type ContextDocumentState, + type ContextKind, + type ContextWrite, + type PreparedPublicGitSource, +} from "./project"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const expectError = async ( + effect: Effect.Effect +): Promise => { + try { + await Effect.runPromise(effect as never); + throw new Error("Expected failure but got success"); + } catch (caught) { + if (caught instanceof Error && caught.message === "Expected failure but got success") { + throw caught; + } + return caught as E; + } +}; + +// --------------------------------------------------------------------------- +// In-memory ProjectStore +// --------------------------------------------------------------------------- + +interface InMemoryProject { + readonly id: string; + readonly organizationId: string; + readonly name: string; + readonly source: { + readonly kind: "git"; + readonly url: string; + readonly normalizedUrl: string; + readonly host: string; + readonly repositoryPath: string; + readonly defaultBranch?: string; + }; + documents: Map; + createdAt: number; + updatedAt: number; +} + +const decideWriteSync = ( + existing: ContextDocumentState | undefined, + write: ContextWrite +): { document: ContextDocumentState; changed: boolean } => { + const pathMap: Record = { + agents: "AGENTS.md", + business: "business.md", + design: "design.md", + product: "product.md", + readme: "README.md", + tech: "tech.md", + }; + const origin = + write._tag === "Repository" + ? "repository" + : write._tag === "PublicTextUrl" + ? "public-url" + : write.origin; + const sourceUrl = write._tag === "UserText" ? undefined : write.sourceUrl; + + if ( + existing && + existing.content === write.content && + existing.origin === origin && + existing.sourceUrl === sourceUrl + ) { + return { changed: false, document: existing }; + } + return { + changed: true, + document: { + content: write.content, + kind: write.kind, + origin, + path: pathMap[write.kind], + revision: (existing?.revision ?? 0) + 1, + sourceUrl, + }, + }; +}; + +const createInMemoryStoreLayer = () => { + const projects = new Map(); + const orgByUser = new Map(); + let nextId = 1; + + const toView = (p: InMemoryProject): ProjectView => ({ + contextDocuments: [...p.documents.values()], + createdAt: p.createdAt as never, + id: p.id as never, + name: p.name, + organizationId: p.organizationId as never, + sources: [ + { + createdAt: p.createdAt as never, + defaultBranch: p.source.defaultBranch, + host: p.source.host, + kind: "git", + normalizedUrl: p.source.normalizedUrl, + projectId: p.id as never, + repositoryPath: p.source.repositoryPath, + updatedAt: p.updatedAt as never, + url: p.source.url, + }, + ], + updatedAt: p.updatedAt as never, + }); + + const toOutcome = (p: InMemoryProject): ProjectImportOutcome => ({ + contextDocuments: [...p.documents.values()], + createdAt: p.createdAt as never, + id: p.id as never, + name: p.name, + organizationId: p.organizationId as never, + source: { + createdAt: p.createdAt as never, + defaultBranch: p.source.defaultBranch, + host: p.source.host, + kind: "git", + normalizedUrl: p.source.normalizedUrl, + projectId: p.id as never, + repositoryPath: p.source.repositoryPath, + updatedAt: p.updatedAt as never, + url: p.source.url, + }, + updatedAt: p.updatedAt as never, + }); + + return Layer.succeed( + ProjectStore, + ProjectStore.of({ + getCurrentOrganization: (userId) => + Effect.succeed((orgByUser.get(userId) ?? `org-${userId}`) as never), + listProjects: (userId) => + Effect.succeed( + (() => { + const orgId = orgByUser.get(userId); + return orgId + ? [...projects.values()] + .filter((p) => p.organizationId === orgId) + .map(toView) + : ([] as readonly ProjectView[]); + })() + ), + getProject: (userId, projectId) => + Effect.succeed( + (() => { + const orgId = orgByUser.get(userId); + const project = projects.get(projectId as string); + if (!project || project.organizationId !== orgId) return null; + return toView(project); + })() + ), + persistPublicGitImport: (input) => + Effect.sync(() => { + const orgId = orgByUser.get(input.userId) ?? `org-${input.userId}`; + orgByUser.set(input.userId, orgId); + + const existing = [...projects.values()].find( + (p) => + p.organizationId === orgId && + p.source.normalizedUrl === input.source.normalizedUrl + ); + + const now = Date.now(); + if (existing) { + existing.updatedAt = now; + for (const doc of input.remote.documents) { + const result = decideWriteSync(existing.documents.get(doc.kind), { + _tag: "Repository" as const, + content: doc.content, + kind: doc.kind, + sourceUrl: input.source.url, + }); + if (result.changed) + existing.documents.set(doc.kind, result.document); + } + return toOutcome(existing); + } + + const id = `project-${nextId++}`; + const documents = new Map(); + for (const seed of makeInitialContext( + input.source.projectName, + input.source.url + )) { + documents.set(seed.kind, seed); + } + for (const doc of input.remote.documents) { + const result = decideWriteSync(documents.get(doc.kind), { + _tag: "Repository" as const, + content: doc.content, + kind: doc.kind, + sourceUrl: input.source.url, + }); + if (result.changed) documents.set(doc.kind, result.document); + } + + const project: InMemoryProject = { + createdAt: now, + documents, + id, + name: input.source.projectName, + organizationId: orgId, + source: { + defaultBranch: input.remote.defaultBranch, + host: input.source.host, + kind: "git", + normalizedUrl: input.source.normalizedUrl, + repositoryPath: input.source.repositoryPath, + url: input.source.url, + }, + updatedAt: now, + }; + projects.set(id, project); + return toOutcome(project); + }), + putContext: (input) => + Effect.sync(() => { + const orgId = orgByUser.get(input.userId); + const project = projects.get(input.projectId as string); + if (!project || project.organizationId !== orgId) { + throw new Error("Project not found"); + } + const result = decideWriteSync( + project.documents.get(input.write.kind), + input.write + ); + if (result.changed) { + project.documents.set(input.write.kind, result.document); + project.updatedAt = Date.now(); + } + return { revision: result.document.revision }; + }), + }) + ); +}; + +// --------------------------------------------------------------------------- +// Fake PublicGit layer +// --------------------------------------------------------------------------- + +const makeFakePublicGitLayer = ( + inspectImpl: ( + source: PreparedPublicGitSource + ) => PublicGitImportResult | PublicGitError, + fetchImpl: (url: string) => PublicTextResult | PublicGitError +) => + Layer.succeed( + PublicGit, + PublicGit.of({ + inspect: (source) => + Effect.gen(function* () { + const result = inspectImpl(source); + if (result instanceof PublicGitError) { + return yield* Effect.fail(result); + } + return result; + }), + fetchText: (url) => + Effect.gen(function* () { + const result = fetchImpl(url); + if (result instanceof PublicGitError) { + return yield* Effect.fail(result); + } + return result; + }), + }) + ); + +const HELLO_WORLD_RESULT: PublicGitImportResult = { + defaultBranch: "main", + documents: [ + { + kind: "readme", + path: "README.md", + content: "# Hello World\n\nA test repository.\n", + }, + ], + warnings: [], +}; + +// --------------------------------------------------------------------------- +// Test layer +// --------------------------------------------------------------------------- + +const makeTestLayer = ( + fakePublicGit: ReturnType +) => + ProjectApplication.layer.pipe( + Layer.provide( + Layer.mergeAll(ProjectDomain.layer, fakePublicGit, createInMemoryStoreLayer()) + ) + ); + +const runApp = ( + effect: Effect.Effect, + layer: Layer.Layer +): Promise => Effect.runPromise(Effect.provide(effect, layer)); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("preparePublicGitSource", () => { + it("normalizes a GitHub URL with .git suffix", async () => { + const result = await Effect.runPromise( + preparePublicGitSource("https://github.com/octocat/Hello-World.git") + ); + expect(result.host).toBe("github.com"); + expect(result.repositoryPath).toBe("octocat/Hello-World"); + expect(result.projectName).toBe("Hello-World"); + expect(result.normalizedUrl).toBe("https://github.com/octocat/Hello-World"); + }); + + it("normalizes a GitLab URL", async () => { + const result = await Effect.runPromise( + preparePublicGitSource("https://gitlab.com/gitlab-org/gitlab-test.git") + ); + expect(result.host).toBe("gitlab.com"); + expect(result.repositoryPath).toBe("gitlab-org/gitlab-test"); + }); + + it("normalizes a Codeberg URL", async () => { + const result = await Effect.runPromise( + preparePublicGitSource("https://codeberg.org/Codeberg/Documentation.git") + ); + expect(result.host).toBe("codeberg.org"); + expect(result.repositoryPath).toBe("Codeberg/Documentation"); + }); + + it("normalizes a generic HTTP(S) host without .git", async () => { + const result = await Effect.runPromise( + preparePublicGitSource("https://example.com/foo/bar") + ); + expect(result.repositoryPath).toBe("foo/bar"); + expect(result.projectName).toBe("bar"); + }); + + it("rejects SSH URLs", async () => { + const error = await expectError( + preparePublicGitSource("git@github.com:octocat/Hello-World.git") + ); + expect(error).toBeInstanceOf(ProjectApplicationError); + expect((error as ProjectApplicationError).reason).toBe( + "InvalidPublicGitUrl" + ); + }); + + it("rejects URLs with credentials", async () => { + const error = await expectError( + preparePublicGitSource( + "https://user:pass@github.com/octocat/Hello-World.git" + ) + ); + expect((error as ProjectApplicationError).reason).toBe( + "InvalidPublicGitUrl" + ); + }); + + it("rejects URLs with query strings", async () => { + const error = await expectError( + preparePublicGitSource( + "https://github.com/octocat/Hello-World.git?branch=dev" + ) + ); + expect((error as ProjectApplicationError).reason).toBe( + "InvalidPublicGitUrl" + ); + }); + + it("rejects URLs with fragments", async () => { + const error = await expectError( + preparePublicGitSource("https://github.com/octocat/Hello-World.git#readme") + ); + expect((error as ProjectApplicationError).reason).toBe( + "InvalidPublicGitUrl" + ); + }); + + it("rejects root-only paths", async () => { + const error = await expectError( + preparePublicGitSource("https://github.com/") + ); + expect((error as ProjectApplicationError).reason).toBe( + "InvalidPublicGitUrl" + ); + }); +}); + +describe("makeInitialContext", () => { + it("produces exactly six canonical seeds", () => { + const seeds = makeInitialContext("test-repo", "https://example.com/foo/bar"); + expect(seeds).toHaveLength(6); + const kinds = seeds.map((s) => s.kind).sort(); + expect(kinds).toEqual([...CONTEXT_KINDS].sort()); + }); + + it("seeds README with project name and repository URL", () => { + const seeds = makeInitialContext("my-project", "https://example.com/foo/bar"); + const readme = seeds.find((s) => s.kind === "readme")!; + expect(readme.content).toBe( + "# my-project\n\nRepository: https://example.com/foo/bar\n" + ); + }); + + it("seeds placeholders with exact title and scaffold text", () => { + const seeds = makeInitialContext("test", "https://example.com/foo/bar"); + const product = seeds.find((s) => s.kind === "product")!; + expect(product.content).toBe("# product.md\n\nContext not imported yet.\n"); + }); + + it("all seeds start at revision 1", () => { + const seeds = makeInitialContext("test", "https://example.com/foo/bar"); + for (const seed of seeds) { + expect(seed.revision).toBe(1); + } + }); +}); + +describe("decideContextWrite", () => { + it("increments revision on content change", async () => { + const existing: ContextDocumentState = { + content: "old", + kind: "product", + origin: "paste", + path: "product.md", + revision: 1, + sourceUrl: undefined, + }; + const result = await Effect.runPromise( + decideContextWrite({ + existing, + write: { + _tag: "UserText", + content: "new content", + kind: "product", + origin: "paste", + }, + }) + ); + expect(result.changed).toBe(true); + expect(result.document.revision).toBe(2); + expect(result.document.content).toBe("new content"); + }); + + it("returns no-op when content/origin/source are identical", async () => { + const existing: ContextDocumentState = { + content: "same", + kind: "product", + origin: "paste", + path: "product.md", + revision: 5, + sourceUrl: undefined, + }; + const result = await Effect.runPromise( + decideContextWrite({ + existing, + write: { + _tag: "UserText", + content: "same", + kind: "product", + origin: "paste", + }, + }) + ); + expect(result.changed).toBe(false); + expect(result.document).toBe(existing); + }); + + it("preserves exact text without trimming", async () => { + const result = await Effect.runPromise( + decideContextWrite({ + existing: undefined, + write: { + _tag: "UserText", + content: " trailing spaces \n\n", + kind: "tech", + origin: "paste", + }, + }) + ); + expect(result.document.content).toBe(" trailing spaces \n\n"); + }); +}); + +describe("ProjectApplication.importPublicGit", () => { + it("imports a repository with six canonical context documents", async () => { + const layer = makeTestLayer( + makeFakePublicGitLayer( + () => HELLO_WORLD_RESULT, + () => ({ content: "", finalUrl: "" }) + ) + ); + const outcome = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicGit({ + userId: "user-a", + repositoryUrl: "https://github.com/octocat/Hello-World.git", + }); + }), + layer + ); + expect(outcome.name).toBe("Hello-World"); + expect(outcome.source.host).toBe("github.com"); + expect(outcome.contextDocuments).toHaveLength(6); + const readme = outcome.contextDocuments.find((d) => d.kind === "readme")!; + expect(readme.content).toBe("# Hello World\n\nA test repository.\n"); + }); + + it("is idempotent on re-import without .git suffix", async () => { + const layer = makeTestLayer( + makeFakePublicGitLayer( + () => HELLO_WORLD_RESULT, + () => ({ content: "", finalUrl: "" }) + ) + ); + const first = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicGit({ + userId: "user-a", + repositoryUrl: "https://github.com/octocat/Hello-World.git", + }); + }), + layer + ); + const second = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicGit({ + userId: "user-a", + repositoryUrl: "https://github.com/octocat/Hello-World", + }); + }), + layer + ); + expect(second.id).toBe(first.id); + }); + + it("rejects an invalid URL", async () => { + const layer = makeTestLayer( + makeFakePublicGitLayer( + () => HELLO_WORLD_RESULT, + () => ({ content: "", finalUrl: "" }) + ) + ); + const error = await expectError( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicGit({ + userId: "user-a", + repositoryUrl: "not-a-url", + }); + }).pipe(Effect.provide(layer)) + ); + expect((error as ProjectApplicationError).reason).toBe( + "InvalidPublicGitUrl" + ); + }); + + it("maps PublicGit unreachable error to PublicGitUnreachable", async () => { + const layer = makeTestLayer( + makeFakePublicGitLayer( + () => + new PublicGitError({ + message: "Connection refused", + reason: "Unreachable", + }), + () => ({ content: "", finalUrl: "" }) + ) + ); + const error = await expectError( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicGit({ + userId: "user-a", + repositoryUrl: "https://github.com/octocat/Hello-World.git", + }); + }).pipe(Effect.provide(layer)) + ); + expect((error as ProjectApplicationError).reason).toBe( + "PublicGitUnreachable" + ); + }); + + it("maps PublicGit cancelled error to PublicGitUnreachable", async () => { + const layer = makeTestLayer( + makeFakePublicGitLayer( + () => + new PublicGitError({ + message: "Timed out", + reason: "Cancelled", + }), + () => ({ content: "", finalUrl: "" }) + ) + ); + const error = await expectError( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicGit({ + userId: "user-a", + repositoryUrl: "https://github.com/octocat/Hello-World.git", + }); + }).pipe(Effect.provide(layer)) + ); + expect((error as ProjectApplicationError).reason).toBe( + "PublicGitUnreachable" + ); + }); +}); + +describe("ProjectApplication.putContext", () => { + const setupWithProject = async () => { + const layer = makeTestLayer( + makeFakePublicGitLayer( + () => HELLO_WORLD_RESULT, + () => ({ content: "", finalUrl: "" }) + ) + ); + const outcome = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicGit({ + userId: "user-a", + repositoryUrl: "https://github.com/octocat/Hello-World.git", + }); + }), + layer + ); + return { layer, projectId: outcome.id }; + }; + + it("writes pasted context and increments revision", async () => { + const { layer, projectId } = await setupWithProject(); + const result = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.putContext({ + userId: "user-a", + projectId, + kind: "design", + content: "# Design\n\nNew design notes.\n", + origin: "paste", + }); + }), + layer + ); + // Seeds start at revision 1; first explicit write bumps to 2 + expect(result.revision).toBe(2); + }); + + it("returns the same revision for identical content (no-op)", async () => { + const { layer, projectId } = await setupWithProject(); + await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.putContext({ + userId: "user-a", + projectId, + kind: "design", + content: "same content", + origin: "paste", + }); + }), + layer + ); + const result = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.putContext({ + userId: "user-a", + projectId, + kind: "design", + content: "same content", + origin: "paste", + }); + }), + layer + ); + expect(result.revision).toBe(2); + }); + + it("last explicit write wins for the same kind", async () => { + const { layer, projectId } = await setupWithProject(); + await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.putContext({ + userId: "user-a", + projectId, + kind: "product", + content: "first paste", + origin: "paste", + }); + }), + layer + ); + const result = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.putContext({ + userId: "user-a", + projectId, + kind: "product", + content: "second upload", + origin: "upload", + }); + }), + layer + ); + expect(result.revision).toBe(3); + }); +}); + +describe("ProjectApplication.importPublicText", () => { + it("imports text from a URL and increments revision", async () => { + const layer = makeTestLayer( + makeFakePublicGitLayer( + () => HELLO_WORLD_RESULT, + (url) => ({ + content: "# Tech via URL\n\nImported.\n", + finalUrl: url, + }) + ) + ); + const outcome = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicGit({ + userId: "user-a", + repositoryUrl: "https://github.com/octocat/Hello-World.git", + }); + }), + layer + ); + const result = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicText({ + userId: "user-a", + projectId: outcome.id, + kind: "tech", + url: "https://raw.githubusercontent.com/foo/bar/main/tech.md", + }); + }), + layer + ); + expect(result.revision).toBe(2); + }); + + it("rejects empty URL", async () => { + const layer = makeTestLayer( + makeFakePublicGitLayer( + () => HELLO_WORLD_RESULT, + () => ({ content: "text", finalUrl: "url" }) + ) + ); + const error = await expectError( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicText({ + userId: "user-a", + projectId: "project-1" as never, + kind: "tech", + url: " ", + }); + }).pipe(Effect.provide(layer)) + ); + expect((error as ProjectApplicationError).reason).toBe( + "InvalidContextInput" + ); + }); +}); + +describe("ProjectApplication tenant isolation", () => { + it("a second tenant cannot list the first tenant's projects", async () => { + const layer = makeTestLayer( + makeFakePublicGitLayer( + () => HELLO_WORLD_RESULT, + () => ({ content: "", finalUrl: "" }) + ) + ); + await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicGit({ + userId: "user-a", + repositoryUrl: "https://github.com/octocat/Hello-World.git", + }); + }), + layer + ); + const secondProjects = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.listProjects({ userId: "user-b" }); + }), + layer + ); + expect(secondProjects).toHaveLength(0); + }); + + it("a second tenant cannot read the first tenant's project", async () => { + const layer = makeTestLayer( + makeFakePublicGitLayer( + () => HELLO_WORLD_RESULT, + () => ({ content: "", finalUrl: "" }) + ) + ); + const outcome = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.importPublicGit({ + userId: "user-a", + repositoryUrl: "https://github.com/octocat/Hello-World.git", + }); + }), + layer + ); + const result = await runApp( + Effect.gen(function* () { + const a = yield* ProjectApplication; + return yield* a.getProject({ + userId: "user-b", + projectId: outcome.id, + }); + }), + layer + ); + expect(result).toBeNull(); + }); +}); diff --git a/packages/primitives/src/project.ts b/packages/primitives/src/project.ts new file mode 100644 index 0000000..c38a9ba --- /dev/null +++ b/packages/primitives/src/project.ts @@ -0,0 +1,754 @@ +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> = { + agents: "AGENTS.md", + business: "business.md", + design: "design.md", + product: "product.md", + readme: "README.md", + tech: "tech.md", +}; + +const PATH_TO_CONTEXT_KIND: Readonly> = + 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, + projectName: MeaningfulString, + repositoryPath: MeaningfulString, + normalizedUrl: Schema.String, + url: Schema.String, +}); +export type PreparedPublicGitSource = typeof PreparedPublicGitSource.Type; + +export const RepositoryContextDocument = Schema.Struct({ + kind: ContextKindSchema, + path: Schema.String, + content: 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", + { + 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", + { + 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", + { + 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 => + Effect.gen(function* () { + 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); + 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 => + Effect.gen(function* () { + const result = yield* Schema.decodeUnknownEffect(PublicGitImportResult)( + raw + ).pipe(Effect.mapError(() => INVALID_REMOTE)); + + const seenKinds = new Set(); + const seenPaths = new Set(); + 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 => + Effect.gen(function* () { + 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"), + ]; +}; + +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 => + Effect.gen(function* () { + const write = input.write; + const content = write.content; + const kind = write.kind; + 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 = + write._tag === "Repository" + ? "repository" + : write._tag === "PublicTextUrl" + ? "public-url" + : write.origin; + const sourceUrl: string | undefined = + write._tag === "UserText" ? undefined : write.sourceUrl; + + const existing = input.existing; + 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({ + preparePublicGitSource, + decodePublicGitImportResult, + decodePublicTextResult, + makeInitialContext, + decideContextWrite, + }) + ); +} + +// --------------------------------------------------------------------------- +// PublicGit port +// --------------------------------------------------------------------------- + +interface PublicGitShape { + readonly inspect: ( + source: PreparedPublicGitSource + ) => Effect.Effect; + readonly fetchText: ( + url: string + ) => Effect.Effect; +} + +export class PublicGit extends Context.Service()( + "@code/primitives/project/PublicGit" +) {} + +// --------------------------------------------------------------------------- +// ProjectStore port +// --------------------------------------------------------------------------- + +interface ProjectStoreShape { + readonly getCurrentOrganization: ( + userId: string + ) => Effect.Effect; + readonly listProjects: ( + userId: string + ) => Effect.Effect; + readonly getProject: ( + userId: string, + projectId: ProjectId + ) => Effect.Effect; + readonly persistPublicGitImport: (input: { + readonly userId: string; + readonly source: PreparedPublicGitSource; + readonly remote: PublicGitImportResult; + }) => Effect.Effect; + 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; + 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 getProject: (input: { + readonly userId: string; + readonly projectId: ProjectId; + }) => Effect.Effect; +} + +export class ProjectApplication extends Context.Service< + ProjectApplication, + ProjectApplicationShape +>()("@code/primitives/project/ProjectApplication") { + static readonly layer = Layer.effect( + ProjectApplication, + Effect.gen(function* () { + const domain = yield* ProjectDomain; + const publicGit = yield* PublicGit; + const store = yield* ProjectStore; + + return ProjectApplication.of({ + importPublicGit: Effect.fn("ProjectApplication.importPublicGit")( + function* ({ + 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({ userId, source, remote }) + .pipe(Effect.mapError(mapStoreError)); + } + ), + importPublicText: Effect.fn("ProjectApplication.importPublicText")( + function* ({ + 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({ userId, projectId, write }) + .pipe(Effect.mapError(mapStoreError)); + } + ), + putContext: Effect.fn("ProjectApplication.putContext")( + function* ({ + 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({ userId, projectId, write }) + .pipe(Effect.mapError(mapStoreError)); + } + ), + listProjects: Effect.fn("ProjectApplication.listProjects")( + function* ({ userId }: { readonly userId: string }) { + return yield* store + .listProjects(userId) + .pipe(Effect.mapError(mapStoreError)); + } + ), + getProject: Effect.fn("ProjectApplication.getProject")( + function* ({ + userId, + projectId, + }: { + readonly userId: string; + readonly projectId: ProjectId; + }) { + return yield* store + .getProject(userId, projectId) + .pipe(Effect.mapError(mapStoreError)); + } + ), + }); + }) + ); +} -- 2.49.1 From 224e98d0bc1c7593ea89a69097d7d50dd58954e2 Mon Sep 17 00:00:00 2001 From: -Puter <22245429+puterhimself@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:16:41 +0530 Subject: [PATCH 2/7] 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. --- packages/backend/convex/artifactModel.ts | 119 ++--- packages/backend/convex/authz.ts | 38 ++ packages/backend/convex/projectArtifacts.ts | 8 +- packages/backend/convex/projectEvents.test.ts | 75 ++-- packages/backend/convex/projectIssues.ts | 30 +- packages/backend/convex/projectStore.ts | 421 ++++++++++++++++++ packages/backend/convex/projects.ts | 357 ++++++++------- packages/backend/convex/schema.ts | 61 ++- packages/backend/convex/signals.test.ts | 43 +- packages/backend/convex/signals.ts | 18 +- packages/backend/package.json | 1 + 11 files changed, 839 insertions(+), 332 deletions(-) create mode 100644 packages/backend/convex/projectStore.ts diff --git a/packages/backend/convex/artifactModel.ts b/packages/backend/convex/artifactModel.ts index 7109d63..334da52 100644 --- a/packages/backend/convex/artifactModel.ts +++ b/packages/backend/convex/artifactModel.ts @@ -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", + }, +]; diff --git a/packages/backend/convex/authz.ts b/packages/backend/convex/authz.ts index 5a28f0a..75b2cef 100644 --- a/packages/backend/convex/authz.ts +++ b/packages/backend/convex/authz.ts @@ -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 }; +}; diff --git a/packages/backend/convex/projectArtifacts.ts b/packages/backend/convex/projectArtifacts.ts index e2ddc74..5f5a2b6 100644 --- a/packages/backend/convex/projectArtifacts.ts +++ b/packages/backend/convex/projectArtifacts.ts @@ -1,14 +1,14 @@ import { v } from "convex/values"; import { query } from "./_generated/server"; -import { requireAuthUserId } from "./authz" +import { requireProjectMember } from "./authz"; export const list = query({ args: { projectId: v.id("projects") }, handler: async (ctx, args) => { - const ownerId = await requireAuthUserId(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 diff --git a/packages/backend/convex/projectEvents.test.ts b/packages/backend/convex/projectEvents.test.ts index e608961..f1f179c 100644 --- a/packages/backend/convex/projectEvents.test.ts +++ b/packages/backend/convex/projectEvents.test.ts @@ -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 Promise>; @@ -19,26 +16,45 @@ 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): Promise> => { + // 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, projectId: Id<"projects"> @@ -56,10 +72,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 +80,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 +125,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 +152,7 @@ describe("projectEvents", () => { test("agent status emits an agent. 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 +192,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, { diff --git a/packages/backend/convex/projectIssues.ts b/packages/backend/convex/projectIssues.ts index f779191..ee99444 100644 --- a/packages/backend/convex/projectIssues.ts +++ b/packages/backend/convex/projectIssues.ts @@ -2,20 +2,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 { 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; -}; +import { requireProjectMember } from "./authz"; export const create = mutation({ args: { @@ -24,8 +11,7 @@ export const create = mutation({ title: v.string(), }, handler: async (ctx, args) => { - const ownerId = await requireAuthUserId(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 +67,11 @@ export const create = mutation({ export const begin = mutation({ args: { issueId: v.id("projectIssues") }, handler: async (ctx, args) => { - const ownerId = await requireAuthUserId(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 +94,11 @@ export const begin = mutation({ export const markDispatchFailed = mutation({ args: { error: v.string(), issueId: v.id("projectIssues") }, handler: async (ctx, args) => { - const ownerId = await requireAuthUserId(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 +117,9 @@ export const markDispatchFailed = mutation({ export const list = query({ args: { projectId: v.id("projects") }, handler: async (ctx, args) => { - const ownerId = await requireAuthUserId(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 diff --git a/packages/backend/convex/projectStore.ts b/packages/backend/convex/projectStore.ts new file mode 100644 index 0000000..54c9ffe --- /dev/null +++ b/packages/backend/convex/projectStore.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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, +}; diff --git a/packages/backend/convex/projects.ts b/packages/backend/convex/projects.ts index 573eec4..4955476 100644 --- a/packages/backend/convex/projects.ts +++ b/packages/backend/convex/projects.ts @@ -1,176 +1,231 @@ -import { ConvexError, v } from "convex/values"; -import { z } from "zod"; +import { v } from "convex/values"; + +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 { internal } from "./_generated/api"; import { action, internalMutation, query } from "./_generated/server"; +import { requireCurrentOrganization } from "./authz"; import { createInitialArtifacts } from "./artifactModel"; -import { requireAuthUserId } 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\/)?(?[^/\s]+)\/(?[^/\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> => { - 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({ +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) - ) - .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, + handler: async (ctx, args): Promise => { + 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">)) + .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 requireAuthUserId(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 => { + 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 requireAuthUserId(ctx); - const project = await ctx.db.get("projects", args.projectId); - return project?.ownerId === ownerId ? project : null; + handler: async (ctx, args): Promise => { + 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 => { + // 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, + }); }, }); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 2fd482d..b39c1e1 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -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"]), // ----------------------------------------------------------------- diff --git a/packages/backend/convex/signals.test.ts b/packages/backend/convex/signals.test.ts index 5e86781..d3591c9 100644 --- a/packages/backend/convex/signals.test.ts +++ b/packages/backend/convex/signals.test.ts @@ -44,13 +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 ( @@ -79,13 +72,37 @@ const seedMessage = async ( const createProject = async ( t: TestConvex, - ownerId: string, - repoId = 12345 + ownerId: string ): Promise> => { - 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", () => { diff --git a/packages/backend/convex/signals.ts b/packages/backend/convex/signals.ts index 7391f81..fd21784 100644 --- a/packages/backend/convex/signals.ts +++ b/packages/backend/convex/signals.ts @@ -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"); } }; diff --git a/packages/backend/package.json b/packages/backend/package.json index 4e75c83..4f0ee01 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -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" }, -- 2.49.1 From e44759d7fef7be250597f1d2e639da0e62c829de Mon Sep 17 00:00:00 2001 From: -Puter <22245429+puterhimself@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:31:48 +0530 Subject: [PATCH 3/7] feat: generic project UI, branding fix, context/artifact separation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web: - Replace GitHub-specific import with generic public Git URL import - Update project workspace page to use ProjectView shape (name, sources) - Migrate use-project-workspace hook to importPublicGit action - Fix status key needsInput → needs-input Branding: - Correct Zopo/zopo → Zopu/zopu in docs/PRODUCT.md, docs/DESIGN.md, docs/TECH.md Artifacts: - Cut to 8 operational artifacts (removed project/business/design.md) - Update artifact seeds with generic context scaffolding --- .../projects/project-workspace-page.tsx | 286 +++++++----------- apps/web/src/hooks/use-project-workspace.ts | 12 +- docs/DESIGN.md | 6 +- docs/PRODUCT.md | 10 +- docs/TECH.md | 4 +- packages/backend/convex/projectIssues.ts | 1 - packages/backend/convex/projectStore.ts | 2 +- packages/backend/convex/projects.ts | 3 - 8 files changed, 133 insertions(+), 191 deletions(-) diff --git a/apps/web/src/components/projects/project-workspace-page.tsx b/apps/web/src/components/projects/project-workspace-page.tsx index 8aa7ed7..d639736 100644 --- a/apps/web/src/components/projects/project-workspace-page.tsx +++ b/apps/web/src/components/projects/project-workspace-page.tsx @@ -18,19 +18,20 @@ import { useProjectWorkspace } from "@/hooks/use-project-workspace"; const statusStyle: Record["status"], string> = { completed: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", - failed: "border-destructive/40 bg-destructive/10 text-destructive", + failed: + "border-destructive/40 bg-destructive/10 text-destructive", "needs-input": - "border-orange-500/40 bg-orange-500/10 text-orange-700 dark:text-orange-300", - open: "border-border text-muted-foreground", - queued: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300", - working: "border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-300", + open: "border-border bg-muted/40 text-muted-foreground", + queued: "border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-300", + working: + "border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-300", }; interface RepositoryFormProps { readonly busy: boolean; readonly onChange: (value: string) => void; - readonly onSubmit: () => Promise; + readonly onSubmit: () => void; readonly value: string; } @@ -41,36 +42,44 @@ const RepositoryForm = ({ value, }: RepositoryFormProps) => (
{ event.preventDefault(); - void onSubmit(); + onSubmit(); }} >
-
onChange(event.target.value)} - placeholder="rivet-dev/rivet" + placeholder="https://github.com/owner/repo" required value={value} /> -
); interface ProjectListProps { - readonly projects: readonly Doc<"projects">[] | undefined; + readonly projects: readonly { + readonly id: string; + readonly name: string; + readonly sources: readonly { + readonly host: string; + readonly repositoryPath: string; + readonly url: string; + }[]; + }[] | undefined; readonly selectedId: Id<"projects"> | null; readonly onSelect: (projectId: Id<"projects">) => void; } @@ -85,7 +94,7 @@ const ProjectList = ({ projects, selectedId, onSelect }: ProjectListProps) => { if (projects.length === 0) { return (

- Connect a repository to create its project artifacts and issue queue. + Import a repository to create its project artifacts and issue queue.

); } @@ -94,19 +103,20 @@ const ProjectList = ({ projects, selectedId, onSelect }: ProjectListProps) => { {projects.map((project) => ( ))} @@ -130,35 +140,26 @@ interface ArtifactGridProps { const ArtifactGrid = ({ artifacts }: ArtifactGridProps) => (
-
-

- Project artifacts -

-

- Canonical context staged into every issue workspace. -

-
+

+ Project artifacts +

{artifacts === undefined ? ( -

Loading artifacts...

+

Loading artifacts...

+ ) : artifacts.length === 0 ? ( +

No artifacts yet.

) : ( -
+
{artifacts.map((artifact) => ( -
-
-
- -

- {artifact.path} -

-
- - r{artifact.revision} - -
-

- {artifact.content} +

+ +

{artifact.path}

+

+ rev {artifact.revision}

-
+
))}
)} @@ -169,7 +170,7 @@ interface IssueComposerProps { readonly body: string; readonly busy: boolean; readonly onBodyChange: (value: string) => void; - readonly onSubmit: () => Promise; + readonly onSubmit: () => void; readonly onTitleChange: (value: string) => void; readonly title: string; } @@ -183,47 +184,27 @@ const IssueComposer = ({ title, }: IssueComposerProps) => (
{ event.preventDefault(); - void onSubmit(); + onSubmit(); }} > -
-

Raise an issue

-

- The issue becomes the identity for one Flue agent and AgentOS workspace. -

-
-
- - onTitleChange(event.target.value)} - required - value={title} - /> -
-
- -