feat: Projects backend slice — auth, primitives, backend, UI (#3)
This commit is contained in:
@@ -7,7 +7,6 @@ import {
|
||||
ExternalLink,
|
||||
FileText,
|
||||
GitFork,
|
||||
GitPullRequest,
|
||||
Play,
|
||||
Plus,
|
||||
} 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",
|
||||
failed: "border-destructive/40 bg-destructive/10 text-destructive",
|
||||
"needs-input":
|
||||
"border-orange-500/40 bg-orange-500/10 text-orange-700 dark:text-orange-300",
|
||||
open: "border-border text-muted-foreground",
|
||||
queued:
|
||||
"border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
open: "border-border bg-muted/40 text-muted-foreground",
|
||||
queued: "border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-300",
|
||||
working: "border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-300",
|
||||
};
|
||||
|
||||
interface RepositoryFormProps {
|
||||
readonly busy: boolean;
|
||||
readonly onChange: (value: string) => void;
|
||||
readonly onSubmit: () => Promise<void>;
|
||||
readonly onSubmit: () => void;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
@@ -41,36 +39,46 @@ const RepositoryForm = ({
|
||||
value,
|
||||
}: RepositoryFormProps) => (
|
||||
<form
|
||||
className="grid gap-3 border-b p-4"
|
||||
className="space-y-2 border-b p-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium" htmlFor="github-repository">
|
||||
Public GitHub repository
|
||||
<label className="text-xs font-medium" htmlFor="git-repository">
|
||||
Public Git repository
|
||||
</label>
|
||||
<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>
|
||||
</div>
|
||||
<Input
|
||||
id="github-repository"
|
||||
id="git-repository"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder="rivet-dev/rivet"
|
||||
placeholder="https://github.com/owner/repo"
|
||||
required
|
||||
value={value}
|
||||
/>
|
||||
<Button disabled={busy || value.trim().length === 0} type="submit">
|
||||
<Button className="w-full" disabled={busy} type="submit">
|
||||
<GitFork data-icon="inline-start" />
|
||||
{busy ? "Connecting" : "Connect repository"}
|
||||
{busy ? "Importing" : "Import repository"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
interface ProjectListProps {
|
||||
readonly projects: readonly Doc<"projects">[] | undefined;
|
||||
readonly projects:
|
||||
| readonly {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly sources: readonly {
|
||||
readonly host: string;
|
||||
readonly repositoryPath: string;
|
||||
readonly url: string;
|
||||
}[];
|
||||
}[]
|
||||
| undefined;
|
||||
readonly selectedId: Id<"projects"> | null;
|
||||
readonly onSelect: (projectId: Id<"projects">) => void;
|
||||
}
|
||||
@@ -85,7 +93,7 @@ const ProjectList = ({ projects, selectedId, onSelect }: ProjectListProps) => {
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -94,19 +102,20 @@ const ProjectList = ({ projects, selectedId, onSelect }: ProjectListProps) => {
|
||||
{projects.map((project) => (
|
||||
<button
|
||||
className={`w-full border px-3 py-2 text-left transition-colors ${
|
||||
selectedId === project._id
|
||||
selectedId === project.id
|
||||
? "border-foreground/30 bg-muted"
|
||||
: "border-transparent hover:bg-muted/60"
|
||||
}`}
|
||||
key={project._id}
|
||||
onClick={() => onSelect(project._id)}
|
||||
key={project.id}
|
||||
onClick={() => onSelect(project.id as Id<"projects">)}
|
||||
type="button"
|
||||
>
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{project.name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{project.repoOwner}/{project.repoName}
|
||||
{project.sources[0]?.host}
|
||||
{project.sources[0]?.repositoryPath}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -128,40 +137,34 @@ interface ArtifactGridProps {
|
||||
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) => (
|
||||
<section aria-labelledby="artifact-heading" className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold" id="artifact-heading">
|
||||
Project artifacts
|
||||
</h2>
|
||||
<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>
|
||||
)}
|
||||
<h2 id="artifact-heading" className="text-sm font-semibold">
|
||||
Project artifacts
|
||||
</h2>
|
||||
{renderArtifacts(artifacts)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -169,7 +172,7 @@ interface IssueComposerProps {
|
||||
readonly body: string;
|
||||
readonly busy: boolean;
|
||||
readonly onBodyChange: (value: string) => void;
|
||||
readonly onSubmit: () => Promise<void>;
|
||||
readonly onSubmit: () => void;
|
||||
readonly onTitleChange: (value: string) => void;
|
||||
readonly title: string;
|
||||
}
|
||||
@@ -183,47 +186,27 @@ const IssueComposer = ({
|
||||
title,
|
||||
}: IssueComposerProps) => (
|
||||
<form
|
||||
className="grid gap-3 border p-4"
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 className="font-semibold">Raise an issue</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The issue becomes the identity for one Flue agent and AgentOS workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="issue-title">
|
||||
Title
|
||||
</label>
|
||||
<Input
|
||||
id="issue-title"
|
||||
maxLength={160}
|
||||
onChange={(event) => onTitleChange(event.target.value)}
|
||||
required
|
||||
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"
|
||||
>
|
||||
<h2 className="text-sm font-semibold">New issue</h2>
|
||||
<Input
|
||||
onChange={(event) => onTitleChange(event.target.value)}
|
||||
placeholder="Issue title"
|
||||
required
|
||||
value={title}
|
||||
/>
|
||||
<Textarea
|
||||
onChange={(event) => onBodyChange(event.target.value)}
|
||||
placeholder="Describe the issue..."
|
||||
required
|
||||
rows={4}
|
||||
value={body}
|
||||
/>
|
||||
<Button className="w-full" disabled={busy} type="submit">
|
||||
<Plus data-icon="inline-start" />
|
||||
{busy ? "Creating" : "Create issue"}
|
||||
</Button>
|
||||
@@ -237,89 +220,53 @@ interface IssueListProps {
|
||||
issueId: Id<"projectIssues">,
|
||||
issueNumber: number,
|
||||
title: string
|
||||
) => Promise<void>;
|
||||
) => void;
|
||||
}
|
||||
|
||||
const IssueList = ({ issues, pendingAction, onStart }: IssueListProps) => {
|
||||
const renderContent = () => {
|
||||
if (issues === undefined) {
|
||||
return <p className="text-sm text-muted-foreground">Loading issues...</p>;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
if (issues === undefined) {
|
||||
return <p className="text-xs text-muted-foreground">Loading issues...</p>;
|
||||
}
|
||||
if (issues.length === 0) {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{issues.map((issue) => {
|
||||
const canStart = ["open", "needs-input", "failed"].includes(
|
||||
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>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No issues yet. Create one to start agent work.
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
}
|
||||
return (
|
||||
<section aria-labelledby="issue-heading" className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitPullRequest className="size-4 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold" id="issue-heading">
|
||||
Issue queue
|
||||
</h2>
|
||||
</div>
|
||||
{renderContent()}
|
||||
</section>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-semibold">Issues</h2>
|
||||
{issues.map((issue) => (
|
||||
<div className="border p-3" key={issue._id}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
#{issue.number} · {issue.title}
|
||||
</span>
|
||||
<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 handleIssueTitleChange = workspace.setIssueTitle;
|
||||
const handleIssueStart = workspace.startIssue;
|
||||
const projectSource = workspace.selectedProject?.sources[0];
|
||||
|
||||
return (
|
||||
<main className="min-h-svh bg-background text-foreground">
|
||||
@@ -344,7 +292,7 @@ export const ProjectWorkspacePage = () => {
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Project manager</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
GitHub, Flue, AgentOS
|
||||
Git · Flue · AgentOS
|
||||
</p>
|
||||
</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>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connected GitHub project
|
||||
Connected repository
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{workspace.selectedProject.repoOwner}/
|
||||
{workspace.selectedProject.repoName}
|
||||
{workspace.selectedProject.name}
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
{workspace.selectedProject.description ??
|
||||
"No repository description is available."}
|
||||
{projectSource?.host}/{projectSource?.repositoryPath}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.open(
|
||||
workspace.selectedProject?.repoUrl,
|
||||
projectSource?.url,
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
)
|
||||
@@ -402,7 +348,7 @@ export const ProjectWorkspacePage = () => {
|
||||
variant="outline"
|
||||
>
|
||||
<ExternalLink data-icon="inline-start" />
|
||||
Open GitHub
|
||||
Open repository
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -429,7 +375,7 @@ export const ProjectWorkspacePage = () => {
|
||||
<div className="max-w-sm space-y-2">
|
||||
<GitFork className="mx-auto size-7 text-muted-foreground" />
|
||||
<h1 className="text-xl font-semibold">
|
||||
Connect your first repository
|
||||
Import your first repository
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Zopu creates the project artifact set and opens an issue queue
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { authClient } from "@code/auth/web";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import { signOutWeb, useWebAuth } from "@code/auth/web";
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -10,12 +9,13 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@code/ui/components/dropdown-menu";
|
||||
import { useQuery } from "convex/react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export default function UserMenu() {
|
||||
const navigate = useNavigate();
|
||||
const user = useQuery(api.auth.getCurrentUser);
|
||||
const auth = useWebAuth();
|
||||
|
||||
const user = auth.status === "authenticated" ? auth.user : null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -29,14 +29,9 @@ export default function UserMenu() {
|
||||
<DropdownMenuItem>{user?.email}</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
navigate("/login", { replace: true });
|
||||
},
|
||||
},
|
||||
});
|
||||
onClick={async () => {
|
||||
await signOutWeb();
|
||||
navigate("/login", { replace: true });
|
||||
}}
|
||||
>
|
||||
Sign Out
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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 type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface OrganizationBootstrapState {
|
||||
readonly error?: Error;
|
||||
readonly organizationId?: Id<"organizations">;
|
||||
readonly sessionId: string;
|
||||
}
|
||||
|
||||
export interface PersonalOrganizationState {
|
||||
readonly error?: Error;
|
||||
readonly organizationId?: Id<"organizations">;
|
||||
}
|
||||
|
||||
/** Ensure the authenticated session has its personal tenancy boundary. */
|
||||
/** Ensure the authenticated user has its personal tenancy boundary. */
|
||||
export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
const { data: session } = authClient.useSession();
|
||||
const sessionId = session?.session.id;
|
||||
const auth = useWebAuth();
|
||||
const userId = auth.status === "authenticated" ? auth.user.id : null;
|
||||
const ensurePersonalOrganization = useMutation(
|
||||
api.organizations.ensurePersonalOrganization
|
||||
);
|
||||
const [bootstrap, setBootstrap] = useState<OrganizationBootstrapState | null>(
|
||||
null
|
||||
);
|
||||
const [organizationId, setOrganizationId] = useState<
|
||||
Id<"organizations"> | undefined
|
||||
>();
|
||||
const [error, setError] = useState<Error | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -36,20 +31,16 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
try {
|
||||
const organization = await ensurePersonalOrganization();
|
||||
if (active) {
|
||||
setBootstrap({
|
||||
organizationId: organization._id,
|
||||
sessionId,
|
||||
});
|
||||
setOrganizationId(organization._id);
|
||||
setError(undefined);
|
||||
}
|
||||
} catch (caughtError: unknown) {
|
||||
if (active) {
|
||||
setBootstrap({
|
||||
error:
|
||||
caughtError instanceof Error
|
||||
? caughtError
|
||||
: new Error(String(caughtError)),
|
||||
sessionId,
|
||||
});
|
||||
setError(
|
||||
caughtError instanceof Error
|
||||
? caughtError
|
||||
: new Error(String(caughtError))
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -58,13 +49,11 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [ensurePersonalOrganization, sessionId]);
|
||||
}, [ensurePersonalOrganization, userId]);
|
||||
|
||||
if (!sessionId || bootstrap?.sessionId !== sessionId) {
|
||||
if (!userId) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
error: bootstrap.error,
|
||||
organizationId: bootstrap.organizationId,
|
||||
};
|
||||
|
||||
return { error, organizationId };
|
||||
};
|
||||
|
||||
@@ -17,11 +17,14 @@ export const useProjectWorkspace = () => {
|
||||
const [issueBody, setIssueBody] = useState("");
|
||||
const [pendingAction, setPendingAction] = 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 beginIssue = useMutation(api.projectIssues.begin);
|
||||
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(
|
||||
api.projectArtifacts.list,
|
||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||
@@ -35,8 +38,8 @@ export const useProjectWorkspace = () => {
|
||||
setPendingAction("connect");
|
||||
setError(null);
|
||||
try {
|
||||
const projectId = await connectGitHub({ repository });
|
||||
setSelectedProjectId(projectId);
|
||||
const outcome = await importPublicGit({ repositoryUrl: repository });
|
||||
setSelectedProjectId(outcome.id as unknown as Id<"projects">);
|
||||
setRepository("");
|
||||
} catch (caughtError) {
|
||||
setError(errorMessage(caughtError));
|
||||
@@ -87,9 +90,10 @@ export const useProjectWorkspace = () => {
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedProject =
|
||||
projects?.find((project) => project._id === activeProjectId) ?? null;
|
||||
projects?.find(
|
||||
(project) => project.id === (activeProjectId as unknown as string)
|
||||
) ?? null;
|
||||
|
||||
return {
|
||||
artifacts,
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { useConvexAuth } from "convex/react";
|
||||
import { useWebAuth } from "@code/auth/web";
|
||||
import { Navigate, Outlet, useLocation } from "react-router";
|
||||
|
||||
export default function AppLayout() {
|
||||
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||
const auth = useWebAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="grid min-h-svh place-items-center text-sm text-muted-foreground">Loading…</div>;
|
||||
if (auth.status === "loading") {
|
||||
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" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { useConvexAuth } from "convex/react";
|
||||
import { useWebAuth } from "@code/auth/web";
|
||||
import { Navigate, Outlet } from "react-router";
|
||||
|
||||
export default function AuthLayout() {
|
||||
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||
const auth = useWebAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="grid min-h-svh place-items-center text-sm text-muted-foreground">Loading…</div>;
|
||||
if (auth.status === "loading") {
|
||||
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 (
|
||||
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -281,7 +281,7 @@ Use explicit actions, never vague “continue”: **Approve plan; answer questio
|
||||
|
||||
```text
|
||||
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
|
||||
Expected duration: 20 minutes
|
||||
[Deploy preview]
|
||||
@@ -364,4 +364,4 @@ Within seconds, users can answer: **What should I focus on? What progressed with
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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. |
|
||||
| **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
|
||||
|
||||
@@ -161,7 +161,7 @@ Accumulates several kinds; each retains its scope (personal prefs, project facts
|
||||
|
||||
| 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. |
|
||||
| **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. |
|
||||
@@ -225,4 +225,4 @@ Not intended to be: a full external-tool replacement; a general social collabora
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
/.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
|
||||
/apps/
|
||||
```
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from "node:path";
|
||||
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineAgent } from "@flue/runtime";
|
||||
|
||||
// import { agentos } from "../sandboxes/agentos";
|
||||
import { local } from "@flue/runtime/node";
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./native": "./src/native/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./web": "./src/web/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { authComponent, createAuth } from "@code/backend/convex/auth";
|
||||
@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
|
||||
|
||||
import { authClient } from "./auth-client";
|
||||
|
||||
const convex = new ConvexReactClient(env.VITE_CONVEX_URL);
|
||||
const convex = new ConvexReactClient(env.VITE_CONVEX_URL, { expectAuth: true });
|
||||
|
||||
export const WebAuthProvider = ({ children }: { children: ReactNode }) => (
|
||||
<ConvexBetterAuthProvider authClient={authClient} client={convex}>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export { authClient } from "./auth-client";
|
||||
export { WebAuthProvider } from "./auth-provider";
|
||||
export { useConvexAccessToken } from "./hooks";
|
||||
export { signOutWeb, useWebAuth } from "./use-web-auth";
|
||||
export type { WebAuthState, WebAuthUser } from "./use-web-auth";
|
||||
export { LoginForm } from "./login-form";
|
||||
export { SignupForm } from "./signup-form";
|
||||
|
||||
81
packages/auth/src/web/use-web-auth.ts
Normal file
81
packages/auth/src/web/use-web-auth.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,9 +1,6 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
export const ARTIFACT_PATHS = [
|
||||
"project.md",
|
||||
"business.md",
|
||||
"design.md",
|
||||
"agent.md",
|
||||
"work.md",
|
||||
"steps.md",
|
||||
@@ -17,9 +14,6 @@ export const ARTIFACT_PATHS = [
|
||||
export type ArtifactPath = (typeof ARTIFACT_PATHS)[number];
|
||||
|
||||
export const artifactPath = v.union(
|
||||
v.literal("project.md"),
|
||||
v.literal("business.md"),
|
||||
v.literal("design.md"),
|
||||
v.literal("agent.md"),
|
||||
v.literal("work.md"),
|
||||
v.literal("steps.md"),
|
||||
@@ -30,71 +24,48 @@ export const artifactPath = v.union(
|
||||
v.literal("card.md")
|
||||
);
|
||||
|
||||
interface ProjectSeed {
|
||||
readonly defaultBranch: string;
|
||||
readonly description?: string;
|
||||
readonly name: string;
|
||||
readonly repoName: string;
|
||||
readonly repoOwner: string;
|
||||
readonly repoUrl: string;
|
||||
}
|
||||
|
||||
export const createInitialArtifacts = (
|
||||
project: ProjectSeed
|
||||
): readonly { path: ArtifactPath; content: string }[] => {
|
||||
const repository = `${project.repoOwner}/${project.repoName}`;
|
||||
const purpose = project.description ?? `Maintain and improve ${repository}.`;
|
||||
|
||||
return [
|
||||
{
|
||||
content: `# ${project.name}\n\nRepository: [${repository}](${project.repoUrl})\n\n## Project knowledge\n\n- [Business](business.md)\n- [Design](design.md)\n- [Agent](agent.md)\n\n## Delivery\n\n- [Work](work.md)\n- [Steps](steps.md)\n- [Artifacts](artifacts.md)\n`,
|
||||
path: "project.md",
|
||||
},
|
||||
{
|
||||
content: `# Business\n\n## Purpose\n\n${purpose}\n\n## Source of truth\n\nThe connected GitHub repository and its project issues define the current product work.\n`,
|
||||
path: "business.md",
|
||||
},
|
||||
{
|
||||
content: `# Design\n\n## Repository boundary\n\nWork targets \`${repository}\` on \`${project.defaultBranch}\`. Preserve its established architecture and conventions before introducing new patterns.\n\n## Decision record\n\nRecord issue-specific architecture decisions here when they affect later work.\n`,
|
||||
path: "design.md",
|
||||
},
|
||||
{
|
||||
content: `# Agent\n\n## Role\n\nOwn one project issue at a time. Read the project artifacts before editing code, ask a focused question when blocked, and support completion claims with command output.\n\n## Guardrails\n\n- Keep issue work isolated in its AgentOS workspace.\n- Update work.md, steps.md, artifacts.md, and context.md as facts change.\n- Never mark work complete without a relevant runtime check.\n`,
|
||||
path: "agent.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Work\n\nNo issue has entered the agent queue yet. New issues are appended here by the control plane.\n",
|
||||
path: "work.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Steps\n\n1. Read the issue and project artifacts.\n2. Inspect the repository context.\n3. Ask for missing decisions only when work cannot continue safely.\n4. Implement the smallest complete change.\n5. Run the relevant command or scenario.\n6. Publish changed artifacts and the final signal.\n",
|
||||
path: "steps.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Artifacts\n\nThis file indexes concrete outputs from issue runs, including changed paths, command results, and preview links.\n",
|
||||
path: "artifacts.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Project events\n\nThe agent manager emits `issue.queued`, `agent.started`, `agent.needs-input`, `agent.completed`, and `agent.failed`. These are durable project events and drive the web status.\n",
|
||||
path: "signals.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Agent manager\n\n## State machine\n\n`open -> queued -> working -> completed`\n\nA blocked run moves from `working` to `needs-input`; a resumed run returns to `queued`. An unrecoverable run moves to `failed`.\n\nEach issue owns one Flue agent identity and one AgentOS actor key, so conversation and workspace state remain isolated.\n",
|
||||
path: "agent-manager.md",
|
||||
},
|
||||
{
|
||||
content: `# Context\n\n- Provider: GitHub\n- Repository: ${repository}\n- URL: ${project.repoUrl}\n- Default branch: ${project.defaultBranch}\n\nIssue-specific facts belong below this repository context.\n`,
|
||||
path: "context.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Project card\n\nThe web project card is the interface between UI and packages. It reads `projects.list`, `project-artifacts.list`, and `project-issues.list`; it writes through `projects.connectGitHub`, `project-issues.create`, and `project-issues.begin`. The issue-scoped Flue agent reads `agent-workspace.get`, publishes with `agent-workspace.updateArtifact`, and reports state with `agent-workspace.setStatus`. Filesystem and shell operations are executed by the AgentOS sandbox adapter through durable daemon commands.\n",
|
||||
path: "card.md",
|
||||
},
|
||||
];
|
||||
};
|
||||
export const createInitialArtifacts = (): readonly {
|
||||
path: ArtifactPath;
|
||||
content: string;
|
||||
}[] => [
|
||||
{
|
||||
content:
|
||||
"# Agent\n\n## Role\n\nOwn one project issue at a time. Read the project artifacts before editing code, ask a focused question when blocked, and support completion claims with command output.\n\n## Guardrails\n\n- Keep issue work isolated in its AgentOS workspace.\n- Update work.md, steps.md, artifacts.md, and context.md as facts change.\n- Never mark work complete without a relevant runtime check.\n",
|
||||
path: "agent.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Work\n\nNo issue has entered the agent queue yet. New issues are appended here by the control plane.\n",
|
||||
path: "work.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Steps\n\n1. Read the issue and project artifacts.\n2. Inspect the repository context.\n3. Ask for missing decisions only when work cannot continue safely.\n4. Implement the smallest complete change.\n5. Run the relevant command or scenario.\n6. Publish changed artifacts and the final signal.\n",
|
||||
path: "steps.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Artifacts\n\nThis file indexes concrete outputs from issue runs, including changed paths, command results, and preview links.\n",
|
||||
path: "artifacts.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Project events\n\nThe agent manager emits `issue.queued`, `agent.started`, `agent.needs-input`, `agent.completed`, and `agent.failed`. These are durable project events and drive the web status.\n",
|
||||
path: "signals.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Agent manager\n\n## State machine\n\n`open -> queued -> working -> completed`\n\nA blocked run moves from `working` to `needs-input`; a resumed run returns to `queued`. An unrecoverable run moves to `failed`.\n\nEach issue owns one Flue agent identity and one AgentOS actor key, so conversation and workspace state remain isolated.\n",
|
||||
path: "agent-manager.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Context\n\nCanonical project knowledge is staged under /workspace/context. Issue-specific facts belong below.\n",
|
||||
path: "context.md",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"# Project card\n\nThe web project workspace reads and writes Projects, context documents, artifacts, and issues through the generated Convex interface. Agent work uses the issue-scoped workspace and durable daemon commands.\n",
|
||||
path: "card.md",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -36,7 +36,33 @@ const createAuth = (ctx: GenericCtx<DataModel>) =>
|
||||
|
||||
export { createAuth };
|
||||
|
||||
export interface AuthUserView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the authenticated user to the canonical web view. The returned `id`
|
||||
* is the Convex `identity.tokenIdentifier`, the stable authenticated identity
|
||||
* used across organization/conversation/Signal ownership. Returns null when no
|
||||
* identity is present or the Better Auth user row cannot be resolved.
|
||||
*/
|
||||
export const getCurrentUser = query({
|
||||
args: {},
|
||||
handler: (ctx) => authComponent.safeGetAuthUser(ctx),
|
||||
handler: async (ctx): Promise<AuthUserView | null> => {
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
if (!identity) {
|
||||
return null;
|
||||
}
|
||||
const authUser = await authComponent.safeGetAuthUser(ctx);
|
||||
if (!authUser) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
email: authUser.email,
|
||||
id: identity.tokenIdentifier,
|
||||
name: authUser.name,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface AuthContext {
|
||||
};
|
||||
}
|
||||
|
||||
export const requireOwnerId = async (ctx: AuthContext): Promise<string> => {
|
||||
export const requireAuthUserId = async (ctx: AuthContext): Promise<string> => {
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
if (!identity) {
|
||||
throw new ConvexError("Authentication required");
|
||||
@@ -28,7 +28,7 @@ export const requireOrganizationMember = async (
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
organizationId: Id<"organizations">
|
||||
): Promise<string> => {
|
||||
const userId = await requireOwnerId(ctx);
|
||||
const userId = await requireAuthUserId(ctx);
|
||||
const membership = await ctx.db
|
||||
.query("organizationMembers")
|
||||
.withIndex("by_organizationId_and_userId", (q) =>
|
||||
@@ -40,3 +40,41 @@ export const requireOrganizationMember = async (
|
||||
}
|
||||
return userId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the authenticated user's personal/current organization. Browsers
|
||||
* never submit organization IDs for Project commands; this resolves the
|
||||
* tenant boundary server-side through the authenticated identity.
|
||||
*/
|
||||
export const requireCurrentOrganization = async (
|
||||
ctx: QueryCtx | MutationCtx
|
||||
): Promise<{ organizationId: Id<"organizations">; userId: string }> => {
|
||||
const userId = await requireAuthUserId(ctx);
|
||||
const organization = await ctx.db
|
||||
.query("organizations")
|
||||
.withIndex("by_createdBy_and_kind", (q) =>
|
||||
q.eq("createdBy", userId).eq("kind", "personal")
|
||||
)
|
||||
.unique();
|
||||
if (!organization) {
|
||||
throw new ConvexError("Organization not found");
|
||||
}
|
||||
return { organizationId: organization._id, userId };
|
||||
};
|
||||
|
||||
/**
|
||||
* Prove the authenticated user is a member of the project's organization.
|
||||
* Projects are organization-scoped; unauthorized single-object operations
|
||||
* throw "Project not found" to avoid leaking existence.
|
||||
*/
|
||||
export const requireProjectMember = async (
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
projectId: Id<"projects">
|
||||
): Promise<{ organizationId: Id<"organizations">; userId: string }> => {
|
||||
const project = await ctx.db.get(projectId);
|
||||
if (!project) {
|
||||
throw new ConvexError("Project not found");
|
||||
}
|
||||
const userId = await requireOrganizationMember(ctx, project.organizationId);
|
||||
return { organizationId: project.organizationId, userId };
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { requireOwnerId } from "./authz";
|
||||
import { requireAuthUserId } from "./authz";
|
||||
|
||||
interface OrganizationView {
|
||||
readonly _id: Id<"organizations">;
|
||||
@@ -31,7 +31,7 @@ const toView = (org: Doc<"organizations">): OrganizationView => ({
|
||||
export const ensurePersonalOrganization = mutation({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<OrganizationView> => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
const existing = await ctx.db
|
||||
.query("organizations")
|
||||
.withIndex("by_createdBy_and_kind", (q) =>
|
||||
@@ -70,7 +70,7 @@ export const ensurePersonalOrganization = mutation({
|
||||
export const getCurrent = query({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<OrganizationView | null> => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
const existing = await ctx.db
|
||||
.query("organizations")
|
||||
.withIndex("by_createdBy_and_kind", (q) =>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { query } from "./_generated/server";
|
||||
import { requireOwnerId } from "./authz";
|
||||
import { requireProjectMember } from "./authz";
|
||||
|
||||
export const list = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const project = await ctx.db.get("projects", args.projectId);
|
||||
if (!project || project.ownerId !== ownerId) {
|
||||
try {
|
||||
await requireProjectMember(ctx, args.projectId);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return await ctx.db
|
||||
|
||||
@@ -7,9 +7,6 @@ import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import schema from "./schema";
|
||||
|
||||
// `import.meta.glob` is provided by the Vitest/Vite test runner at runtime; it
|
||||
// has no type definition under the Convex tsconfig (which scopes `types` to
|
||||
// node), so declare the minimal shape the test relies on.
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
||||
@@ -19,26 +16,49 @@ declare global {
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
|
||||
// `agentWorkspace` gates agent-controlled mutations on `env.FLUE_DB_TOKEN`.
|
||||
// Vitest loads the repo `.env`, so resolve the real value from the env module
|
||||
// rather than hardcoding a constant that would mismatch the loaded secret.
|
||||
const FLUE_DB_TOKEN = env.FLUE_DB_TOKEN;
|
||||
|
||||
const ID_A = "https://convex.test|user-a";
|
||||
const identityA = { tokenIdentifier: ID_A };
|
||||
|
||||
const repository = {
|
||||
defaultBranch: "main",
|
||||
id: 12345,
|
||||
name: "zopu",
|
||||
owner: "puter",
|
||||
const SOURCE = {
|
||||
host: "github.com",
|
||||
projectName: "zopu",
|
||||
repositoryPath: "puter/zopu",
|
||||
normalizedUrl: "https://github.com/puter/zopu",
|
||||
url: "https://github.com/puter/zopu",
|
||||
};
|
||||
|
||||
// Read every projectEvents row for a project via the same indexes the control
|
||||
// plane writes through, returning them oldest-first. Reads MUST go through the
|
||||
// same test instance that performed the writes; a fresh `convexTest()` is an
|
||||
// isolated in-memory backend with no shared state.
|
||||
const REMOTE = {
|
||||
defaultBranch: "main",
|
||||
documents: [
|
||||
{
|
||||
kind: "readme" as const,
|
||||
path: "README.md",
|
||||
content: "# zopu\n\nRepository: https://github.com/puter/zopu\n",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const createTestProject = async (
|
||||
t: TestConvex<typeof schema>
|
||||
): Promise<Id<"projects">> => {
|
||||
// Ensure personal organization for the user
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization);
|
||||
// Persist the public Git import
|
||||
const outcome = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
userId: ID_A,
|
||||
source: SOURCE,
|
||||
remote: REMOTE,
|
||||
});
|
||||
return outcome.id as unknown as Id<"projects">;
|
||||
};
|
||||
|
||||
const eventsForProject = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
projectId: Id<"projects">
|
||||
@@ -56,10 +76,7 @@ const eventsForProject = async (
|
||||
describe("projectEvents", () => {
|
||||
test("project connect emits a project.connected event", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
|
||||
ownerId: ID_A,
|
||||
repository,
|
||||
});
|
||||
const projectId = await createTestProject(t);
|
||||
|
||||
const events = await eventsForProject(t, projectId);
|
||||
expect(events).toHaveLength(1);
|
||||
@@ -67,18 +84,11 @@ describe("projectEvents", () => {
|
||||
expect(events[0].projectId).toBe(projectId);
|
||||
expect(events[0].issueId).toBeUndefined();
|
||||
expect(events[0].runId).toBeUndefined();
|
||||
expect(events[0].data).toMatchObject({
|
||||
provider: "github",
|
||||
repository: repository.url,
|
||||
});
|
||||
});
|
||||
|
||||
test("issue create and begin emit issue.created then issue.queued events", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
|
||||
ownerId: ID_A,
|
||||
repository,
|
||||
});
|
||||
const projectId = await createTestProject(t);
|
||||
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
@@ -119,10 +129,7 @@ describe("projectEvents", () => {
|
||||
|
||||
test("artifact update emits an artifact.updated event", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
|
||||
ownerId: ID_A,
|
||||
repository,
|
||||
});
|
||||
const projectId = await createTestProject(t);
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
@@ -149,10 +156,7 @@ describe("projectEvents", () => {
|
||||
|
||||
test("agent status emits an agent.<status> event linked to the run", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
|
||||
ownerId: ID_A,
|
||||
repository,
|
||||
});
|
||||
const projectId = await createTestProject(t);
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
@@ -192,10 +196,7 @@ describe("projectEvents", () => {
|
||||
|
||||
test("wrong agent token is rejected before any event is written", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await t.mutation(internal.projects.storeGitHubProject, {
|
||||
ownerId: ID_A,
|
||||
repository,
|
||||
});
|
||||
const projectId = await createTestProject(t);
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
import { ConvexError, v } from "convex/values";
|
||||
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
import { requireOwnerId } from "./authz";
|
||||
|
||||
const requireProjectOwner = async (
|
||||
ctx: MutationCtx,
|
||||
projectId: Id<"projects">,
|
||||
ownerId: string
|
||||
) => {
|
||||
const project = await ctx.db.get("projects", projectId);
|
||||
if (!project || project.ownerId !== ownerId) {
|
||||
throw new ConvexError("Project not found");
|
||||
}
|
||||
return project;
|
||||
};
|
||||
import { requireProjectMember } from "./authz";
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
@@ -24,8 +10,7 @@ export const create = mutation({
|
||||
title: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
await requireProjectOwner(ctx, args.projectId, ownerId);
|
||||
await requireProjectMember(ctx, args.projectId);
|
||||
const title = args.title.trim();
|
||||
const body = args.body.trim();
|
||||
if (title.length < 3 || title.length > 160) {
|
||||
@@ -81,12 +66,11 @@ export const create = mutation({
|
||||
export const begin = mutation({
|
||||
args: { issueId: v.id("projectIssues") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
await requireProjectOwner(ctx, issue.projectId, ownerId);
|
||||
await requireProjectMember(ctx, issue.projectId);
|
||||
if (issue.status === "queued" || issue.status === "working") {
|
||||
return issue.status;
|
||||
}
|
||||
@@ -109,12 +93,11 @@ export const begin = mutation({
|
||||
export const markDispatchFailed = mutation({
|
||||
args: { error: v.string(), issueId: v.id("projectIssues") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
await requireProjectOwner(ctx, issue.projectId, ownerId);
|
||||
await requireProjectMember(ctx, issue.projectId);
|
||||
const timestamp = Date.now();
|
||||
await ctx.db.patch("projectIssues", issue._id, {
|
||||
status: "failed",
|
||||
@@ -133,9 +116,9 @@ export const markDispatchFailed = mutation({
|
||||
export const list = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const project = await ctx.db.get("projects", args.projectId);
|
||||
if (!project || project.ownerId !== ownerId) {
|
||||
try {
|
||||
await requireProjectMember(ctx, args.projectId);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return await ctx.db
|
||||
|
||||
430
packages/backend/convex/projectStore.ts
Normal file
430
packages/backend/convex/projectStore.ts
Normal 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 };
|
||||
@@ -1,176 +1,228 @@
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
preparePublicGitSource,
|
||||
type ProjectImportOutcome,
|
||||
type ProjectView,
|
||||
type PublicGitImportResult,
|
||||
type PreparedPublicGitSource,
|
||||
} from "@code/primitives/project";
|
||||
import { v } from "convex/values";
|
||||
import { Effect } from "effect";
|
||||
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { action, internalMutation, query } from "./_generated/server";
|
||||
import { createInitialArtifacts } from "./artifactModel";
|
||||
import { requireOwnerId } from "./authz";
|
||||
import { requireCurrentOrganization } from "./authz";
|
||||
import { persistPublicGitImportTransaction } from "./projectStore";
|
||||
|
||||
const gitHubRepositorySchema = z.object({
|
||||
default_branch: z.string(),
|
||||
description: z.string().nullable(),
|
||||
html_url: z.url(),
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
owner: z.object({ login: z.string() }),
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: persist a public Git import in one transaction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const parseRepository = (value: string): { owner: string; repo: string } => {
|
||||
const normalized = value.trim().replace(/\.git$/u, "");
|
||||
const match = normalized.match(
|
||||
/^(?:https?:\/\/github\.com\/)?(?<owner>[^/\s]+)\/(?<repo>[^/\s]+)$/iu
|
||||
);
|
||||
if (!match?.groups?.owner || !match.groups.repo) {
|
||||
throw new ConvexError("Use a GitHub repository in owner/name format");
|
||||
}
|
||||
return { owner: match.groups.owner, repo: match.groups.repo };
|
||||
};
|
||||
|
||||
const readGitHubRepository = (value: unknown) => {
|
||||
const result = gitHubRepositorySchema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw new ConvexError("GitHub returned incomplete repository metadata");
|
||||
}
|
||||
return {
|
||||
defaultBranch: result.data.default_branch,
|
||||
...(result.data.description === null
|
||||
? {}
|
||||
: { description: result.data.description }),
|
||||
id: result.data.id,
|
||||
name: result.data.name,
|
||||
owner: result.data.owner.login,
|
||||
url: result.data.html_url,
|
||||
};
|
||||
};
|
||||
|
||||
export const connectGitHub = action({
|
||||
args: { repository: v.string() },
|
||||
handler: async (ctx, args): Promise<Id<"projects">> => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const { owner, repo } = parseRepository(args.repository);
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "zopu-code",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new ConvexError(
|
||||
"Repository not found. This first flow supports public GitHub repositories."
|
||||
);
|
||||
}
|
||||
throw new ConvexError(`GitHub connection failed with ${response.status}`);
|
||||
}
|
||||
const repository = readGitHubRepository(await response.json());
|
||||
const projectId: Id<"projects"> = await ctx.runMutation(
|
||||
internal.projects.storeGitHubProject,
|
||||
{ ownerId, repository }
|
||||
);
|
||||
return projectId;
|
||||
},
|
||||
});
|
||||
|
||||
export const storeGitHubProject = internalMutation({
|
||||
export const persistPublicGitImport = internalMutation({
|
||||
args: {
|
||||
ownerId: v.string(),
|
||||
repository: v.object({
|
||||
defaultBranch: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
id: v.number(),
|
||||
name: v.string(),
|
||||
owner: v.string(),
|
||||
userId: v.string(),
|
||||
source: v.object({
|
||||
host: v.string(),
|
||||
projectName: v.string(),
|
||||
repositoryPath: v.string(),
|
||||
normalizedUrl: v.string(),
|
||||
url: v.string(),
|
||||
}),
|
||||
remote: v.object({
|
||||
defaultBranch: v.optional(v.string()),
|
||||
documents: v.array(
|
||||
v.object({
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
path: v.string(),
|
||||
content: v.string(),
|
||||
})
|
||||
),
|
||||
warnings: v.array(
|
||||
v.object({
|
||||
path: v.string(),
|
||||
message: v.string(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_owner_and_repository", (q) =>
|
||||
q
|
||||
.eq("ownerId", args.ownerId)
|
||||
.eq("provider", "github")
|
||||
.eq("repoOwner", args.repository.owner)
|
||||
.eq("repoName", args.repository.name)
|
||||
handler: async (ctx, args): Promise<ProjectImportOutcome> => {
|
||||
const result = await persistPublicGitImportTransaction(
|
||||
ctx,
|
||||
args.userId,
|
||||
args.source as PreparedPublicGitSource,
|
||||
args.remote as PublicGitImportResult
|
||||
);
|
||||
|
||||
// Seed operational artifacts on first creation (detected by checking
|
||||
// existing artifacts)
|
||||
const existingArtifacts = await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project", (q) =>
|
||||
q.eq("projectId", result.id as unknown as Id<"projects">)
|
||||
)
|
||||
.unique();
|
||||
const timestamp = Date.now();
|
||||
if (existing) {
|
||||
await ctx.db.patch("projects", existing._id, {
|
||||
defaultBranch: args.repository.defaultBranch,
|
||||
description: args.repository.description,
|
||||
providerRepoId: args.repository.id,
|
||||
repoUrl: args.repository.url,
|
||||
updatedAt: timestamp,
|
||||
.first();
|
||||
if (!existingArtifacts) {
|
||||
const now = Date.now();
|
||||
const artifacts = createInitialArtifacts();
|
||||
await Promise.all(
|
||||
artifacts.map(({ content, path }) =>
|
||||
ctx.db.insert("projectArtifacts", {
|
||||
content,
|
||||
createdAt: now,
|
||||
path,
|
||||
projectId: result.id as unknown as Id<"projects">,
|
||||
revision: 1,
|
||||
updatedAt: now,
|
||||
})
|
||||
)
|
||||
);
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: now,
|
||||
data: { host: result.source.host, url: result.source.url },
|
||||
kind: "project.connected",
|
||||
projectId: result.id as unknown as Id<"projects">,
|
||||
});
|
||||
return existing._id;
|
||||
}
|
||||
|
||||
const projectId = await ctx.db.insert("projects", {
|
||||
createdAt: timestamp,
|
||||
defaultBranch: args.repository.defaultBranch,
|
||||
description: args.repository.description,
|
||||
name: args.repository.name,
|
||||
ownerId: args.ownerId,
|
||||
provider: "github",
|
||||
providerRepoId: args.repository.id,
|
||||
repoName: args.repository.name,
|
||||
repoOwner: args.repository.owner,
|
||||
repoUrl: args.repository.url,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
const artifacts = createInitialArtifacts({
|
||||
defaultBranch: args.repository.defaultBranch,
|
||||
description: args.repository.description,
|
||||
name: args.repository.name,
|
||||
repoName: args.repository.name,
|
||||
repoOwner: args.repository.owner,
|
||||
repoUrl: args.repository.url,
|
||||
});
|
||||
await Promise.all(
|
||||
artifacts.map(({ content, path }) =>
|
||||
ctx.db.insert("projectArtifacts", {
|
||||
content,
|
||||
createdAt: timestamp,
|
||||
path,
|
||||
projectId,
|
||||
revision: 1,
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
)
|
||||
);
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data: { provider: "github", repository: args.repository.url },
|
||||
kind: "project.connected",
|
||||
projectId,
|
||||
});
|
||||
return projectId;
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: put a context document
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const putContextDocument = internalMutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
projectId: v.id("projects"),
|
||||
write: v.union(
|
||||
v.object({
|
||||
_tag: v.literal("Repository"),
|
||||
content: v.string(),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
sourceUrl: v.string(),
|
||||
}),
|
||||
v.object({
|
||||
_tag: v.literal("PublicTextUrl"),
|
||||
content: v.string(),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
sourceUrl: v.string(),
|
||||
}),
|
||||
v.object({
|
||||
_tag: v.literal("UserText"),
|
||||
content: v.string(),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
origin: v.union(v.literal("paste"), v.literal("upload")),
|
||||
})
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { makeProjectMutationStore } = await import("./projectStore");
|
||||
const store = makeProjectMutationStore(ctx);
|
||||
return store.putContext(args.userId, args.projectId, args.write as never);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public query: list projects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const list = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
return await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_owner_and_createdAt", (q) => q.eq("ownerId", ownerId))
|
||||
.order("desc")
|
||||
.take(50);
|
||||
handler: async (ctx): Promise<ProjectView[]> => {
|
||||
const { userId } = await requireCurrentOrganization(ctx);
|
||||
const { makeProjectQueryStore } = await import("./projectStore");
|
||||
const store = makeProjectQueryStore(ctx);
|
||||
return store.listProjects(userId);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public query: get a project
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const get = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const project = await ctx.db.get("projects", args.projectId);
|
||||
return project?.ownerId === ownerId ? project : null;
|
||||
handler: async (ctx, args): Promise<ProjectView | null> => {
|
||||
const { userId } = await requireCurrentOrganization(ctx);
|
||||
const { makeProjectQueryStore } = await import("./projectStore");
|
||||
const store = makeProjectQueryStore(ctx);
|
||||
return store.getProject(userId, args.projectId);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public action: import a public Git repository
|
||||
// (daemon wiring added in the Daemon phase)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const importPublicGit = action({
|
||||
args: { repositoryUrl: v.string() },
|
||||
handler: async (_ctx, args): Promise<ProjectImportOutcome> => {
|
||||
// Normalize through the domain layer to validate the URL before the
|
||||
// daemon adapter is wired.
|
||||
await Effect.runPromise(preparePublicGitSource(args.repositoryUrl));
|
||||
// The daemon PublicGit adapter is wired in the Daemon phase.
|
||||
throw new Error("PublicGit import is not yet wired to the daemon adapter");
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public mutation: put context text (paste/upload)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const putText = internalMutation({
|
||||
args: {
|
||||
projectId: v.id("projects"),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
content: v.string(),
|
||||
origin: v.union(v.literal("paste"), v.literal("upload")),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireCurrentOrganization(ctx);
|
||||
const { makeProjectMutationStore } = await import("./projectStore");
|
||||
const store = makeProjectMutationStore(ctx);
|
||||
return store.putContext(userId, args.projectId, {
|
||||
_tag: "UserText" as const,
|
||||
content: args.content,
|
||||
kind: args.kind,
|
||||
origin: args.origin,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -63,25 +63,57 @@ export default defineSchema({
|
||||
.index("by_daemonId_and_createdAt", ["daemonId", "createdAt"])
|
||||
.index("by_commandId_and_createdAt", ["commandId", "createdAt"]),
|
||||
projects: defineTable({
|
||||
ownerId: v.string(),
|
||||
provider: v.literal("github"),
|
||||
providerRepoId: v.number(),
|
||||
organizationId: v.id("organizations"),
|
||||
name: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
repoOwner: v.string(),
|
||||
repoName: v.string(),
|
||||
repoUrl: v.string(),
|
||||
defaultBranch: v.string(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
}).index("by_organization_and_createdAt", ["organizationId", "createdAt"]),
|
||||
projectSources: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
kind: v.literal("git"),
|
||||
url: v.string(),
|
||||
normalizedUrl: v.string(),
|
||||
host: v.string(),
|
||||
repositoryPath: v.string(),
|
||||
defaultBranch: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_owner_and_createdAt", ["ownerId", "createdAt"])
|
||||
.index("by_owner_and_repository", [
|
||||
"ownerId",
|
||||
"provider",
|
||||
"repoOwner",
|
||||
"repoName",
|
||||
]),
|
||||
.index("by_project", ["projectId"])
|
||||
.index("by_organization_and_normalizedUrl", [
|
||||
"organizationId",
|
||||
"normalizedUrl",
|
||||
])
|
||||
.index("by_organization_and_createdAt", ["organizationId", "createdAt"]),
|
||||
projectContextDocuments: defineTable({
|
||||
organizationId: v.id("organizations"),
|
||||
projectId: v.id("projects"),
|
||||
kind: v.union(
|
||||
v.literal("readme"),
|
||||
v.literal("agents"),
|
||||
v.literal("product"),
|
||||
v.literal("business"),
|
||||
v.literal("design"),
|
||||
v.literal("tech")
|
||||
),
|
||||
path: v.string(),
|
||||
content: v.string(),
|
||||
revision: v.number(),
|
||||
origin: v.union(
|
||||
v.literal("repository"),
|
||||
v.literal("paste"),
|
||||
v.literal("upload"),
|
||||
v.literal("public-url")
|
||||
),
|
||||
sourceUrl: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_project", ["projectId"])
|
||||
.index("by_project_and_kind", ["projectId", "kind"])
|
||||
.index("by_project_and_path", ["projectId", "path"]),
|
||||
projectArtifacts: defineTable({
|
||||
projectId: v.id("projects"),
|
||||
path: v.string(),
|
||||
@@ -165,6 +197,7 @@ export default defineSchema({
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_userId", ["userId"])
|
||||
.index("by_organizationId", ["organizationId"])
|
||||
.index("by_organizationId_and_userId", ["organizationId", "userId"]),
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@@ -44,14 +44,6 @@ const problemStatement = {
|
||||
|
||||
const processedBy = { agentInstanceId: "zopu-instance-1", agentName: "zopu" };
|
||||
|
||||
const repository = {
|
||||
defaultBranch: "main",
|
||||
id: 12345,
|
||||
name: "zopu",
|
||||
owner: "puter",
|
||||
url: "https://github.com/puter/zopu",
|
||||
};
|
||||
|
||||
// Begin and admit one user message, returning its messageId.
|
||||
const seedMessage = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
@@ -79,13 +71,37 @@ const seedMessage = async (
|
||||
|
||||
const createProject = async (
|
||||
t: TestConvex<typeof schema>,
|
||||
ownerId: string,
|
||||
repoId = 12345
|
||||
ownerId: string
|
||||
): Promise<Id<"projects">> => {
|
||||
return await t.mutation(internal.projects.storeGitHubProject, {
|
||||
ownerId,
|
||||
repository: { ...repository, id: repoId },
|
||||
});
|
||||
// Resolve or create the owner's personal organization
|
||||
const ownerIdentity = { tokenIdentifier: ownerId };
|
||||
await t
|
||||
.withIdentity(ownerIdentity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization);
|
||||
const outcome = await t
|
||||
.withIdentity(ownerIdentity)
|
||||
.mutation(internal.projects.persistPublicGitImport, {
|
||||
userId: ownerId,
|
||||
source: {
|
||||
host: "github.com",
|
||||
projectName: "test-repo",
|
||||
repositoryPath: `owner-${ownerId.slice(-4)}/test-repo`,
|
||||
normalizedUrl: `https://github.com/owner-${ownerId.slice(-4)}/test-repo`,
|
||||
url: `https://github.com/owner-${ownerId.slice(-4)}/test-repo`,
|
||||
},
|
||||
remote: {
|
||||
defaultBranch: "main",
|
||||
documents: [
|
||||
{
|
||||
kind: "readme" as const,
|
||||
path: "README.md",
|
||||
content: "# test-repo\n\nTest.\n",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
});
|
||||
return outcome.id as unknown as Id<"projects">;
|
||||
};
|
||||
|
||||
describe("signals", () => {
|
||||
|
||||
@@ -161,13 +161,9 @@ const resolveSources = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve whether an optional project belongs to the same organization. Until
|
||||
* projects carry an explicit `organizationId`, a project is attachable to an
|
||||
* organization's signal only when the project owner is a member of that
|
||||
* organization. The caller is already proven to be a member, so this is a
|
||||
* security-preserving temporary mapping: an organization's project set is the
|
||||
* intersection of (caller is a member) and (project owner is a member), which
|
||||
* cannot weaken cross-tenant isolation.
|
||||
* Verify a project belongs to the same organization. Projects now carry an
|
||||
* explicit `organizationId`; the caller is already proven to be a member, so
|
||||
* this is a direct invariant check with no cross-tenant leakage.
|
||||
*/
|
||||
const authorizeProject = async (
|
||||
ctx: MutationCtx,
|
||||
@@ -178,13 +174,7 @@ const authorizeProject = async (
|
||||
if (!project) {
|
||||
throw new ConvexError("Project not found");
|
||||
}
|
||||
const ownerMembership = await ctx.db
|
||||
.query("organizationMembers")
|
||||
.withIndex("by_organizationId_and_userId", (q) =>
|
||||
q.eq("organizationId", organizationId).eq("userId", project.ownerId)
|
||||
)
|
||||
.unique();
|
||||
if (!ownerMembership) {
|
||||
if (project.organizationId !== organizationId) {
|
||||
throw new ConvexError("Project does not belong to the target organization");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"scripts": {
|
||||
"dev": "convex dev --env-file ../../.env",
|
||||
"dev:setup": "convex dev --env-file ../../.env --configure --until-success",
|
||||
"check-types": "tsc --noEmit -p convex/tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./agent-os": "./src/agent-os.ts",
|
||||
"./project": "./src/project.ts",
|
||||
"./signal": "./src/signal.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
|
||||
export * from "./agent-os";
|
||||
export * from "./project";
|
||||
export * from "./signal";
|
||||
|
||||
762
packages/primitives/src/project.ts
Normal file
762
packages/primitives/src/project.ts
Normal 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));
|
||||
}),
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user