intial files

This commit is contained in:
sai karthik
2026-07-23 00:19:53 +05:30
parent 677a355397
commit 74a209a807
45 changed files with 1941 additions and 1016 deletions

View File

@@ -0,0 +1,37 @@
{
"name": "@code/auth",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./native": "./src/native/index.ts",
"./server": "./src/server/index.ts",
"./web": "./src/web/index.ts"
},
"scripts": {
"check-types": "tsc --noEmit"
},
"dependencies": {
"@better-auth/expo": "catalog:",
"@code/backend": "workspace:*",
"@code/env": "workspace:*",
"@code/ui": "workspace:*",
"@convex-dev/better-auth": "catalog:",
"@tanstack/react-form": "catalog:",
"better-auth": "catalog:",
"convex": "catalog:",
"expo-constants": "~57.0.2",
"expo-secure-store": "~57.0.0",
"heroui-native": "^1.0.5",
"react": "^19.2.3",
"react-native": "0.86.0",
"sonner": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@code/config": "workspace:*",
"@types/react": "~19.2.17",
"vite": "^7.3.6",
"typescript": "^6"
}
}

View File

@@ -0,0 +1,23 @@
import { expoClient } from "@better-auth/expo/client";
import { env } from "@code/env/native";
import { convexClient, crossDomainClient } from "@convex-dev/better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import Constants from "expo-constants";
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
const scheme = Constants.expoConfig?.scheme;
if (typeof scheme !== "string") {
throw new Error("Expo auth requires a string app scheme in app.json");
}
export const authClient = createAuthClient({
baseURL: env.EXPO_PUBLIC_CONVEX_SITE_URL,
plugins: [
convexClient(),
Platform.OS === "web"
? crossDomainClient()
: expoClient({ scheme, storage: SecureStore, storagePrefix: scheme }),
],
});

View File

@@ -0,0 +1,16 @@
import { env } from "@code/env/native";
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { ConvexReactClient } from "convex/react";
import type { ReactNode } from "react";
import { authClient } from "./auth-client";
const convex = new ConvexReactClient(env.EXPO_PUBLIC_CONVEX_URL, {
unsavedChangesWarning: false,
});
export const NativeAuthProvider = ({ children }: { children: ReactNode }) => (
<ConvexBetterAuthProvider authClient={authClient} client={convex}>
{children}
</ConvexBetterAuthProvider>
);

View File

@@ -0,0 +1,62 @@
import { useForm } from "@tanstack/react-form";
import { useToast } from "heroui-native";
import { getErrorMessage, signInSchema, signUpSchema } from "../shared/forms";
import { authClient } from "./auth-client";
type AuthFormOptions = {
readonly onSuccess?: () => void;
};
export const useNativeSignInForm = ({ onSuccess }: AuthFormOptions = {}) => {
const { toast } = useToast();
return useForm({
defaultValues: { email: "", password: "" },
onSubmit: async ({ formApi, value }) => {
await authClient.signIn.email(
{ email: value.email.trim(), password: value.password },
{
onError: ({ error }) => {
toast.show({ label: error.message || "Failed to sign in", variant: "danger" });
},
onSuccess: () => {
formApi.reset();
toast.show({ label: "Signed in successfully", variant: "success" });
onSuccess?.();
},
},
);
},
validators: { onSubmit: signInSchema },
});
};
export const useNativeSignUpForm = ({ onSuccess }: AuthFormOptions = {}) => {
const { toast } = useToast();
return useForm({
defaultValues: { confirmPassword: "", email: "", name: "", password: "" },
onSubmit: async ({ formApi, value }) => {
await authClient.signUp.email(
{ email: value.email.trim(), name: value.name.trim(), password: value.password },
{
onError: ({ error }) => {
toast.show({
label: error.message || "Failed to create account",
variant: "danger",
});
},
onSuccess: () => {
formApi.reset();
toast.show({ label: "Account created successfully", variant: "success" });
onSuccess?.();
},
},
);
},
validators: { onSubmit: signUpSchema },
});
};
export { getErrorMessage };

View File

@@ -0,0 +1,4 @@
export { authClient } from "./auth-client";
export { NativeAuthProvider } from "./auth-provider";
export { NativeLoginForm } from "./login-form";
export { NativeSignupForm } from "./signup-form";

View File

@@ -0,0 +1,107 @@
import {
Button,
FieldError,
Input,
Label,
Spinner,
Surface,
TextField,
useThemeColor,
} from "heroui-native";
import { useRef } from "react";
import { Text, type TextInput, View } from "react-native";
import { getErrorMessage, useNativeSignInForm } from "./hooks";
type NativeLoginFormProps = {
readonly onNavigateSignUp: () => void;
readonly onSuccess?: () => void;
};
export const NativeLoginForm = ({
onNavigateSignUp,
onSuccess,
}: NativeLoginFormProps) => {
const passwordInputRef = useRef<TextInput>(null);
const form = useNativeSignInForm({ onSuccess });
const foregroundColor = useThemeColor("foreground");
const mutedColor = useThemeColor("muted");
return (
<Surface className="rounded-xl p-5" variant="secondary">
<Text style={{ color: foregroundColor, fontSize: 24, fontWeight: "600", marginBottom: 4 }}>
Welcome back
</Text>
<Text style={{ color: mutedColor, fontSize: 14, marginBottom: 20 }}>
Sign in with your email and password.
</Text>
<form.Subscribe
selector={(state) => ({
isSubmitting: state.isSubmitting,
submitError: getErrorMessage(state.errorMap.onSubmit),
})}
>
{({ isSubmitting, submitError }) => (
<View style={{ gap: 16 }}>
<FieldError className="mb-1" isInvalid={!!submitError}>
{submitError}
</FieldError>
<form.Field name="email">
{(field) => (
<TextField>
<Label>Email</Label>
<Input
autoCapitalize="none"
autoComplete="email"
blurOnSubmit={false}
keyboardType="email-address"
onBlur={field.handleBlur}
onChangeText={field.handleChange}
onSubmitEditing={() => passwordInputRef.current?.focus()}
placeholder="you@example.com"
returnKeyType="next"
textContentType="emailAddress"
value={field.state.value}
/>
</TextField>
)}
</form.Field>
<form.Field name="password">
{(field) => (
<TextField>
<Label>Password</Label>
<Input
autoComplete="password"
onBlur={field.handleBlur}
onChangeText={field.handleChange}
onSubmitEditing={() => void form.handleSubmit()}
ref={passwordInputRef}
returnKeyType="go"
secureTextEntry
textContentType="password"
value={field.state.value}
/>
</TextField>
)}
</form.Field>
<Button
className="mt-1"
isDisabled={isSubmitting}
onPress={() => void form.handleSubmit()}
>
{isSubmitting ? <Spinner color="default" size="sm" /> : <Button.Label>Sign in</Button.Label>}
</Button>
<Button onPress={onNavigateSignUp} variant="ghost">
<Button.Label>Need an account? Sign up</Button.Label>
</Button>
</View>
)}
</form.Subscribe>
</Surface>
);
};

View File

@@ -0,0 +1,138 @@
import {
Button,
FieldError,
Input,
Label,
Spinner,
Surface,
TextField,
useThemeColor,
} from "heroui-native";
import { Text, View } from "react-native";
import { getErrorMessage, useNativeSignUpForm } from "./hooks";
type NativeSignupFormProps = {
readonly onNavigateSignIn: () => void;
readonly onSuccess?: () => void;
};
export const NativeSignupForm = ({
onNavigateSignIn,
onSuccess,
}: NativeSignupFormProps) => {
const form = useNativeSignUpForm({ onSuccess });
const foregroundColor = useThemeColor("foreground");
const mutedColor = useThemeColor("muted");
return (
<Surface className="rounded-xl p-5" variant="secondary">
<Text style={{ color: foregroundColor, fontSize: 24, fontWeight: "600", marginBottom: 4 }}>
Create an account
</Text>
<Text style={{ color: mutedColor, fontSize: 14, marginBottom: 20 }}>
Use your name, email, and password.
</Text>
<form.Subscribe
selector={(state) => ({
isSubmitting: state.isSubmitting,
submitError: getErrorMessage(state.errorMap.onSubmit),
})}
>
{({ isSubmitting, submitError }) => (
<View style={{ gap: 16 }}>
<FieldError className="mb-1" isInvalid={!!submitError}>
{submitError}
</FieldError>
<form.Field name="name">
{(field) => (
<TextField>
<Label>Full name</Label>
<Input
autoCapitalize="words"
autoComplete="name"
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="Jane Doe"
textContentType="name"
value={field.state.value}
/>
</TextField>
)}
</form.Field>
<form.Field name="email">
{(field) => (
<TextField>
<Label>Email</Label>
<Input
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="you@example.com"
textContentType="emailAddress"
value={field.state.value}
/>
</TextField>
)}
</form.Field>
<form.Field name="password">
{(field) => (
<TextField>
<Label>Password</Label>
<Input
autoComplete="new-password"
onBlur={field.handleBlur}
onChangeText={field.handleChange}
secureTextEntry
textContentType="newPassword"
value={field.state.value}
/>
</TextField>
)}
</form.Field>
<form.Field name="confirmPassword">
{(field) => (
<TextField>
<Label>Confirm password</Label>
<Input
autoComplete="new-password"
onBlur={field.handleBlur}
onChangeText={field.handleChange}
onSubmitEditing={() => void form.handleSubmit()}
returnKeyType="go"
secureTextEntry
textContentType="newPassword"
value={field.state.value}
/>
</TextField>
)}
</form.Field>
<Button
className="mt-1"
isDisabled={isSubmitting}
onPress={() => void form.handleSubmit()}
>
{isSubmitting ? (
<Spinner color="default" size="sm" />
) : (
<Button.Label>Create account</Button.Label>
)}
</Button>
<Button onPress={onNavigateSignIn} variant="ghost">
<Button.Label>Already have an account? Sign in</Button.Label>
</Button>
</View>
)}
</form.Subscribe>
</Surface>
);
};

View File

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

View File

@@ -0,0 +1,38 @@
import { z } from "zod";
export const signInSchema = z.object({
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"),
password: z.string().min(1, "Password is required").min(8, "Use at least 8 characters"),
});
export const signUpSchema = z
.object({
confirmPassword: z.string().min(1, "Confirm your password"),
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"),
name: z.string().trim().min(2, "Name must be at least 2 characters"),
password: z.string().min(1, "Password is required").min(8, "Use at least 8 characters"),
})
.refine(({ confirmPassword, password }) => confirmPassword === password, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
export const getErrorMessage = (error: unknown): string | null => {
if (!error) return null;
if (typeof error === "string") return error;
if (Array.isArray(error)) {
for (const issue of error) {
const message = getErrorMessage(issue);
if (message) return message;
}
return null;
}
if (typeof error === "object" && error !== null && "message" in error) {
const { message } = error as { message?: unknown };
return typeof message === "string" ? message : null;
}
return null;
};

View File

@@ -0,0 +1,8 @@
import { env } from "@code/env/web";
import { convexClient, crossDomainClient } from "@convex-dev/better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: env.VITE_CONVEX_SITE_URL,
plugins: [convexClient(), crossDomainClient()],
});

View File

@@ -0,0 +1,14 @@
import { env } from "@code/env/web";
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { ConvexReactClient } from "convex/react";
import type { ReactNode } from "react";
import { authClient } from "./auth-client";
const convex = new ConvexReactClient(env.VITE_CONVEX_URL);
export const WebAuthProvider = ({ children }: { children: ReactNode }) => (
<ConvexBetterAuthProvider authClient={authClient} client={convex}>
{children}
</ConvexBetterAuthProvider>
);

View File

@@ -0,0 +1,51 @@
import { useForm } from "@tanstack/react-form";
import { toast } from "sonner";
import { signInSchema, signUpSchema } from "../shared/forms";
import { authClient } from "./auth-client";
type AuthFormOptions = {
readonly onSuccess?: () => void;
};
export const useSignInForm = ({ onSuccess }: AuthFormOptions = {}) =>
useForm({
defaultValues: { email: "", password: "" },
onSubmit: async ({ formApi, value }) => {
await authClient.signIn.email(
{ email: value.email.trim(), password: value.password },
{
onError: ({ error }) => {
toast.error(error.message || error.statusText);
},
onSuccess: () => {
formApi.reset();
toast.success("Signed in successfully");
onSuccess?.();
},
},
);
},
validators: { onSubmit: signInSchema },
});
export const useSignUpForm = ({ onSuccess }: AuthFormOptions = {}) =>
useForm({
defaultValues: { confirmPassword: "", email: "", name: "", password: "" },
onSubmit: async ({ formApi, value }) => {
await authClient.signUp.email(
{ email: value.email.trim(), name: value.name.trim(), password: value.password },
{
onError: ({ error }) => {
toast.error(error.message || error.statusText);
},
onSuccess: () => {
formApi.reset();
toast.success("Account created successfully");
onSuccess?.();
},
},
);
},
validators: { onSubmit: signUpSchema },
});

View File

@@ -0,0 +1,4 @@
export { authClient } from "./auth-client";
export { WebAuthProvider } from "./auth-provider";
export { LoginForm } from "./login-form";
export { SignupForm } from "./signup-form";

View File

@@ -0,0 +1,114 @@
import { Button } from "@code/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@code/ui/components/card";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@code/ui/components/field";
import { Input } from "@code/ui/components/input";
import { cn } from "@code/ui/lib/utils";
import type { ComponentProps } from "react";
import { getErrorMessage } from "../shared/forms";
import { useSignInForm } from "./hooks";
type LoginFormProps = ComponentProps<"div"> & {
readonly onSuccess?: () => void;
readonly signUpHref?: string;
};
export const LoginForm = ({
className,
onSuccess,
signUpHref = "/signup",
...props
}: LoginFormProps) => {
const form = useSignInForm({ onSuccess });
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader>
<CardTitle>Welcome back</CardTitle>
<CardDescription>Enter your email and password to continue.</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<FieldGroup>
<form.Field name="email">
{(field) => (
<Field data-invalid={!field.state.meta.isValid}>
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
<Input
autoComplete="email"
id={field.name}
name={field.name}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
placeholder="you@example.com"
type="email"
value={field.state.value}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Field name="password">
{(field) => (
<Field data-invalid={!field.state.meta.isValid}>
<FieldLabel htmlFor={field.name}>Password</FieldLabel>
<Input
autoComplete="current-password"
id={field.name}
name={field.name}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
type="password"
value={field.state.value}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Subscribe
selector={(state) => ({
canSubmit: state.canSubmit,
isSubmitting: state.isSubmitting,
submitError: getErrorMessage(state.errorMap.onSubmit),
})}
>
{({ canSubmit, isSubmitting, submitError }) => (
<Field>
<FieldError>{submitError}</FieldError>
<Button disabled={!canSubmit || isSubmitting} type="submit">
{isSubmitting ? "Signing in…" : "Sign in"}
</Button>
<FieldDescription className="text-center">
Don&apos;t have an account? <a href={signUpHref}>Sign up</a>
</FieldDescription>
</Field>
)}
</form.Subscribe>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,147 @@
import { Button } from "@code/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@code/ui/components/card";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@code/ui/components/field";
import { Input } from "@code/ui/components/input";
import type { ComponentProps } from "react";
import { getErrorMessage } from "../shared/forms";
import { useSignUpForm } from "./hooks";
type SignupFormProps = ComponentProps<typeof Card> & {
readonly onSuccess?: () => void;
readonly signInHref?: string;
};
export const SignupForm = ({
onSuccess,
signInHref = "/login",
...props
}: SignupFormProps) => {
const form = useSignUpForm({ onSuccess });
return (
<Card {...props}>
<CardHeader>
<CardTitle>Create an account</CardTitle>
<CardDescription>Use your name, email, and password to get started.</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<FieldGroup>
<form.Field name="name">
{(field) => (
<Field data-invalid={!field.state.meta.isValid}>
<FieldLabel htmlFor={field.name}>Full name</FieldLabel>
<Input
autoComplete="name"
id={field.name}
name={field.name}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
placeholder="Jane Doe"
value={field.state.value}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Field name="email">
{(field) => (
<Field data-invalid={!field.state.meta.isValid}>
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
<Input
autoComplete="email"
id={field.name}
name={field.name}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
placeholder="you@example.com"
type="email"
value={field.state.value}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Field name="password">
{(field) => (
<Field data-invalid={!field.state.meta.isValid}>
<FieldLabel htmlFor={field.name}>Password</FieldLabel>
<Input
autoComplete="new-password"
id={field.name}
name={field.name}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
type="password"
value={field.state.value}
/>
<FieldDescription>Use at least 8 characters.</FieldDescription>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Field name="confirmPassword">
{(field) => (
<Field data-invalid={!field.state.meta.isValid}>
<FieldLabel htmlFor={field.name}>Confirm password</FieldLabel>
<Input
autoComplete="new-password"
id={field.name}
name={field.name}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
type="password"
value={field.state.value}
/>
<FieldError errors={field.state.meta.errors} />
</Field>
)}
</form.Field>
<form.Subscribe
selector={(state) => ({
canSubmit: state.canSubmit,
isSubmitting: state.isSubmitting,
submitError: getErrorMessage(state.errorMap.onSubmit),
})}
>
{({ canSubmit, isSubmitting, submitError }) => (
<Field>
<FieldError>{submitError}</FieldError>
<Button disabled={!canSubmit || isSubmitting} type="submit">
{isSubmitting ? "Creating account…" : "Create account"}
</Button>
<FieldDescription className="text-center">
Already have an account? <a href={signInHref}>Sign in</a>
</FieldDescription>
</Field>
)}
</form.Subscribe>
</FieldGroup>
</form>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,10 @@
{
"extends": "@code/config/tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["node", "vite/client"],
"noEmit": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,94 @@
import { v } from "convex/values";
export const ARTIFACT_PATHS = [
"project.md",
"business.md",
"design.md",
"agent.md",
"work.md",
"steps.md",
"artifacts.md",
"signals.md",
"agent-manager.md",
"context.md",
"card.md",
] as const;
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"),
v.literal("artifacts.md"),
v.literal("signals.md"),
v.literal("agent-manager.md"),
v.literal("context.md"),
v.literal("card.md")
);
type ProjectSeed = {
readonly defaultBranch: string;
readonly description?: string;
readonly name: string;
readonly repoName: string;
readonly repoOwner: string;
readonly repoUrl: string;
};
export const createInitialArtifacts = (
project: ProjectSeed
): ReadonlyArray<{ path: ArtifactPath; content: string }> => {
const repository = `${project.repoOwner}/${project.repoName}`;
const purpose = project.description ?? `Maintain and improve ${repository}.`;
return [
{
path: "project.md",
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: "business.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: "design.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: "agent.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: "work.md",
content: "# Work\n\nNo issue has entered the agent queue yet. New issues are appended here by the control plane.\n",
},
{
path: "steps.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: "artifacts.md",
content: "# Artifacts\n\nThis file indexes concrete outputs from issue runs, including changed paths, command results, and preview links.\n",
},
{
path: "signals.md",
content: "# Signals\n\nThe agent manager emits `issue.queued`, `agent.started`, `agent.needs-input`, `agent.completed`, and `agent.failed`. Signals are durable project events and drive the web status.\n",
},
{
path: "agent-manager.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: "context.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: "card.md",
content: "# Project card\n\nThe web project card is the interface between UI and packages. It reads `projects.list`, `projectArtifacts.list`, and `projectIssues.list`; it writes through `projects.connectGitHub`, `projectIssues.create`, and `projectIssues.begin`. The issue-scoped Flue agent reads `agentWorkspace.get`, publishes with `agentWorkspace.updateArtifact`, and reports state with `agentWorkspace.setStatus`. Filesystem and shell operations are executed by the AgentOS sandbox adapter through durable daemon commands.\n",
},
];
};

View File

@@ -0,0 +1,17 @@
import { ConvexError } from "convex/values";
export type AuthContext = {
readonly auth: {
readonly getUserIdentity: () => Promise<{
readonly tokenIdentifier: string;
} | null>;
};
};
export const requireOwnerId = async (ctx: AuthContext): Promise<string> => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new ConvexError("Authentication required");
}
return identity.tokenIdentifier;
};

View File

@@ -0,0 +1,19 @@
import { v } from "convex/values";
import { query } from "./_generated/server";
import { requireOwnerId } 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) {
return [];
}
return await ctx.db
.query("projectArtifacts")
.withIndex("by_project", (q) => q.eq("projectId", args.projectId))
.take(25);
},
});

View File

@@ -0,0 +1,145 @@
import { ConvexError, v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { requireOwnerId } from "./authz";
const requireProjectOwner = async (
ctx: Parameters<Parameters<typeof mutation>[0]["handler"]>[0],
projectId: Parameters<typeof ctx.db.get<"projects">>[1],
ownerId: string
) => {
const project = await ctx.db.get("projects", projectId);
if (!project || project.ownerId !== ownerId) {
throw new ConvexError("Project not found");
}
return project;
};
export const create = mutation({
args: {
projectId: v.id("projects"),
title: v.string(),
body: v.string(),
},
handler: async (ctx, args) => {
const ownerId = await requireOwnerId(ctx);
await requireProjectOwner(ctx, args.projectId, ownerId);
const title = args.title.trim();
const body = args.body.trim();
if (title.length < 3 || title.length > 160) {
throw new ConvexError("Issue title must be between 3 and 160 characters");
}
if (body.length < 10 || body.length > 10_000) {
throw new ConvexError("Issue description must be between 10 and 10000 characters");
}
const latest = await ctx.db
.query("projectIssues")
.withIndex("by_project_and_number", (q) =>
q.eq("projectId", args.projectId)
)
.order("desc")
.first();
const timestamp = Date.now();
const number = (latest?.number ?? 0) + 1;
const issueId = await ctx.db.insert("projectIssues", {
projectId: args.projectId,
number,
title,
body,
status: "open",
createdAt: timestamp,
updatedAt: timestamp,
});
const workArtifact = await ctx.db
.query("projectArtifacts")
.withIndex("by_project_and_path", (q) =>
q.eq("projectId", args.projectId).eq("path", "work.md")
)
.unique();
if (workArtifact) {
await ctx.db.patch("projectArtifacts", workArtifact._id, {
content: `${workArtifact.content}\n## Issue ${number}: ${title}\n\nStatus: open\n\n${body}\n`,
revision: workArtifact.revision + 1,
updatedAt: timestamp,
});
}
await ctx.db.insert("projectSignals", {
projectId: args.projectId,
issueId,
kind: "issue.created",
data: { number, title },
createdAt: timestamp,
});
return issueId;
},
});
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);
if (issue.status === "queued" || issue.status === "working") {
return issue.status;
}
const timestamp = Date.now();
await ctx.db.patch("projectIssues", issue._id, {
status: "queued",
updatedAt: timestamp,
});
await ctx.db.insert("projectSignals", {
projectId: issue.projectId,
issueId: issue._id,
kind: "issue.queued",
data: { number: issue.number },
createdAt: timestamp,
});
return "queued" as const;
},
});
export const markDispatchFailed = mutation({
args: { issueId: v.id("projectIssues"), error: v.string() },
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);
const timestamp = Date.now();
await ctx.db.patch("projectIssues", issue._id, {
status: "failed",
updatedAt: timestamp,
});
await ctx.db.insert("projectSignals", {
projectId: issue.projectId,
issueId: issue._id,
kind: "agent.failed",
data: { error: args.error.slice(0, 1000), phase: "dispatch" },
createdAt: timestamp,
});
},
});
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) {
return [];
}
return await ctx.db
.query("projectIssues")
.withIndex("by_project_and_number", (q) =>
q.eq("projectId", args.projectId)
)
.order("desc")
.take(100);
},
});

View File

@@ -0,0 +1,184 @@
import { ConvexError, v } from "convex/values";
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";
type GitHubRepository = {
readonly defaultBranch: string;
readonly description?: string;
readonly id: number;
readonly name: string;
readonly owner: string;
readonly url: string;
};
const parseRepository = (value: string): { owner: string; repo: string } => {
const normalized = value.trim().replace(/\.git$/u, "");
const match = normalized.match(
/^(?:https?:\/\/github\.com\/)?([^/\s]+)\/([^/\s]+)$/iu
);
if (!match?.[1] || !match[2]) {
throw new ConvexError("Use a GitHub repository in owner/name format");
}
return { owner: match[1], repo: match[2] };
};
const readGitHubRepository = (value: unknown): GitHubRepository => {
if (typeof value !== "object" || value === null) {
throw new ConvexError("GitHub returned an invalid repository response");
}
const record = value as Record<string, unknown>;
const owner = record.owner;
if (
typeof record.id !== "number" ||
typeof record.name !== "string" ||
typeof record.html_url !== "string" ||
typeof record.default_branch !== "string" ||
typeof owner !== "object" ||
owner === null ||
typeof (owner as Record<string, unknown>).login !== "string"
) {
throw new ConvexError("GitHub returned incomplete repository metadata");
}
return {
defaultBranch: record.default_branch,
...(typeof record.description === "string"
? { description: record.description }
: {}),
id: record.id,
name: record.name,
owner: (owner as { login: string }).login,
url: record.html_url,
};
};
export const connectGitHub = action({
args: { repository: v.string() },
handler: async (ctx, args): Promise<Id<"projects">> => {
const ownerId = await requireOwnerId(ctx);
const { owner, repo } = parseRepository(args.repository);
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "zopu-code",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!response.ok) {
if (response.status === 404) {
throw new ConvexError(
"Repository not found. This first flow supports public GitHub repositories."
);
}
throw new ConvexError(`GitHub connection failed with ${response.status}`);
}
const repository = readGitHubRepository(await response.json());
const projectId: Id<"projects"> = await ctx.runMutation(
internal.projects.storeGitHubProject,
{ ownerId, repository }
);
return projectId;
},
});
export const storeGitHubProject = internalMutation({
args: {
ownerId: v.string(),
repository: v.object({
defaultBranch: v.string(),
description: v.optional(v.string()),
id: v.number(),
name: v.string(),
owner: v.string(),
url: v.string(),
}),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("projects")
.withIndex("by_owner_and_repository", (q) =>
q
.eq("ownerId", args.ownerId)
.eq("provider", "github")
.eq("repoOwner", args.repository.owner)
.eq("repoName", args.repository.name)
)
.unique();
const timestamp = Date.now();
if (existing) {
await ctx.db.patch("projects", existing._id, {
defaultBranch: args.repository.defaultBranch,
description: args.repository.description,
providerRepoId: args.repository.id,
repoUrl: args.repository.url,
updatedAt: timestamp,
});
return existing._id;
}
const projectId = await ctx.db.insert("projects", {
ownerId: args.ownerId,
provider: "github",
providerRepoId: args.repository.id,
name: args.repository.name,
description: args.repository.description,
repoOwner: args.repository.owner,
repoName: args.repository.name,
repoUrl: args.repository.url,
defaultBranch: args.repository.defaultBranch,
createdAt: timestamp,
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", {
projectId,
path,
content,
revision: 1,
createdAt: timestamp,
updatedAt: timestamp,
})
)
);
await ctx.db.insert("projectSignals", {
projectId,
kind: "project.connected",
data: { provider: "github", repository: args.repository.url },
createdAt: timestamp,
});
return projectId;
},
});
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);
},
});
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;
},
});

View File

@@ -62,6 +62,86 @@ 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(),
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_owner_and_createdAt", ["ownerId", "createdAt"])
.index("by_owner_and_repository", [
"ownerId",
"provider",
"repoOwner",
"repoName",
]),
projectArtifacts: defineTable({
projectId: v.id("projects"),
path: v.string(),
content: v.string(),
revision: v.number(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_project", ["projectId"])
.index("by_project_and_path", ["projectId", "path"]),
projectIssues: defineTable({
projectId: v.id("projects"),
number: v.number(),
title: v.string(),
body: v.string(),
status: v.union(
v.literal("open"),
v.literal("queued"),
v.literal("working"),
v.literal("needs-input"),
v.literal("completed"),
v.literal("failed")
),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_project_and_number", ["projectId", "number"])
.index("by_project_and_status", ["projectId", "status"]),
projectWorkRuns: defineTable({
projectId: v.id("projects"),
issueId: v.id("projectIssues"),
agentId: v.string(),
daemonId: v.string(),
actorKey: v.array(v.string()),
status: v.union(
v.literal("ready"),
v.literal("queued"),
v.literal("working"),
v.literal("needs-input"),
v.literal("completed"),
v.literal("failed")
),
summary: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
startedAt: v.optional(v.number()),
completedAt: v.optional(v.number()),
})
.index("by_issue", ["issueId"])
.index("by_project_and_createdAt", ["projectId", "createdAt"]),
projectSignals: defineTable({
projectId: v.id("projects"),
issueId: v.optional(v.id("projectIssues")),
runId: v.optional(v.id("projectWorkRuns")),
kind: v.string(),
data: v.any(),
createdAt: v.number(),
})
.index("by_project_and_createdAt", ["projectId", "createdAt"])
.index("by_issue_and_createdAt", ["issueId", "createdAt"]),
todos: defineTable({
text: v.string(),
completed: v.boolean(),

View File

@@ -0,0 +1,236 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@code/ui/lib/utils"
import { Label } from "@code/ui/components/label"
import { Separator } from "@code/ui/components/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-2.5 font-medium data-[variant=label]:text-xs data-[variant=legend]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-none has-[>[data-slot=field]]:border *:data-[slot=field]:p-2 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-xs/relaxed group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-xs/relaxed leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-xs group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-xs font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View File

@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@code/ui/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }