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

Merged
puter merged 7 commits from feat/projects-backend into master 2026-07-23 14:09:26 +00:00
32 changed files with 1926 additions and 664 deletions

View File

@@ -7,7 +7,6 @@ import {
ExternalLink, ExternalLink,
FileText, FileText,
GitFork, GitFork,
GitPullRequest,
Play, Play,
Plus, Plus,
} from "lucide-react"; } from "lucide-react";
@@ -20,17 +19,16 @@ const statusStyle: Record<Doc<"projectIssues">["status"], string> = {
"border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", "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": "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", "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 { interface RepositoryFormProps {
readonly busy: boolean; readonly busy: boolean;
readonly onChange: (value: string) => void; readonly onChange: (value: string) => void;
readonly onSubmit: () => Promise<void>; readonly onSubmit: () => void;
readonly value: string; readonly value: string;
} }
@@ -41,36 +39,46 @@ const RepositoryForm = ({
value, value,
}: RepositoryFormProps) => ( }: RepositoryFormProps) => (
<form <form
className="grid gap-3 border-b p-4" className="space-y-2 border-b p-3"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
void onSubmit(); onSubmit();
}} }}
> >
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-medium" htmlFor="github-repository"> <label className="text-xs font-medium" htmlFor="git-repository">
Public GitHub repository Public Git repository
</label> </label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Connect with an owner/name slug or repository URL. Import with a public HTTP(S) Git URL.
</p> </p>
</div> </div>
<Input <Input
id="github-repository" id="git-repository"
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
placeholder="rivet-dev/rivet" placeholder="https://github.com/owner/repo"
required required
value={value} value={value}
/> />
<Button disabled={busy || value.trim().length === 0} type="submit"> <Button className="w-full" disabled={busy} type="submit">
<GitFork data-icon="inline-start" /> <GitFork data-icon="inline-start" />
{busy ? "Connecting" : "Connect repository"} {busy ? "Importing" : "Import repository"}
</Button> </Button>
</form> </form>
); );
interface ProjectListProps { 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 selectedId: Id<"projects"> | null;
readonly onSelect: (projectId: Id<"projects">) => void; readonly onSelect: (projectId: Id<"projects">) => void;
} }
@@ -85,7 +93,7 @@ const ProjectList = ({ projects, selectedId, onSelect }: ProjectListProps) => {
if (projects.length === 0) { if (projects.length === 0) {
return ( return (
<p className="p-3 text-xs leading-relaxed text-muted-foreground"> <p className="p-3 text-xs leading-relaxed text-muted-foreground">
Connect a repository to create its project artifacts and issue queue. Import a repository to create its project artifacts and issue queue.
</p> </p>
); );
} }
@@ -94,19 +102,20 @@ const ProjectList = ({ projects, selectedId, onSelect }: ProjectListProps) => {
{projects.map((project) => ( {projects.map((project) => (
<button <button
className={`w-full border px-3 py-2 text-left transition-colors ${ className={`w-full border px-3 py-2 text-left transition-colors ${
selectedId === project._id selectedId === project.id
? "border-foreground/30 bg-muted" ? "border-foreground/30 bg-muted"
: "border-transparent hover:bg-muted/60" : "border-transparent hover:bg-muted/60"
}`} }`}
key={project._id} key={project.id}
onClick={() => onSelect(project._id)} onClick={() => onSelect(project.id as Id<"projects">)}
type="button" type="button"
> >
<span className="block truncate text-sm font-medium"> <span className="block truncate text-sm font-medium">
{project.name} {project.name}
</span> </span>
<span className="block truncate text-xs text-muted-foreground"> <span className="block truncate text-xs text-muted-foreground">
{project.repoOwner}/{project.repoName} {project.sources[0]?.host}
{project.sources[0]?.repositoryPath}
</span> </span>
</button> </button>
))} ))}
@@ -128,40 +137,34 @@ interface ArtifactGridProps {
readonly artifacts: readonly Doc<"projectArtifacts">[] | undefined; readonly artifacts: readonly Doc<"projectArtifacts">[] | undefined;
} }
const renderArtifacts = (artifacts: ArtifactGridProps["artifacts"]) => {
if (artifacts === undefined) {
return <p className="text-xs text-muted-foreground">Loading artifacts...</p>;
}
if (artifacts.length === 0) {
return <p className="text-xs text-muted-foreground">No artifacts yet.</p>;
}
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{artifacts.map((artifact) => (
<div className="border p-3" key={artifact._id}>
<FileText className="mb-2 size-4 text-muted-foreground" />
<p className="truncate text-xs font-medium">{artifact.path}</p>
<p className="text-[10px] text-muted-foreground">
rev {artifact.revision}
</p>
</div>
))}
</div>
);
};
const ArtifactGrid = ({ artifacts }: ArtifactGridProps) => ( const ArtifactGrid = ({ artifacts }: ArtifactGridProps) => (
<section aria-labelledby="artifact-heading" className="space-y-3"> <section aria-labelledby="artifact-heading" className="space-y-3">
<div> <h2 id="artifact-heading" className="text-sm font-semibold">
<h2 className="text-lg font-semibold" id="artifact-heading"> Project artifacts
Project artifacts </h2>
</h2> {renderArtifacts(artifacts)}
<p className="text-sm text-muted-foreground">
Canonical context staged into every issue workspace.
</p>
</div>
{artifacts === undefined ? (
<p className="text-sm text-muted-foreground">Loading artifacts...</p>
) : (
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
{artifacts.map((artifact) => (
<article className="min-w-0 border bg-card p-3" key={artifact._id}>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<FileText className="size-4 shrink-0 text-muted-foreground" />
<h3 className="truncate font-mono text-xs font-medium">
{artifact.path}
</h3>
</div>
<span className="shrink-0 text-[11px] text-muted-foreground">
r{artifact.revision}
</span>
</div>
<p className="line-clamp-4 whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground">
{artifact.content}
</p>
</article>
))}
</div>
)}
</section> </section>
); );
@@ -169,7 +172,7 @@ interface IssueComposerProps {
readonly body: string; readonly body: string;
readonly busy: boolean; readonly busy: boolean;
readonly onBodyChange: (value: string) => void; readonly onBodyChange: (value: string) => void;
readonly onSubmit: () => Promise<void>; readonly onSubmit: () => void;
readonly onTitleChange: (value: string) => void; readonly onTitleChange: (value: string) => void;
readonly title: string; readonly title: string;
} }
@@ -183,47 +186,27 @@ const IssueComposer = ({
title, title,
}: IssueComposerProps) => ( }: IssueComposerProps) => (
<form <form
className="grid gap-3 border p-4" className="space-y-3"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
void onSubmit(); onSubmit();
}} }}
> >
<div> <h2 className="text-sm font-semibold">New issue</h2>
<h2 className="font-semibold">Raise an issue</h2> <Input
<p className="text-xs text-muted-foreground"> onChange={(event) => onTitleChange(event.target.value)}
The issue becomes the identity for one Flue agent and AgentOS workspace. placeholder="Issue title"
</p> required
</div> value={title}
<div className="space-y-1.5"> />
<label className="text-xs font-medium" htmlFor="issue-title"> <Textarea
Title onChange={(event) => onBodyChange(event.target.value)}
</label> placeholder="Describe the issue..."
<Input required
id="issue-title" rows={4}
maxLength={160} value={body}
onChange={(event) => onTitleChange(event.target.value)} />
required <Button className="w-full" disabled={busy} type="submit">
value={title}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium" htmlFor="issue-body">
Description
</label>
<Textarea
id="issue-body"
maxLength={10_000}
onChange={(event) => onBodyChange(event.target.value)}
required
rows={5}
value={body}
/>
</div>
<Button
disabled={busy || title.trim().length < 3 || body.trim().length < 10}
type="submit"
>
<Plus data-icon="inline-start" /> <Plus data-icon="inline-start" />
{busy ? "Creating" : "Create issue"} {busy ? "Creating" : "Create issue"}
</Button> </Button>
@@ -237,89 +220,53 @@ interface IssueListProps {
issueId: Id<"projectIssues">, issueId: Id<"projectIssues">,
issueNumber: number, issueNumber: number,
title: string title: string
) => Promise<void>; ) => void;
} }
const IssueList = ({ issues, pendingAction, onStart }: IssueListProps) => { const IssueList = ({ issues, pendingAction, onStart }: IssueListProps) => {
const renderContent = () => { if (issues === undefined) {
if (issues === undefined) { return <p className="text-xs text-muted-foreground">Loading issues...</p>;
return <p className="text-sm text-muted-foreground">Loading issues...</p>; }
} if (issues.length === 0) {
if (issues.length === 0) {
return (
<p className="border p-4 text-sm text-muted-foreground">
No issues yet. Describe a concrete change to start the workflow.
</p>
);
}
return ( return (
<div className="grid gap-2"> <p className="text-xs text-muted-foreground">
{issues.map((issue) => { No issues yet. Create one to start agent work.
const canStart = ["open", "needs-input", "failed"].includes( </p>
issue.status
);
const busy = pendingAction === `issue:${issue._id}`;
let actionLabel: string = issue.status;
if (canStart) {
actionLabel = "Start agent";
}
if (busy) {
actionLabel = "Dispatching";
}
return (
<article
className="grid gap-3 border bg-card p-4 md:grid-cols-[1fr_auto] md:items-center"
key={issue._id}
>
<div className="min-w-0">
<div className="mb-1 flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">
#{issue.number}
</span>
<span
className={`border px-1.5 py-0.5 text-[11px] font-medium ${statusStyle[issue.status]}`}
>
{issue.status}
</span>
</div>
<h3 className="font-medium">{issue.title}</h3>
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{issue.body}
</p>
</div>
<Button
disabled={!canStart || busy}
onClick={() =>
void onStart(issue._id, issue.number, issue.title)
}
size="sm"
type="button"
variant={canStart ? "default" : "outline"}
>
{canStart ? (
<Play data-icon="inline-start" />
) : (
<Bot data-icon="inline-start" />
)}
{actionLabel}
</Button>
</article>
);
})}
</div>
); );
}; }
return ( return (
<section aria-labelledby="issue-heading" className="space-y-3"> <div className="space-y-2">
<div className="flex items-center gap-2"> <h2 className="text-sm font-semibold">Issues</h2>
<GitPullRequest className="size-4 text-muted-foreground" /> {issues.map((issue) => (
<h2 className="text-lg font-semibold" id="issue-heading"> <div className="border p-3" key={issue._id}>
Issue queue <div className="flex items-center justify-between gap-2">
</h2> <span className="text-sm font-medium">
</div> #{issue.number} · {issue.title}
{renderContent()} </span>
</section> <span
className={`rounded border px-2 py-0.5 text-[10px] font-medium ${statusStyle[issue.status]}`}
>
{issue.status}
</span>
</div>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{issue.body}
</p>
{(issue.status === "open" || issue.status === "failed") && (
<Button
className="mt-2"
disabled={pendingAction === `issue:${issue._id}`}
onClick={() => onStart(issue._id, issue.number, issue.title)}
size="sm"
variant="outline"
>
<Play data-icon="inline-start" />
{pendingAction === `issue:${issue._id}` ? "Starting" : "Start"}
</Button>
)}
</div>
))}
</div>
); );
}; };
@@ -332,6 +279,7 @@ export const ProjectWorkspacePage = () => {
const handleIssueSubmit = workspace.raiseIssue; const handleIssueSubmit = workspace.raiseIssue;
const handleIssueTitleChange = workspace.setIssueTitle; const handleIssueTitleChange = workspace.setIssueTitle;
const handleIssueStart = workspace.startIssue; const handleIssueStart = workspace.startIssue;
const projectSource = workspace.selectedProject?.sources[0];
return ( return (
<main className="min-h-svh bg-background text-foreground"> <main className="min-h-svh bg-background text-foreground">
@@ -344,7 +292,7 @@ export const ProjectWorkspacePage = () => {
<div> <div>
<p className="text-sm font-semibold">Project manager</p> <p className="text-sm font-semibold">Project manager</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
GitHub, Flue, AgentOS Git · Flue · AgentOS
</p> </p>
</div> </div>
</div> </div>
@@ -379,21 +327,19 @@ export const ProjectWorkspacePage = () => {
<div className="flex flex-col gap-3 border-b pb-5 sm:flex-row sm:items-end sm:justify-between"> <div className="flex flex-col gap-3 border-b pb-5 sm:flex-row sm:items-end sm:justify-between">
<div> <div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Connected GitHub project Connected repository
</p> </p>
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
{workspace.selectedProject.repoOwner}/ {workspace.selectedProject.name}
{workspace.selectedProject.repoName}
</h1> </h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground"> <p className="mt-1 max-w-2xl text-sm text-muted-foreground">
{workspace.selectedProject.description ?? {projectSource?.host}/{projectSource?.repositoryPath}
"No repository description is available."}
</p> </p>
</div> </div>
<Button <Button
onClick={() => onClick={() =>
window.open( window.open(
workspace.selectedProject?.repoUrl, projectSource?.url,
"_blank", "_blank",
"noopener,noreferrer" "noopener,noreferrer"
) )
@@ -402,7 +348,7 @@ export const ProjectWorkspacePage = () => {
variant="outline" variant="outline"
> >
<ExternalLink data-icon="inline-start" /> <ExternalLink data-icon="inline-start" />
Open GitHub Open repository
</Button> </Button>
</div> </div>
@@ -429,7 +375,7 @@ export const ProjectWorkspacePage = () => {
<div className="max-w-sm space-y-2"> <div className="max-w-sm space-y-2">
<GitFork className="mx-auto size-7 text-muted-foreground" /> <GitFork className="mx-auto size-7 text-muted-foreground" />
<h1 className="text-xl font-semibold"> <h1 className="text-xl font-semibold">
Connect your first repository Import your first repository
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Zopu creates the project artifact set and opens an issue queue Zopu creates the project artifact set and opens an issue queue

View File

@@ -1,5 +1,4 @@
import { authClient } from "@code/auth/web"; import { signOutWeb, useWebAuth } from "@code/auth/web";
import { api } from "@code/backend/convex/_generated/api";
import { Button } from "@code/ui/components/button"; import { Button } from "@code/ui/components/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -10,12 +9,13 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@code/ui/components/dropdown-menu"; } from "@code/ui/components/dropdown-menu";
import { useQuery } from "convex/react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
export default function UserMenu() { export default function UserMenu() {
const navigate = useNavigate(); const navigate = useNavigate();
const user = useQuery(api.auth.getCurrentUser); const auth = useWebAuth();
const user = auth.status === "authenticated" ? auth.user : null;
return ( return (
<DropdownMenu> <DropdownMenu>
@@ -29,14 +29,9 @@ export default function UserMenu() {
<DropdownMenuItem>{user?.email}</DropdownMenuItem> <DropdownMenuItem>{user?.email}</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
variant="destructive" variant="destructive"
onClick={() => { onClick={async () => {
authClient.signOut({ await signOutWeb();
fetchOptions: { navigate("/login", { replace: true });
onSuccess: () => {
navigate("/login", { replace: true });
},
},
});
}} }}
> >
Sign Out Sign Out

View File

@@ -1,77 +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(),
session: {
data: { session: { id: "session-a" } },
} as { data: { session: { id: string } } | null },
state: null as {
error?: Error;
organizationId?: string;
sessionId: string;
} | null,
}));
vi.mock("@code/auth/web", () => ({
authClient: { useSession: () => mocks.session },
}));
vi.mock("@code/backend/convex/_generated/api", () => ({
api: { organizations: { ensurePersonalOrganization: "ensure-org" } },
}));
vi.mock("convex/react", () => ({
useMutation: () => mocks.ensurePersonalOrganization,
}));
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;
},
],
}));
describe("usePersonalOrganization", () => {
beforeEach(() => {
mocks.cleanup?.();
mocks.cleanup = undefined;
mocks.ensurePersonalOrganization.mockReset();
mocks.session = { data: { session: { id: "session-a" } } };
mocks.state = null;
});
test("an authenticated session ensures its org before exposing the id", async () => {
const ensured = Promise.withResolvers<{ _id: string }>();
mocks.ensurePersonalOrganization.mockReturnValue(ensured.promise);
expect(usePersonalOrganization()).toEqual({});
expect(mocks.ensurePersonalOrganization).toHaveBeenCalledTimes(1);
ensured.resolve({ _id: "org-a" });
await ensured.promise;
await Promise.resolve();
expect(usePersonalOrganization()).toEqual({
error: undefined,
organizationId: "org-a",
});
});
test("logout cannot expose the prior session organization", async () => {
mocks.ensurePersonalOrganization.mockResolvedValue({ _id: "org-a" });
usePersonalOrganization();
await Promise.resolve();
await Promise.resolve();
mocks.session = { data: null };
expect(usePersonalOrganization()).toEqual({});
});
});

View File

@@ -1,33 +1,28 @@
import { authClient } from "@code/auth/web"; import { useWebAuth } from "@code/auth/web";
import { api } from "@code/backend/convex/_generated/api"; import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel"; import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useMutation } from "convex/react"; import { useMutation } from "convex/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
interface OrganizationBootstrapState {
readonly error?: Error;
readonly organizationId?: Id<"organizations">;
readonly sessionId: string;
}
export interface PersonalOrganizationState { export interface PersonalOrganizationState {
readonly error?: Error; readonly error?: Error;
readonly organizationId?: Id<"organizations">; 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 => { export const usePersonalOrganization = (): PersonalOrganizationState => {
const { data: session } = authClient.useSession(); const auth = useWebAuth();
const sessionId = session?.session.id; const userId = auth.status === "authenticated" ? auth.user.id : null;
const ensurePersonalOrganization = useMutation( const ensurePersonalOrganization = useMutation(
api.organizations.ensurePersonalOrganization api.organizations.ensurePersonalOrganization
); );
const [bootstrap, setBootstrap] = useState<OrganizationBootstrapState | null>( const [organizationId, setOrganizationId] = useState<
null Id<"organizations"> | undefined
); >();
const [error, setError] = useState<Error | undefined>();
useEffect(() => { useEffect(() => {
if (!sessionId) { if (!userId) {
return; return;
} }
@@ -36,20 +31,16 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
try { try {
const organization = await ensurePersonalOrganization(); const organization = await ensurePersonalOrganization();
if (active) { if (active) {
setBootstrap({ setOrganizationId(organization._id);
organizationId: organization._id, setError(undefined);
sessionId,
});
} }
} catch (caughtError: unknown) { } catch (caughtError: unknown) {
if (active) { if (active) {
setBootstrap({ setError(
error: caughtError instanceof Error
caughtError instanceof Error ? caughtError
? caughtError : new Error(String(caughtError))
: new Error(String(caughtError)), );
sessionId,
});
} }
} }
}; };
@@ -58,13 +49,11 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
return () => { return () => {
active = false; active = false;
}; };
}, [ensurePersonalOrganization, sessionId]); }, [ensurePersonalOrganization, userId]);
if (!sessionId || bootstrap?.sessionId !== sessionId) { if (!userId) {
return {}; return {};
} }
return {
error: bootstrap.error, return { error, organizationId };
organizationId: bootstrap.organizationId,
};
}; };

View File

@@ -17,11 +17,14 @@ export const useProjectWorkspace = () => {
const [issueBody, setIssueBody] = useState(""); const [issueBody, setIssueBody] = useState("");
const [pendingAction, setPendingAction] = useState<string | null>(null); const [pendingAction, setPendingAction] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const connectGitHub = useAction(api.projects.connectGitHub); const importPublicGit = useAction(api.projects.importPublicGit);
const createIssue = useMutation(api.projectIssues.create); const createIssue = useMutation(api.projectIssues.create);
const beginIssue = useMutation(api.projectIssues.begin); const beginIssue = useMutation(api.projectIssues.begin);
const markDispatchFailed = useMutation(api.projectIssues.markDispatchFailed); const markDispatchFailed = useMutation(api.projectIssues.markDispatchFailed);
const activeProjectId = selectedProjectId ?? projects?.[0]?._id ?? null; const activeProjectId =
selectedProjectId ??
(projects?.[0]?.id as unknown as Id<"projects">) ??
null;
const artifacts = useQuery( const artifacts = useQuery(
api.projectArtifacts.list, api.projectArtifacts.list,
activeProjectId ? { projectId: activeProjectId } : "skip" activeProjectId ? { projectId: activeProjectId } : "skip"
@@ -35,8 +38,8 @@ export const useProjectWorkspace = () => {
setPendingAction("connect"); setPendingAction("connect");
setError(null); setError(null);
try { try {
const projectId = await connectGitHub({ repository }); const outcome = await importPublicGit({ repositoryUrl: repository });
setSelectedProjectId(projectId); setSelectedProjectId(outcome.id as unknown as Id<"projects">);
setRepository(""); setRepository("");
} catch (caughtError) { } catch (caughtError) {
setError(errorMessage(caughtError)); setError(errorMessage(caughtError));
@@ -87,9 +90,10 @@ export const useProjectWorkspace = () => {
setPendingAction(null); setPendingAction(null);
} }
}; };
const selectedProject = const selectedProject =
projects?.find((project) => project._id === activeProjectId) ?? null; projects?.find(
(project) => project.id === (activeProjectId as unknown as string)
) ?? null;
return { return {
artifacts, artifacts,

View File

@@ -1,15 +1,27 @@
import { useConvexAuth } from "convex/react"; import { useWebAuth } from "@code/auth/web";
import { Navigate, Outlet, useLocation } from "react-router"; import { Navigate, Outlet, useLocation } from "react-router";
export default function AppLayout() { export default function AppLayout() {
const { isAuthenticated, isLoading } = useConvexAuth(); const auth = useWebAuth();
const location = useLocation(); const location = useLocation();
if (isLoading) { if (auth.status === "loading") {
return <div className="grid min-h-svh place-items-center text-sm text-muted-foreground">Loading</div>; return (
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
Loading
</div>
);
} }
if (!isAuthenticated) { if (auth.status === "inconsistent") {
return (
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
Your session could not be verified
</div>
);
}
if (auth.status === "unauthenticated") {
return <Navigate replace state={{ from: location.pathname }} to="/login" />; return <Navigate replace state={{ from: location.pathname }} to="/login" />;
} }

View File

@@ -1,14 +1,20 @@
import { useConvexAuth } from "convex/react"; import { useWebAuth } from "@code/auth/web";
import { Navigate, Outlet } from "react-router"; import { Navigate, Outlet } from "react-router";
export default function AuthLayout() { export default function AuthLayout() {
const { isAuthenticated, isLoading } = useConvexAuth(); const auth = useWebAuth();
if (isLoading) { if (auth.status === "loading") {
return <div className="grid min-h-svh place-items-center text-sm text-muted-foreground">Loading</div>; return (
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
Loading
</div>
);
} }
if (isAuthenticated) return <Navigate replace to="/" />; if (auth.status === "authenticated") {
return <Navigate replace to="/" />;
}
return ( return (
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10"> <main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">

View File

@@ -1,4 +1,4 @@
# Zopo Work OS — Product Design # Zopu Work OS — Product Design
> **Status:** canonical working interaction/interface specification · **Audience:** product design, frontend/product engineering, agent experience · **Scope:** information architecture, screens, cards, interaction patterns, states, actions, principles · **Excludes:** backend and low-level infrastructure > **Status:** canonical working interaction/interface specification · **Audience:** product design, frontend/product engineering, agent experience · **Scope:** information architecture, screens, cards, interaction patterns, states, actions, principles · **Excludes:** backend and low-level infrastructure
@@ -281,7 +281,7 @@ Use explicit actions, never vague “continue”: **Approve plan; answer questio
```text ```text
Deploy preview Deploy preview
Project: Zopo Web · Branch: work/W-103/team-invitations Project: Zopu Web · Branch: work/W-103/team-invitations
Environment: Temporary preview · External impact: None Environment: Temporary preview · External impact: None
Expected duration: 20 minutes Expected duration: 20 minutes
[Deploy preview] [Deploy preview]
@@ -364,4 +364,4 @@ Within seconds, users can answer: **What should I focus on? What progressed with
## 25. Core experience statement ## 25. Core experience statement
Zopo should feel like supervising a capable operating system, not operating a collection of agents: one workspace shows meaningful work; the user resolves the few things requiring judgment and continues conversation where needed; the system handles context, execution, progress tracking, and knowledge retention beneath a calm surface organized around persistent outcomes. Zopu should feel like supervising a capable operating system, not operating a collection of agents: one workspace shows meaningful work; the user resolves the few things requiring judgment and continues conversation where needed; the system handles context, execution, progress tracking, and knowledge retention beneath a calm surface organized around persistent outcomes.

View File

@@ -1,10 +1,10 @@
# Zopo Work OS — Product # Zopu Work OS — Product
> Working spec · product/design/ops/research/agent teams · concepts, behavior, boundaries, value. Excludes implementation, infra, tech choices, low-level UI. > Working spec · product/design/ops/research/agent teams · concepts, behavior, boundaries, value. Excludes implementation, infra, tech choices, low-level UI.
## 1. Summary ## 1. Summary
Zopo Work OS is a persistent OS for technical founders, product engineers, and small teams managing the full lifecycle of building and growing software across products, companies, repos, customers, and functions. It replaces fragmented chats, trackers, docs, and manually coordinated AI agents with one coherent system organized around **units of work**. Zopu Work OS is a persistent OS for technical founders, product engineers, and small teams managing the full lifecycle of building and growing software across products, companies, repos, customers, and functions. It replaces fragmented chats, trackers, docs, and manually coordinated AI agents with one coherent system organized around **units of work**.
One continuous workspace discovers work, builds understanding, coordinates execution, requests human judgment when needed, tracks outcomes, and retains knowledge. Not a better chat app or just another PM tool — it continuously converts company signals into structured, persistent, executable work. One continuous workspace discovers work, builds understanding, coordinates execution, requests human judgment when needed, tracks outcomes, and retains knowledge. Not a better chat app or just another PM tool — it continuously converts company signals into structured, persistent, executable work.
@@ -26,7 +26,7 @@ Technical-first because code is a clear execution domain (measurable artifacts,
| **Across AI sessions** | Chat/thread-organized work forces repeated decisions: which conversation, what context to restate, which model/agent, where to store output, how it relates to existing work, what changed after shipping. Conversation becomes structure though conversations are temporary and work is persistent. | | **Across AI sessions** | Chat/thread-organized work forces repeated decisions: which conversation, what context to restate, which model/agent, where to store output, how it relates to existing work, what changed after shipping. Conversation becomes structure though conversations are temporary and work is persistent. |
| **Founders as coordination** | Small-team founders bridge every system/function: remember decisions, connect feedback to work, check blockers, move work between tools, review AI output, decide next steps. The founder becomes the human integration bus. | | **Founders as coordination** | Small-team founders bridge every system/function: remember decisions, connect feedback to work, check blockers, move work between tools, review AI output, decide next steps. The founder becomes the human integration bus. |
Zopo removes this burden by maintaining a persistent model of work, context, state, evidence, execution, and outcomes. Zopu removes this burden by maintaining a persistent model of work, context, state, evidence, execution, and outcomes.
## 4. Thesis ## 4. Thesis
@@ -161,7 +161,7 @@ Accumulates several kinds; each retains its scope (personal prefs, project facts
| Boundary | Rule | | Boundary | Rule |
| --- | --- | | --- | --- |
| **Control layer, not tool replacement** | Specialist systems (source control, support, comms, analytics, billing, deployment, docs) keep native records/actions. Zopo provides the unified model: what matters, its state, next step, whether outcome worked. | | **Control layer, not tool replacement** | Specialist systems (source control, support, comms, analytics, billing, deployment, docs) keep native records/actions. Zopu provides the unified model: what matters, its state, next step, whether outcome worked. |
| **Own the work graph** | Connects organizations→projects→goals→signals→work units→dependencies→decisions→runs→artifacts→results→learnings. Durable source of coordination/context. | | **Own the work graph** | Connects organizations→projects→goals→signals→work units→dependencies→decisions→runs→artifacts→results→learnings. Durable source of coordination/context. |
| **Execution is replaceable** | Execution is a capability beneath the work unit. Users shouldn't care which agent/model/harness ran unless inspection is useful. | | **Execution is replaceable** | Execution is a capability beneath the work unit. Users shouldn't care which agent/model/harness ran unless inspection is useful. |
| **No unnecessary AI config** | Users shouldn't normally choose model, thinking level, agent framework, context-window strategy, execution runtime, tool routing. System chooses by task/risk/cost/policy. | | **No unnecessary AI config** | Users shouldn't normally choose model, thinking level, agent framework, context-window strategy, execution runtime, tool routing. System chooses by task/risk/cost/policy. |
@@ -225,4 +225,4 @@ Not intended to be: a full external-tool replacement; a general social collabora
## 20. Working statement ## 20. Working statement
Zopo Work OS is an autonomous OS for technical founders and small product teams. It observes work across the company, turns fragmented signals into persistent units of work, progresses them via agents and tools, and brings the human in only when judgment, permission, or direction is required. One continuous workspace moves from idea/problem to verified, shipped, measurable outcome while preserving context and building durable understanding over time. Zopu Work OS is an autonomous OS for technical founders and small product teams. It observes work across the company, turns fragmented signals into persistent units of work, progresses them via agents and tools, and brings the human in only when judgment, permission, or direction is required. One continuous workspace moves from idea/problem to verified, shipped, measurable outcome while preserving context and building durable understanding over time.

View File

@@ -1,4 +1,4 @@
# Zopo Work OS — Technical Architecture # Zopu Work OS — Technical Architecture
> Working spec · engineering/platform/infra/security/agent teams · starts from an existing Better-T-Stack monorepo (web + Expo + shared packages). > Working spec · engineering/platform/infra/security/agent teams · starts from an existing Better-T-Stack monorepo (web + Expo + shared packages).
@@ -107,7 +107,7 @@ Known first-party templates may run in lighter env; imported arbitrary repos def
``` ```
/ README.md, AGENTS.md, product.md, design.md, tech.md / README.md, AGENTS.md, product.md, design.md, tech.md
/.zopo/ project.yaml, services.yaml, policies.yaml /.zopu/ project.yaml, services.yaml, policies.yaml
lifecycle/{setup,resume,verify,preview} skills/{testing,reviewing,deploying}/SKILL.md lifecycle/{setup,resume,verify,preview} skills/{testing,reviewing,deploying}/SKILL.md
/apps/ /apps/
``` ```

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
import { authClient } from "./auth-client"; 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 }) => ( export const WebAuthProvider = ({ children }: { children: ReactNode }) => (
<ConvexBetterAuthProvider authClient={authClient} client={convex}> <ConvexBetterAuthProvider authClient={authClient} client={convex}>

View File

@@ -1,5 +1,7 @@
export { authClient } from "./auth-client"; export { authClient } from "./auth-client";
export { WebAuthProvider } from "./auth-provider"; export { WebAuthProvider } from "./auth-provider";
export { useConvexAccessToken } from "./hooks"; 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 { LoginForm } from "./login-form";
export { SignupForm } from "./signup-form"; export { SignupForm } from "./signup-form";

View File

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

View File

@@ -1,9 +1,6 @@
import { v } from "convex/values"; import { v } from "convex/values";
export const ARTIFACT_PATHS = [ export const ARTIFACT_PATHS = [
"project.md",
"business.md",
"design.md",
"agent.md", "agent.md",
"work.md", "work.md",
"steps.md", "steps.md",
@@ -17,9 +14,6 @@ export const ARTIFACT_PATHS = [
export type ArtifactPath = (typeof ARTIFACT_PATHS)[number]; export type ArtifactPath = (typeof ARTIFACT_PATHS)[number];
export const artifactPath = v.union( export const artifactPath = v.union(
v.literal("project.md"),
v.literal("business.md"),
v.literal("design.md"),
v.literal("agent.md"), v.literal("agent.md"),
v.literal("work.md"), v.literal("work.md"),
v.literal("steps.md"), v.literal("steps.md"),
@@ -30,71 +24,48 @@ export const artifactPath = v.union(
v.literal("card.md") v.literal("card.md")
); );
interface ProjectSeed { export const createInitialArtifacts = (): readonly {
readonly defaultBranch: string; path: ArtifactPath;
readonly description?: string; content: string;
readonly name: string; }[] => [
readonly repoName: string; {
readonly repoOwner: string; content:
readonly repoUrl: string; "# 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",
},
export const createInitialArtifacts = ( {
project: ProjectSeed content:
): readonly { path: ArtifactPath; content: string }[] => { "# Work\n\nNo issue has entered the agent queue yet. New issues are appended here by the control plane.\n",
const repository = `${project.repoOwner}/${project.repoName}`; path: "work.md",
const purpose = project.description ?? `Maintain and improve ${repository}.`; },
{
return [ 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",
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: "steps.md",
path: "project.md", },
}, {
{ content:
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`, "# Artifacts\n\nThis file indexes concrete outputs from issue runs, including changed paths, command results, and preview links.\n",
path: "business.md", path: "artifacts.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`, content:
path: "design.md", "# 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\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:
}, "# 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: },
"# Work\n\nNo issue has entered the agent queue yet. New issues are appended here by the control plane.\n", {
path: "work.md", content:
}, "# Context\n\nCanonical project knowledge is staged under /workspace/context. Issue-specific facts belong below.\n",
{ path: "context.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:
}, "# 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",
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",
},
];
};

View File

@@ -36,7 +36,33 @@ const createAuth = (ctx: GenericCtx<DataModel>) =>
export { createAuth }; 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({ export const getCurrentUser = query({
args: {}, args: {},
handler: (ctx) => authComponent.safeGetAuthUser(ctx), handler: async (ctx): Promise<AuthUserView | null> => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
return null;
}
const authUser = await authComponent.safeGetAuthUser(ctx);
if (!authUser) {
return null;
}
return {
email: authUser.email,
id: identity.tokenIdentifier,
name: authUser.name,
};
},
}); });

View File

@@ -11,7 +11,7 @@ export interface AuthContext {
}; };
} }
export const requireOwnerId = async (ctx: AuthContext): Promise<string> => { export const requireAuthUserId = async (ctx: AuthContext): Promise<string> => {
const identity = await ctx.auth.getUserIdentity(); const identity = await ctx.auth.getUserIdentity();
if (!identity) { if (!identity) {
throw new ConvexError("Authentication required"); throw new ConvexError("Authentication required");
@@ -28,7 +28,7 @@ export const requireOrganizationMember = async (
ctx: QueryCtx | MutationCtx, ctx: QueryCtx | MutationCtx,
organizationId: Id<"organizations"> organizationId: Id<"organizations">
): Promise<string> => { ): Promise<string> => {
const userId = await requireOwnerId(ctx); const userId = await requireAuthUserId(ctx);
const membership = await ctx.db const membership = await ctx.db
.query("organizationMembers") .query("organizationMembers")
.withIndex("by_organizationId_and_userId", (q) => .withIndex("by_organizationId_and_userId", (q) =>
@@ -40,3 +40,41 @@ export const requireOrganizationMember = async (
} }
return userId; return userId;
}; };
/**
* Resolve the authenticated user's personal/current organization. Browsers
* never submit organization IDs for Project commands; this resolves the
* tenant boundary server-side through the authenticated identity.
*/
export const requireCurrentOrganization = async (
ctx: QueryCtx | MutationCtx
): Promise<{ organizationId: Id<"organizations">; userId: string }> => {
const userId = await requireAuthUserId(ctx);
const organization = await ctx.db
.query("organizations")
.withIndex("by_createdBy_and_kind", (q) =>
q.eq("createdBy", userId).eq("kind", "personal")
)
.unique();
if (!organization) {
throw new ConvexError("Organization not found");
}
return { organizationId: organization._id, userId };
};
/**
* Prove the authenticated user is a member of the project's organization.
* Projects are organization-scoped; unauthorized single-object operations
* throw "Project not found" to avoid leaking existence.
*/
export const requireProjectMember = async (
ctx: QueryCtx | MutationCtx,
projectId: Id<"projects">
): Promise<{ organizationId: Id<"organizations">; userId: string }> => {
const project = await ctx.db.get(projectId);
if (!project) {
throw new ConvexError("Project not found");
}
const userId = await requireOrganizationMember(ctx, project.organizationId);
return { organizationId: project.organizationId, userId };
};

View File

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

View File

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

View File

@@ -7,9 +7,6 @@ import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel"; import type { Id } from "./_generated/dataModel";
import schema from "./schema"; import schema from "./schema";
// `import.meta.glob` is provided by the Vitest/Vite test runner at runtime; it
// has no type definition under the Convex tsconfig (which scopes `types` to
// node), so declare the minimal shape the test relies on.
declare global { declare global {
interface ImportMeta { interface ImportMeta {
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>; readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
@@ -19,26 +16,49 @@ declare global {
const modules = import.meta.glob("./**/*.ts"); const modules = import.meta.glob("./**/*.ts");
const api = anyApi; const api = anyApi;
// `agentWorkspace` gates agent-controlled mutations on `env.FLUE_DB_TOKEN`.
// Vitest loads the repo `.env`, so resolve the real value from the env module
// rather than hardcoding a constant that would mismatch the loaded secret.
const FLUE_DB_TOKEN = env.FLUE_DB_TOKEN; const FLUE_DB_TOKEN = env.FLUE_DB_TOKEN;
const ID_A = "https://convex.test|user-a"; const ID_A = "https://convex.test|user-a";
const identityA = { tokenIdentifier: ID_A }; const identityA = { tokenIdentifier: ID_A };
const repository = { const SOURCE = {
defaultBranch: "main", host: "github.com",
id: 12345, projectName: "zopu",
name: "zopu", repositoryPath: "puter/zopu",
owner: "puter", normalizedUrl: "https://github.com/puter/zopu",
url: "https://github.com/puter/zopu", url: "https://github.com/puter/zopu",
}; };
// Read every projectEvents row for a project via the same indexes the control const REMOTE = {
// plane writes through, returning them oldest-first. Reads MUST go through the defaultBranch: "main",
// same test instance that performed the writes; a fresh `convexTest()` is an documents: [
// isolated in-memory backend with no shared state. {
kind: "readme" as const,
path: "README.md",
content: "# zopu\n\nRepository: https://github.com/puter/zopu\n",
},
],
warnings: [],
};
const createTestProject = async (
t: TestConvex<typeof schema>
): Promise<Id<"projects">> => {
// Ensure personal organization for the user
await t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization);
// Persist the public Git import
const outcome = await t
.withIdentity(identityA)
.mutation(internal.projects.persistPublicGitImport, {
userId: ID_A,
source: SOURCE,
remote: REMOTE,
});
return outcome.id as unknown as Id<"projects">;
};
const eventsForProject = async ( const eventsForProject = async (
t: TestConvex<typeof schema>, t: TestConvex<typeof schema>,
projectId: Id<"projects"> projectId: Id<"projects">
@@ -56,10 +76,7 @@ const eventsForProject = async (
describe("projectEvents", () => { describe("projectEvents", () => {
test("project connect emits a project.connected event", async () => { test("project connect emits a project.connected event", async () => {
const t = convexTest({ schema, modules }); const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, { const projectId = await createTestProject(t);
ownerId: ID_A,
repository,
});
const events = await eventsForProject(t, projectId); const events = await eventsForProject(t, projectId);
expect(events).toHaveLength(1); expect(events).toHaveLength(1);
@@ -67,18 +84,11 @@ describe("projectEvents", () => {
expect(events[0].projectId).toBe(projectId); expect(events[0].projectId).toBe(projectId);
expect(events[0].issueId).toBeUndefined(); expect(events[0].issueId).toBeUndefined();
expect(events[0].runId).toBeUndefined(); expect(events[0].runId).toBeUndefined();
expect(events[0].data).toMatchObject({
provider: "github",
repository: repository.url,
});
}); });
test("issue create and begin emit issue.created then issue.queued events", async () => { test("issue create and begin emit issue.created then issue.queued events", async () => {
const t = convexTest({ schema, modules }); const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, { const projectId = await createTestProject(t);
ownerId: ID_A,
repository,
});
const issueId = await t const issueId = await t
.withIdentity(identityA) .withIdentity(identityA)
@@ -119,10 +129,7 @@ describe("projectEvents", () => {
test("artifact update emits an artifact.updated event", async () => { test("artifact update emits an artifact.updated event", async () => {
const t = convexTest({ schema, modules }); const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, { const projectId = await createTestProject(t);
ownerId: ID_A,
repository,
});
const issueId = await t const issueId = await t
.withIdentity(identityA) .withIdentity(identityA)
.mutation(api.projectIssues.create, { .mutation(api.projectIssues.create, {
@@ -149,10 +156,7 @@ describe("projectEvents", () => {
test("agent status emits an agent.<status> event linked to the run", async () => { test("agent status emits an agent.<status> event linked to the run", async () => {
const t = convexTest({ schema, modules }); const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, { const projectId = await createTestProject(t);
ownerId: ID_A,
repository,
});
const issueId = await t const issueId = await t
.withIdentity(identityA) .withIdentity(identityA)
.mutation(api.projectIssues.create, { .mutation(api.projectIssues.create, {
@@ -192,10 +196,7 @@ describe("projectEvents", () => {
test("wrong agent token is rejected before any event is written", async () => { test("wrong agent token is rejected before any event is written", async () => {
const t = convexTest({ schema, modules }); const t = convexTest({ schema, modules });
const projectId = await t.mutation(internal.projects.storeGitHubProject, { const projectId = await createTestProject(t);
ownerId: ID_A,
repository,
});
const issueId = await t const issueId = await t
.withIdentity(identityA) .withIdentity(identityA)
.mutation(api.projectIssues.create, { .mutation(api.projectIssues.create, {

View File

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

View File

@@ -0,0 +1,430 @@
import {
CONTEXT_KINDS,
contextPathForKind,
decideContextWrite,
makeInitialContext,
type ContextDocumentState,
type ContextKind,
type ContextWrite,
type PreparedPublicGitSource,
type ProjectImportOutcome,
type ProjectView,
type PublicGitImportResult,
} from "@code/primitives/project";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
import { requireCurrentOrganization, requireProjectMember } from "./authz";
// ---------------------------------------------------------------------------
// View mappers
// ---------------------------------------------------------------------------
interface SourceRow extends Doc<"projectSources"> {}
const toContextDocumentState = (
doc: Doc<"projectContextDocuments">
): ContextDocumentState => ({
content: doc.content,
kind: doc.kind,
origin: doc.origin,
path: doc.path,
revision: doc.revision,
sourceUrl: doc.sourceUrl ?? undefined,
});
const toProjectView = async (
ctx: QueryCtx,
project: Doc<"projects">
): Promise<ProjectView> => {
const [sources, contextDocs] = await Promise.all([
ctx.db
.query("projectSources")
.withIndex("by_project", (q) => q.eq("projectId", project._id))
.collect(),
ctx.db
.query("projectContextDocuments")
.withIndex("by_project", (q) => q.eq("projectId", project._id))
.collect(),
]);
return {
contextDocuments: contextDocs.map(toContextDocumentState),
createdAt: project.createdAt as never,
id: project._id as never,
name: project.name,
organizationId: project.organizationId as never,
sources: sources.map((s) => ({
createdAt: s.createdAt as never,
defaultBranch: s.defaultBranch,
host: s.host,
kind: "git" as const,
normalizedUrl: s.normalizedUrl,
projectId: s.projectId as never,
repositoryPath: s.repositoryPath,
updatedAt: s.updatedAt as never,
url: s.url,
})),
updatedAt: project.updatedAt as never,
};
};
const toImportOutcome = async (
ctx: QueryCtx,
project: Doc<"projects">,
source: SourceRow
): Promise<ProjectImportOutcome> => {
const contextDocs = await ctx.db
.query("projectContextDocuments")
.withIndex("by_project", (q) => q.eq("projectId", project._id))
.collect();
return {
contextDocuments: contextDocs.map(toContextDocumentState),
createdAt: project.createdAt as never,
id: project._id as never,
name: project.name,
organizationId: project.organizationId as never,
source: {
createdAt: source.createdAt as never,
defaultBranch: source.defaultBranch,
host: source.host,
kind: "git" as const,
normalizedUrl: source.normalizedUrl,
projectId: source.projectId as never,
repositoryPath: source.repositoryPath,
updatedAt: source.updatedAt as never,
url: source.url,
},
updatedAt: project.updatedAt as never,
};
};
// ---------------------------------------------------------------------------
// Query store — read-only ctx.db access
// ---------------------------------------------------------------------------
export const makeProjectQueryStore = (ctx: QueryCtx) => ({
listProjects: async (userId: string): Promise<ProjectView[]> => {
const { organizationId } = await resolveOrgForUser(ctx, userId);
const projects = await ctx.db
.query("projects")
.withIndex("by_organization_and_createdAt", (q) =>
q.eq("organizationId", organizationId)
)
.order("desc")
.take(50);
return Promise.all(projects.map((p) => toProjectView(ctx, p)));
},
getProject: async (
userId: string,
projectId: Id<"projects">
): Promise<ProjectView | null> => {
const { organizationId } = await resolveOrgForUser(ctx, userId);
const project = await ctx.db.get(projectId);
if (!project || project.organizationId !== organizationId) {
return null;
}
return toProjectView(ctx, project);
},
});
// ---------------------------------------------------------------------------
// Mutation store — transactional ctx.db writes
// ---------------------------------------------------------------------------
export const makeProjectMutationStore = (ctx: MutationCtx) => ({
putContext: async (
userId: string,
projectId: Id<"projects">,
write: ContextWrite
): Promise<{ revision: number }> => {
const { organizationId } = await resolveProjectOrg(ctx, projectId, userId);
const path = contextPathForKind(write.kind);
const existing = await ctx.db
.query("projectContextDocuments")
.withIndex("by_project_and_path", (q) =>
q.eq("projectId", projectId).eq("path", path)
)
.unique();
const result = await Effect.runPromise(
decideContextWrite({
existing: existing ? toContextDocumentState(existing) : undefined,
write,
})
);
const now = Date.now();
if (!result.changed) {
return { revision: result.document.revision };
}
if (existing) {
await ctx.db.patch(existing._id, {
content: result.document.content,
origin: result.document.origin,
revision: result.document.revision,
sourceUrl: result.document.sourceUrl,
updatedAt: now,
});
} else {
await ctx.db.insert("projectContextDocuments", {
content: result.document.content,
createdAt: now,
kind: result.document.kind,
organizationId,
origin: result.document.origin,
path: result.document.path,
projectId,
revision: result.document.revision,
sourceUrl: result.document.sourceUrl,
updatedAt: now,
});
}
return { revision: result.document.revision };
},
});
// ---------------------------------------------------------------------------
// Action store — calls narrowly scoped internal mutations/queries
// ---------------------------------------------------------------------------
export const makeProjectActionStore = (_ctx: ActionCtx) => ({
getCurrentOrganization: async (userId: string) => {
return userId;
},
listProjects: async (userId: string) => {
return userId;
},
getProject: async (userId: string, _projectId: Id<"projects">) => {
return userId;
},
persistPublicGitImport: async (
_userId: string,
_source: PreparedPublicGitSource,
_remote: PublicGitImportResult
): Promise<ProjectImportOutcome> => {
throw new Error(
"persistPublicGitImport must be called via internal mutation"
);
},
putContext: async (
_userId: string,
_projectId: Id<"projects">,
_write: ContextWrite
): Promise<{ revision: number }> => {
throw new Error("putContext must be called via internal mutation");
},
});
// We need Effect for decideContextWrite at runtime.
import { Effect } from "effect";
// ---------------------------------------------------------------------------
// Internal mutation store — the actual transactional persistence
// ---------------------------------------------------------------------------
export const persistPublicGitImportTransaction = async (
ctx: MutationCtx,
userId: string,
source: PreparedPublicGitSource,
remote: PublicGitImportResult
): Promise<ProjectImportOutcome> => {
const { organizationId } = await resolveOrgForUser(ctx, userId);
// Idempotency: lookup by normalizedUrl within the organization
const existingSource = await ctx.db
.query("projectSources")
.withIndex("by_organization_and_normalizedUrl", (q) =>
q
.eq("organizationId", organizationId)
.eq("normalizedUrl", source.normalizedUrl)
)
.unique();
const now = Date.now();
if (existingSource) {
const project = await ctx.db.get(existingSource.projectId);
if (!project) {
throw new Error("Project not found for existing source");
}
// Update source metadata
await ctx.db.patch(existingSource._id, {
defaultBranch: remote.defaultBranch,
updatedAt: now,
});
// Apply remote documents as repository writes
for (const doc of remote.documents) {
await applyRepositoryWrite(
ctx,
project._id,
organizationId,
doc,
source.url,
now
);
}
await ctx.db.patch(project._id, { updatedAt: now });
const updatedSource = await ctx.db.get(existingSource._id);
return toImportOutcome(ctx, project, updatedSource ?? existingSource);
}
// Create new project
const projectId = await ctx.db.insert("projects", {
createdAt: now,
name: source.projectName,
organizationId,
updatedAt: now,
});
// Create source
const sourceId = await ctx.db.insert("projectSources", {
createdAt: now,
defaultBranch: remote.defaultBranch,
host: source.host,
kind: "git",
normalizedUrl: source.normalizedUrl,
organizationId,
projectId,
repositoryPath: source.repositoryPath,
updatedAt: now,
url: source.url,
});
// Seed six canonical context documents
const seeds = makeInitialContext(source.projectName, source.url);
for (const seed of seeds) {
await ctx.db.insert("projectContextDocuments", {
content: seed.content,
createdAt: now,
kind: seed.kind,
organizationId,
origin: seed.origin,
path: seed.path,
projectId,
revision: seed.revision,
sourceUrl: seed.sourceUrl,
updatedAt: now,
});
}
// Apply remote documents (overrides seeds)
for (const doc of remote.documents) {
await applyRepositoryWrite(
ctx,
projectId,
organizationId,
doc,
source.url,
now
);
}
const createdProject = await ctx.db.get(projectId);
const createdSource = await ctx.db.get(sourceId);
if (!createdProject || !createdSource) {
throw new Error("Failed to read created project");
}
return toImportOutcome(ctx, createdProject, createdSource);
};
const applyRepositoryWrite = async (
ctx: MutationCtx,
projectId: Id<"projects">,
organizationId: Id<"organizations">,
doc: { kind: ContextKind; path: string; content: string },
sourceUrl: string,
now: number
) => {
const path = contextPathForKind(doc.kind);
const existing = await ctx.db
.query("projectContextDocuments")
.withIndex("by_project_and_path", (q) =>
q.eq("projectId", projectId).eq("path", path)
)
.unique();
const result = await Effect.runPromise(
decideContextWrite({
existing: existing ? toContextDocumentState(existing) : undefined,
write: {
_tag: "Repository" as const,
content: doc.content,
kind: doc.kind,
sourceUrl,
},
})
);
if (!result.changed) return;
if (existing) {
await ctx.db.patch(existing._id, {
content: result.document.content,
origin: result.document.origin,
revision: result.document.revision,
sourceUrl: result.document.sourceUrl,
updatedAt: now,
});
} else {
await ctx.db.insert("projectContextDocuments", {
content: result.document.content,
createdAt: now,
kind: result.document.kind,
organizationId,
origin: result.document.origin,
path: result.document.path,
projectId,
revision: result.document.revision,
sourceUrl: result.document.sourceUrl,
updatedAt: now,
});
}
};
// ---------------------------------------------------------------------------
// Organization resolvers
// ---------------------------------------------------------------------------
const resolveOrgForUser = async (
ctx: QueryCtx | MutationCtx,
userId: string
): Promise<{ organizationId: Id<"organizations"> }> => {
const organization = await ctx.db
.query("organizations")
.withIndex("by_createdBy_and_kind", (q) =>
q.eq("createdBy", userId).eq("kind", "personal")
)
.unique();
if (!organization) {
throw new Error("Organization not found");
}
return { organizationId: organization._id };
};
const resolveProjectOrg = async (
ctx: QueryCtx | MutationCtx,
projectId: Id<"projects">,
userId: string
): Promise<{ organizationId: Id<"organizations"> }> => {
const project = await ctx.db.get(projectId);
if (!project) {
throw new Error("Project not found");
}
const { organizationId } = await resolveOrgForUser(ctx, userId);
if (project.organizationId !== organizationId) {
throw new Error("Project not found");
}
return { organizationId };
};
// Re-export for convenience
export { CONTEXT_KINDS, requireCurrentOrganization, requireProjectMember };

View File

@@ -1,176 +1,228 @@
import { ConvexError, v } from "convex/values"; import {
import { z } from "zod"; preparePublicGitSource,
type ProjectImportOutcome,
type ProjectView,
type PublicGitImportResult,
type PreparedPublicGitSource,
} from "@code/primitives/project";
import { v } from "convex/values";
import { Effect } from "effect";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel"; import type { Id } from "./_generated/dataModel";
import { action, internalMutation, query } from "./_generated/server"; import { action, internalMutation, query } from "./_generated/server";
import { createInitialArtifacts } from "./artifactModel"; import { createInitialArtifacts } from "./artifactModel";
import { requireOwnerId } from "./authz"; import { requireCurrentOrganization } from "./authz";
import { persistPublicGitImportTransaction } from "./projectStore";
const gitHubRepositorySchema = z.object({ // ---------------------------------------------------------------------------
default_branch: z.string(), // Internal: persist a public Git import in one transaction
description: z.string().nullable(), // ---------------------------------------------------------------------------
html_url: z.url(),
id: z.number(),
name: z.string(),
owner: z.object({ login: z.string() }),
});
const parseRepository = (value: string): { owner: string; repo: string } => { export const persistPublicGitImport = internalMutation({
const normalized = value.trim().replace(/\.git$/u, "");
const match = normalized.match(
/^(?:https?:\/\/github\.com\/)?(?<owner>[^/\s]+)\/(?<repo>[^/\s]+)$/iu
);
if (!match?.groups?.owner || !match.groups.repo) {
throw new ConvexError("Use a GitHub repository in owner/name format");
}
return { owner: match.groups.owner, repo: match.groups.repo };
};
const readGitHubRepository = (value: unknown) => {
const result = gitHubRepositorySchema.safeParse(value);
if (!result.success) {
throw new ConvexError("GitHub returned incomplete repository metadata");
}
return {
defaultBranch: result.data.default_branch,
...(result.data.description === null
? {}
: { description: result.data.description }),
id: result.data.id,
name: result.data.name,
owner: result.data.owner.login,
url: result.data.html_url,
};
};
export const connectGitHub = action({
args: { repository: v.string() },
handler: async (ctx, args): Promise<Id<"projects">> => {
const ownerId = await requireOwnerId(ctx);
const { owner, repo } = parseRepository(args.repository);
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}`,
{
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "zopu-code",
"X-GitHub-Api-Version": "2022-11-28",
},
}
);
if (!response.ok) {
if (response.status === 404) {
throw new ConvexError(
"Repository not found. This first flow supports public GitHub repositories."
);
}
throw new ConvexError(`GitHub connection failed with ${response.status}`);
}
const repository = readGitHubRepository(await response.json());
const projectId: Id<"projects"> = await ctx.runMutation(
internal.projects.storeGitHubProject,
{ ownerId, repository }
);
return projectId;
},
});
export const storeGitHubProject = internalMutation({
args: { args: {
ownerId: v.string(), userId: v.string(),
repository: v.object({ source: v.object({
defaultBranch: v.string(), host: v.string(),
description: v.optional(v.string()), projectName: v.string(),
id: v.number(), repositoryPath: v.string(),
name: v.string(), normalizedUrl: v.string(),
owner: v.string(),
url: v.string(), url: v.string(),
}), }),
remote: v.object({
defaultBranch: v.optional(v.string()),
documents: v.array(
v.object({
kind: v.union(
v.literal("readme"),
v.literal("agents"),
v.literal("product"),
v.literal("business"),
v.literal("design"),
v.literal("tech")
),
path: v.string(),
content: v.string(),
})
),
warnings: v.array(
v.object({
path: v.string(),
message: v.string(),
})
),
}),
}, },
handler: async (ctx, args) => { handler: async (ctx, args): Promise<ProjectImportOutcome> => {
const existing = await ctx.db const result = await persistPublicGitImportTransaction(
.query("projects") ctx,
.withIndex("by_owner_and_repository", (q) => args.userId,
q args.source as PreparedPublicGitSource,
.eq("ownerId", args.ownerId) args.remote as PublicGitImportResult
.eq("provider", "github") );
.eq("repoOwner", args.repository.owner)
.eq("repoName", args.repository.name) // Seed operational artifacts on first creation (detected by checking
// existing artifacts)
const existingArtifacts = await ctx.db
.query("projectArtifacts")
.withIndex("by_project", (q) =>
q.eq("projectId", result.id as unknown as Id<"projects">)
) )
.unique(); .first();
const timestamp = Date.now(); if (!existingArtifacts) {
if (existing) { const now = Date.now();
await ctx.db.patch("projects", existing._id, { const artifacts = createInitialArtifacts();
defaultBranch: args.repository.defaultBranch, await Promise.all(
description: args.repository.description, artifacts.map(({ content, path }) =>
providerRepoId: args.repository.id, ctx.db.insert("projectArtifacts", {
repoUrl: args.repository.url, content,
updatedAt: timestamp, 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", { return result;
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;
}, },
}); });
// ---------------------------------------------------------------------------
// Internal: put a context document
// ---------------------------------------------------------------------------
export const putContextDocument = internalMutation({
args: {
userId: v.string(),
projectId: v.id("projects"),
write: v.union(
v.object({
_tag: v.literal("Repository"),
content: v.string(),
kind: v.union(
v.literal("readme"),
v.literal("agents"),
v.literal("product"),
v.literal("business"),
v.literal("design"),
v.literal("tech")
),
sourceUrl: v.string(),
}),
v.object({
_tag: v.literal("PublicTextUrl"),
content: v.string(),
kind: v.union(
v.literal("readme"),
v.literal("agents"),
v.literal("product"),
v.literal("business"),
v.literal("design"),
v.literal("tech")
),
sourceUrl: v.string(),
}),
v.object({
_tag: v.literal("UserText"),
content: v.string(),
kind: v.union(
v.literal("readme"),
v.literal("agents"),
v.literal("product"),
v.literal("business"),
v.literal("design"),
v.literal("tech")
),
origin: v.union(v.literal("paste"), v.literal("upload")),
})
),
},
handler: async (ctx, args) => {
const { makeProjectMutationStore } = await import("./projectStore");
const store = makeProjectMutationStore(ctx);
return store.putContext(args.userId, args.projectId, args.write as never);
},
});
// ---------------------------------------------------------------------------
// Public query: list projects
// ---------------------------------------------------------------------------
export const list = query({ export const list = query({
args: {}, args: {},
handler: async (ctx) => { handler: async (ctx): Promise<ProjectView[]> => {
const ownerId = await requireOwnerId(ctx); const { userId } = await requireCurrentOrganization(ctx);
return await ctx.db const { makeProjectQueryStore } = await import("./projectStore");
.query("projects") const store = makeProjectQueryStore(ctx);
.withIndex("by_owner_and_createdAt", (q) => q.eq("ownerId", ownerId)) return store.listProjects(userId);
.order("desc")
.take(50);
}, },
}); });
// ---------------------------------------------------------------------------
// Public query: get a project
// ---------------------------------------------------------------------------
export const get = query({ export const get = query({
args: { projectId: v.id("projects") }, args: { projectId: v.id("projects") },
handler: async (ctx, args) => { handler: async (ctx, args): Promise<ProjectView | null> => {
const ownerId = await requireOwnerId(ctx); const { userId } = await requireCurrentOrganization(ctx);
const project = await ctx.db.get("projects", args.projectId); const { makeProjectQueryStore } = await import("./projectStore");
return project?.ownerId === ownerId ? project : null; const store = makeProjectQueryStore(ctx);
return store.getProject(userId, args.projectId);
},
});
// ---------------------------------------------------------------------------
// Public action: import a public Git repository
// (daemon wiring added in the Daemon phase)
// ---------------------------------------------------------------------------
export const importPublicGit = action({
args: { repositoryUrl: v.string() },
handler: async (_ctx, args): Promise<ProjectImportOutcome> => {
// Normalize through the domain layer to validate the URL before the
// daemon adapter is wired.
await Effect.runPromise(preparePublicGitSource(args.repositoryUrl));
// The daemon PublicGit adapter is wired in the Daemon phase.
throw new Error("PublicGit import is not yet wired to the daemon adapter");
},
});
// ---------------------------------------------------------------------------
// Public mutation: put context text (paste/upload)
// ---------------------------------------------------------------------------
export const putText = internalMutation({
args: {
projectId: v.id("projects"),
kind: v.union(
v.literal("readme"),
v.literal("agents"),
v.literal("product"),
v.literal("business"),
v.literal("design"),
v.literal("tech")
),
content: v.string(),
origin: v.union(v.literal("paste"), v.literal("upload")),
},
handler: async (ctx, args) => {
const { userId } = await requireCurrentOrganization(ctx);
const { makeProjectMutationStore } = await import("./projectStore");
const store = makeProjectMutationStore(ctx);
return store.putContext(userId, args.projectId, {
_tag: "UserText" as const,
content: args.content,
kind: args.kind,
origin: args.origin,
});
}, },
}); });

View File

@@ -63,25 +63,57 @@ export default defineSchema({
.index("by_daemonId_and_createdAt", ["daemonId", "createdAt"]) .index("by_daemonId_and_createdAt", ["daemonId", "createdAt"])
.index("by_commandId_and_createdAt", ["commandId", "createdAt"]), .index("by_commandId_and_createdAt", ["commandId", "createdAt"]),
projects: defineTable({ projects: defineTable({
ownerId: v.string(), organizationId: v.id("organizations"),
provider: v.literal("github"),
providerRepoId: v.number(),
name: v.string(), name: v.string(),
description: v.optional(v.string()), description: v.optional(v.string()),
repoOwner: v.string(), createdAt: v.number(),
repoName: v.string(), updatedAt: v.number(),
repoUrl: v.string(), }).index("by_organization_and_createdAt", ["organizationId", "createdAt"]),
defaultBranch: v.string(), projectSources: defineTable({
organizationId: v.id("organizations"),
projectId: v.id("projects"),
kind: v.literal("git"),
url: v.string(),
normalizedUrl: v.string(),
host: v.string(),
repositoryPath: v.string(),
defaultBranch: v.optional(v.string()),
createdAt: v.number(), createdAt: v.number(),
updatedAt: v.number(), updatedAt: v.number(),
}) })
.index("by_owner_and_createdAt", ["ownerId", "createdAt"]) .index("by_project", ["projectId"])
.index("by_owner_and_repository", [ .index("by_organization_and_normalizedUrl", [
"ownerId", "organizationId",
"provider", "normalizedUrl",
"repoOwner", ])
"repoName", .index("by_organization_and_createdAt", ["organizationId", "createdAt"]),
]), projectContextDocuments: defineTable({
organizationId: v.id("organizations"),
projectId: v.id("projects"),
kind: v.union(
v.literal("readme"),
v.literal("agents"),
v.literal("product"),
v.literal("business"),
v.literal("design"),
v.literal("tech")
),
path: v.string(),
content: v.string(),
revision: v.number(),
origin: v.union(
v.literal("repository"),
v.literal("paste"),
v.literal("upload"),
v.literal("public-url")
),
sourceUrl: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_project", ["projectId"])
.index("by_project_and_kind", ["projectId", "kind"])
.index("by_project_and_path", ["projectId", "path"]),
projectArtifacts: defineTable({ projectArtifacts: defineTable({
projectId: v.id("projects"), projectId: v.id("projects"),
path: v.string(), path: v.string(),
@@ -165,6 +197,7 @@ export default defineSchema({
createdAt: v.number(), createdAt: v.number(),
}) })
.index("by_userId", ["userId"]) .index("by_userId", ["userId"])
.index("by_organizationId", ["organizationId"])
.index("by_organizationId_and_userId", ["organizationId", "userId"]), .index("by_organizationId_and_userId", ["organizationId", "userId"]),
// ----------------------------------------------------------------- // -----------------------------------------------------------------

View File

@@ -44,14 +44,6 @@ const problemStatement = {
const processedBy = { agentInstanceId: "zopu-instance-1", agentName: "zopu" }; const processedBy = { agentInstanceId: "zopu-instance-1", agentName: "zopu" };
const repository = {
defaultBranch: "main",
id: 12345,
name: "zopu",
owner: "puter",
url: "https://github.com/puter/zopu",
};
// Begin and admit one user message, returning its messageId. // Begin and admit one user message, returning its messageId.
const seedMessage = async ( const seedMessage = async (
t: TestConvex<typeof schema>, t: TestConvex<typeof schema>,
@@ -79,13 +71,37 @@ const seedMessage = async (
const createProject = async ( const createProject = async (
t: TestConvex<typeof schema>, t: TestConvex<typeof schema>,
ownerId: string, ownerId: string
repoId = 12345
): Promise<Id<"projects">> => { ): Promise<Id<"projects">> => {
return await t.mutation(internal.projects.storeGitHubProject, { // Resolve or create the owner's personal organization
ownerId, const ownerIdentity = { tokenIdentifier: ownerId };
repository: { ...repository, id: repoId }, await t
}); .withIdentity(ownerIdentity)
.mutation(api.organizations.ensurePersonalOrganization);
const outcome = await t
.withIdentity(ownerIdentity)
.mutation(internal.projects.persistPublicGitImport, {
userId: ownerId,
source: {
host: "github.com",
projectName: "test-repo",
repositoryPath: `owner-${ownerId.slice(-4)}/test-repo`,
normalizedUrl: `https://github.com/owner-${ownerId.slice(-4)}/test-repo`,
url: `https://github.com/owner-${ownerId.slice(-4)}/test-repo`,
},
remote: {
defaultBranch: "main",
documents: [
{
kind: "readme" as const,
path: "README.md",
content: "# test-repo\n\nTest.\n",
},
],
warnings: [],
},
});
return outcome.id as unknown as Id<"projects">;
}; };
describe("signals", () => { describe("signals", () => {

View File

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

View File

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

View File

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

View File

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

View File

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