diff --git a/apps/web/src/components/projects/project-workspace-page.tsx b/apps/web/src/components/projects/project-workspace-page.tsx index d639736..b171063 100644 --- a/apps/web/src/components/projects/project-workspace-page.tsx +++ b/apps/web/src/components/projects/project-workspace-page.tsx @@ -7,7 +7,6 @@ import { ExternalLink, FileText, GitFork, - GitPullRequest, Play, Plus, } from "lucide-react"; @@ -18,14 +17,12 @@ 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-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-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", + working: "border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-300", }; interface RepositoryFormProps { @@ -71,15 +68,17 @@ const RepositoryForm = ({ ); interface ProjectListProps { - readonly projects: readonly { - readonly id: string; - readonly name: string; - readonly sources: readonly { - readonly host: string; - readonly repositoryPath: string; - readonly url: string; - }[]; - }[] | 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; } @@ -138,31 +137,34 @@ interface ArtifactGridProps { readonly artifacts: readonly Doc<"projectArtifacts">[] | undefined; } +const renderArtifacts = (artifacts: ArtifactGridProps["artifacts"]) => { + if (artifacts === undefined) { + return

Loading artifacts...

; + } + if (artifacts.length === 0) { + return

No artifacts yet.

; + } + return ( +
+ {artifacts.map((artifact) => ( +
+ +

{artifact.path}

+

+ rev {artifact.revision} +

+
+ ))} +
+ ); +}; + const ArtifactGrid = ({ artifacts }: ArtifactGridProps) => (

Project artifacts

- {artifacts === undefined ? ( -

Loading artifacts...

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

No artifacts yet.

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

{artifact.path}

-

- rev {artifact.revision} -

-
- ))} -
- )} + {renderArtifacts(artifacts)}
); @@ -223,9 +225,7 @@ interface IssueListProps { const IssueList = ({ issues, pendingAction, onStart }: IssueListProps) => { if (issues === undefined) { - return ( -

Loading issues...

- ); + return

Loading issues...

; } if (issues.length === 0) { return ( diff --git a/apps/web/src/components/user-menu.tsx b/apps/web/src/components/user-menu.tsx index e6281f5..ee3e595 100644 --- a/apps/web/src/components/user-menu.tsx +++ b/apps/web/src/components/user-menu.tsx @@ -29,10 +29,9 @@ export default function UserMenu() { {user?.email} { - void signOutWeb().then(() => { - navigate("/login", { replace: true }); - }); + onClick={async () => { + await signOutWeb(); + navigate("/login", { replace: true }); }} > Sign Out diff --git a/apps/web/src/hooks/use-personal-organization.test.ts b/apps/web/src/hooks/use-personal-organization.test.ts deleted file mode 100644 index a601bf4..0000000 --- a/apps/web/src/hooks/use-personal-organization.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { EffectCallback } from "react"; -import { beforeEach, describe, expect, test, vi } from "vitest"; - -import { usePersonalOrganization } from "./use-personal-organization"; - -const mocks = vi.hoisted(() => ({ - cleanup: undefined as (() => void) | undefined, - ensurePersonalOrganization: vi.fn(), - 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", () => ({ - useWebAuth: () => mocks.authState, -})); -vi.mock("@code/backend/convex/_generated/api", () => ({ - api: { organizations: { ensurePersonalOrganization: "ensure-org" } }, -})); -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: (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", () => { - beforeEach(() => { - mocks.cleanup?.(); - mocks.cleanup = undefined; - mocks.ensurePersonalOrganization.mockReset(); - 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); - - usePersonalOrganization(); - expect(mocks.ensurePersonalOrganization).toHaveBeenCalledTimes(1); - - ensured.resolve({ _id: "org-a" }); - await ensured.promise; - await Promise.resolve(); - - expect(stateCells.orgId).toBe("org-a"); - }); - - test("an unauthenticated session never ensures an org", () => { - mocks.authState = { status: "unauthenticated" }; - - 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 b25fd1d..5b1feae 100644 --- a/apps/web/src/hooks/use-personal-organization.ts +++ b/apps/web/src/hooks/use-personal-organization.ts @@ -23,8 +23,6 @@ export const usePersonalOrganization = (): PersonalOrganizationState => { useEffect(() => { if (!userId) { - setOrganizationId(undefined); - setError(undefined); return; } diff --git a/apps/web/src/hooks/use-project-workspace.ts b/apps/web/src/hooks/use-project-workspace.ts index 2ee0cde..1bce781 100644 --- a/apps/web/src/hooks/use-project-workspace.ts +++ b/apps/web/src/hooks/use-project-workspace.ts @@ -22,7 +22,9 @@ export const useProjectWorkspace = () => { const beginIssue = useMutation(api.projectIssues.begin); const markDispatchFailed = useMutation(api.projectIssues.markDispatchFailed); const activeProjectId = - (selectedProjectId ?? (projects?.[0]?.id as unknown as Id<"projects">)) ?? null; + selectedProjectId ?? + (projects?.[0]?.id as unknown as Id<"projects">) ?? + null; const artifacts = useQuery( api.projectArtifacts.list, activeProjectId ? { projectId: activeProjectId } : "skip" @@ -89,7 +91,9 @@ export const useProjectWorkspace = () => { } }; const selectedProject = - projects?.find((project) => project.id === (activeProjectId as unknown as string)) ?? null; + projects?.find( + (project) => project.id === (activeProjectId as unknown as string) + ) ?? null; return { artifacts, diff --git a/apps/web/src/routes/_app.tsx b/apps/web/src/routes/_app.tsx index 495893d..83e3a33 100644 --- a/apps/web/src/routes/_app.tsx +++ b/apps/web/src/routes/_app.tsx @@ -6,7 +6,11 @@ export default function AppLayout() { const location = useLocation(); if (auth.status === "loading") { - return
Loading…
; + return ( +
+ Loading… +
+ ); } if (auth.status === "inconsistent") { diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx index a3b472b..8498887 100644 --- a/apps/web/src/routes/_auth.tsx +++ b/apps/web/src/routes/_auth.tsx @@ -5,10 +5,16 @@ export default function AuthLayout() { const auth = useWebAuth(); if (auth.status === "loading") { - return
Loading…
; + return ( +
+ Loading… +
+ ); } - if (auth.status === "authenticated") return ; + if (auth.status === "authenticated") { + return ; + } return (
diff --git a/packages/auth/src/web/use-web-auth.ts b/packages/auth/src/web/use-web-auth.ts index 31161d3..171f5b9 100644 --- a/packages/auth/src/web/use-web-auth.ts +++ b/packages/auth/src/web/use-web-auth.ts @@ -1,6 +1,6 @@ import { api } from "@code/backend/convex/_generated/api"; -import { useEffect, useRef } from "react"; import { useConvexAuth, useQuery } from "convex/react"; +import { useEffect, useRef } from "react"; import { authClient } from "./auth-client"; diff --git a/packages/backend/convex/projectEvents.test.ts b/packages/backend/convex/projectEvents.test.ts index f1f179c..d9695ce 100644 --- a/packages/backend/convex/projectEvents.test.ts +++ b/packages/backend/convex/projectEvents.test.ts @@ -41,9 +41,13 @@ const REMOTE = { warnings: [], }; -const createTestProject = async (t: TestConvex): Promise> => { +const createTestProject = async ( + t: TestConvex +): Promise> => { // Ensure personal organization for the user - await t.withIdentity(identityA).mutation(api.organizations.ensurePersonalOrganization); + await t + .withIdentity(identityA) + .mutation(api.organizations.ensurePersonalOrganization); // Persist the public Git import const outcome = await t .withIdentity(identityA) diff --git a/packages/backend/convex/projectStore.ts b/packages/backend/convex/projectStore.ts index 054700d..c629809 100644 --- a/packages/backend/convex/projectStore.ts +++ b/packages/backend/convex/projectStore.ts @@ -11,12 +11,9 @@ import { type ProjectView, type PublicGitImportResult, } from "@code/primitives/project"; + import type { Doc, Id } from "./_generated/dataModel"; -import type { - ActionCtx, - MutationCtx, - QueryCtx, -} from "./_generated/server"; +import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server"; import { requireCurrentOrganization, requireProjectMember } from "./authz"; // --------------------------------------------------------------------------- @@ -213,7 +210,9 @@ export const makeProjectActionStore = (_ctx: ActionCtx) => ({ _source: PreparedPublicGitSource, _remote: PublicGitImportResult ): Promise => { - throw new Error("persistPublicGitImport must be called via internal mutation"); + throw new Error( + "persistPublicGitImport must be called via internal mutation" + ); }, putContext: async ( @@ -264,7 +263,14 @@ export const persistPublicGitImportTransaction = async ( }); // Apply remote documents as repository writes for (const doc of remote.documents) { - await applyRepositoryWrite(ctx, project._id, organizationId, doc, source.url, now); + 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); @@ -312,7 +318,14 @@ export const persistPublicGitImportTransaction = async ( // Apply remote documents (overrides seeds) for (const doc of remote.documents) { - await applyRepositoryWrite(ctx, projectId, organizationId, doc, source.url, now); + await applyRepositoryWrite( + ctx, + projectId, + organizationId, + doc, + source.url, + now + ); } const createdProject = await ctx.db.get(projectId); @@ -414,8 +427,4 @@ const resolveProjectOrg = async ( }; // Re-export for convenience -export { - CONTEXT_KINDS, - requireCurrentOrganization, - requireProjectMember, -}; +export { CONTEXT_KINDS, requireCurrentOrganization, requireProjectMember }; diff --git a/packages/backend/convex/projects.ts b/packages/backend/convex/projects.ts index 8a30781..a890aa9 100644 --- a/packages/backend/convex/projects.ts +++ b/packages/backend/convex/projects.ts @@ -1,4 +1,3 @@ -import { v } from "convex/values"; import { preparePublicGitSource, type ProjectImportOutcome, @@ -6,12 +5,13 @@ import { type PublicGitImportResult, type PreparedPublicGitSource, } from "@code/primitives/project"; +import { v } from "convex/values"; import { Effect } from "effect"; import type { Id } from "./_generated/dataModel"; import { action, internalMutation, query } from "./_generated/server"; -import { requireCurrentOrganization } from "./authz"; import { createInitialArtifacts } from "./artifactModel"; +import { requireCurrentOrganization } from "./authz"; import { persistPublicGitImportTransaction } from "./projectStore"; // --------------------------------------------------------------------------- @@ -64,7 +64,9 @@ export const persistPublicGitImport = internalMutation({ // existing artifacts) const existingArtifacts = await ctx.db .query("projectArtifacts") - .withIndex("by_project", (q) => q.eq("projectId", result.id as unknown as Id<"projects">)) + .withIndex("by_project", (q) => + q.eq("projectId", result.id as unknown as Id<"projects">) + ) .first(); if (!existingArtifacts) { const now = Date.now(); @@ -190,9 +192,7 @@ export const importPublicGit = action({ // 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" - ); + throw new Error("PublicGit import is not yet wired to the daemon adapter"); }, }); diff --git a/packages/backend/convex/signals.test.ts b/packages/backend/convex/signals.test.ts index d3591c9..6d1e508 100644 --- a/packages/backend/convex/signals.test.ts +++ b/packages/backend/convex/signals.test.ts @@ -44,7 +44,6 @@ const problemStatement = { const processedBy = { agentInstanceId: "zopu-instance-1", agentName: "zopu" }; - // Begin and admit one user message, returning its messageId. const seedMessage = async ( t: TestConvex, diff --git a/packages/primitives/src/project.test.ts b/packages/primitives/src/project.test.ts deleted file mode 100644 index e9578fb..0000000 --- a/packages/primitives/src/project.test.ts +++ /dev/null @@ -1,857 +0,0 @@ -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 index c38a9ba..d6525f7 100644 --- a/packages/primitives/src/project.ts +++ b/packages/primitives/src/project.ts @@ -1,6 +1,8 @@ +/* eslint-disable max-classes-per-file -- deep Effect v4 domain module: branded schemas, tagged errors, and context services */ import { Context, Effect, Layer, Schema } from "effect"; import { OrganizationId, ProjectId, TimestampMs } from "./signal.js"; + export type { OrganizationId, ProjectId } from "./signal.js"; // --------------------------------------------------------------------------- @@ -52,24 +54,23 @@ const MeaningfulString = Schema.String.check( }) ); - // --------------------------------------------------------------------------- // Shared data shapes // --------------------------------------------------------------------------- export const PreparedPublicGitSource = Schema.Struct({ host: Schema.String, + normalizedUrl: Schema.String, projectName: MeaningfulString, repositoryPath: MeaningfulString, - normalizedUrl: Schema.String, url: Schema.String, }); export type PreparedPublicGitSource = typeof PreparedPublicGitSource.Type; export const RepositoryContextDocument = Schema.Struct({ + content: Schema.String, kind: ContextKindSchema, path: Schema.String, - content: Schema.String, }); export type RepositoryContextDocument = typeof RepositoryContextDocument.Type; @@ -250,7 +251,7 @@ const INVALID_REMOTE = new ProjectApplicationError({ export const preparePublicGitSource = ( rawUrl: string ): Effect.Effect => - Effect.gen(function* () { + Effect.gen(function* prepareSource() { let parsed: URL; try { parsed = new URL(rawUrl); @@ -303,7 +304,7 @@ export const preparePublicGitSource = ( export const decodePublicGitImportResult = ( raw: unknown ): Effect.Effect => - Effect.gen(function* () { + Effect.gen(function* decodeImport() { const result = yield* Schema.decodeUnknownEffect(PublicGitImportResult)( raw ).pipe(Effect.mapError(() => INVALID_REMOTE)); @@ -351,7 +352,7 @@ export const decodePublicGitImportResult = ( export const decodePublicTextResult = ( raw: unknown ): Effect.Effect => - Effect.gen(function* () { + Effect.gen(function* decodeText() { const result = yield* Schema.decodeUnknownEffect(PublicTextResult)( raw ).pipe(Effect.mapError(() => INVALID_REMOTE)); @@ -417,6 +418,16 @@ export const makeInitialContext = ( ]; }; +const resolveWriteOrigin = (write: ContextWrite): ContextOrigin => { + if (write._tag === "Repository") { + return "repository"; + } + if (write._tag === "PublicTextUrl") { + return "public-url"; + } + return write.origin; +}; + export interface DecideContextWriteInput { readonly existing: ContextDocumentState | undefined; readonly write: ContextWrite; @@ -430,10 +441,10 @@ export interface DecideContextWriteResult { export const decideContextWrite = ( input: DecideContextWriteInput ): Effect.Effect => - Effect.gen(function* () { - const write = input.write; - const content = write.content; - const kind = write.kind; + Effect.gen(function* decideWrite() { + const { write } = input; + const { content } = write; + const { kind } = write; const path = contextPathForKind(kind); if (isWhitespaceOnly(content)) { @@ -453,16 +464,11 @@ export const decideContextWrite = ( ); } - const origin: ContextOrigin = - write._tag === "Repository" - ? "repository" - : write._tag === "PublicTextUrl" - ? "public-url" - : write.origin; + const origin: ContextOrigin = resolveWriteOrigin(write); const sourceUrl: string | undefined = write._tag === "UserText" ? undefined : write.sourceUrl; - const existing = input.existing; + const { existing } = input; if ( existing && existing.content === content && @@ -475,7 +481,14 @@ export const decideContextWrite = ( const nextRevision = (existing?.revision ?? 0) + 1; return { changed: true, - document: { content, kind, origin, path, revision: nextRevision, sourceUrl }, + document: { + content, + kind, + origin, + path, + revision: nextRevision, + sourceUrl, + }, }; }); @@ -498,11 +511,11 @@ export class ProjectDomain extends Context.Service< static readonly layer = Layer.succeed( ProjectDomain, ProjectDomain.of({ - preparePublicGitSource, + decideContextWrite, decodePublicGitImportResult, decodePublicTextResult, makeInitialContext, - decideContextWrite, + preparePublicGitSource, }) ); } @@ -629,14 +642,25 @@ export class ProjectApplication extends Context.Service< >()("@code/primitives/project/ProjectApplication") { static readonly layer = Layer.effect( ProjectApplication, - Effect.gen(function* () { + Effect.gen(function* layer() { const domain = yield* ProjectDomain; const publicGit = yield* PublicGit; const store = yield* ProjectStore; return ProjectApplication.of({ + getProject: Effect.fn("ProjectApplication.getProject")(function* getProject({ + userId, + projectId, + }: { + readonly userId: string; + readonly projectId: ProjectId; + }) { + return yield* store + .getProject(userId, projectId) + .pipe(Effect.mapError(mapStoreError)); + }), importPublicGit: Effect.fn("ProjectApplication.importPublicGit")( - function* ({ + function* importPublicGit({ userId, repositoryUrl, }: { @@ -647,25 +671,21 @@ export class ProjectApplication extends Context.Service< return yield* Effect.fail(INVALID_GIT_URL); } - const source = yield* domain.preparePublicGitSource( - repositoryUrl - ); + const source = yield* domain.preparePublicGitSource(repositoryUrl); const rawRemote = yield* publicGit .inspect(source) .pipe(Effect.mapError(mapPublicGitError)); - const remote = yield* domain.decodePublicGitImportResult( - rawRemote - ); + const remote = yield* domain.decodePublicGitImportResult(rawRemote); return yield* store - .persistPublicGitImport({ userId, source, remote }) + .persistPublicGitImport({ remote, source, userId }) .pipe(Effect.mapError(mapStoreError)); } ), importPublicText: Effect.fn("ProjectApplication.importPublicText")( - function* ({ + function* importPublicText({ userId, projectId, kind, @@ -699,55 +719,42 @@ export class ProjectApplication extends Context.Service< }; return yield* store - .putContext({ userId, projectId, write }) + .putContext({ projectId, userId, write }) .pipe(Effect.mapError(mapStoreError)); } ), - putContext: Effect.fn("ProjectApplication.putContext")( - function* ({ - userId, - projectId, - kind, + listProjects: Effect.fn("ProjectApplication.listProjects")(function* listProjects({ + userId, + }: { + readonly userId: string; + }) { + return yield* store + .listProjects(userId) + .pipe(Effect.mapError(mapStoreError)); + }), + putContext: Effect.fn("ProjectApplication.putContext")(function* putContext({ + userId, + projectId, + kind, + content, + origin, + }: { + readonly userId: string; + readonly projectId: ProjectId; + readonly kind: ContextKind; + readonly content: string; + readonly origin: "paste" | "upload"; + }) { + const write: ContextWrite = { + _tag: "UserText", content, + kind, origin, - }: { - 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)); - } - ), + }; + return yield* store + .putContext({ projectId, userId, write }) + .pipe(Effect.mapError(mapStoreError)); + }), }); }) );